Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/reference/package-apis/drivers/ble.md
2 changes: 2 additions & 0 deletions docs/source/reference/package-apis/drivers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Drivers that control the power state and basic operation of devices:

Drivers that provide various communication interfaces:

* **[BLE](ble.md)** (`jumpstarter-driver-ble`) - Bluetooth Low Energy communication
* **[CAN](can.md)** (`jumpstarter-driver-can`) - Controller Area Network
communication
* **[HTTP](http.md)** (`jumpstarter-driver-http`) - HTTP communication
Expand Down Expand Up @@ -84,6 +85,7 @@ General-purpose utility drivers:

```{toctree}
:hidden:
ble.md
can.md
corellium.md
dutlink.md
Expand Down
1 change: 1 addition & 0 deletions packages/jumpstarter-all/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies = [
"jumpstarter-cli-admin",
"jumpstarter-cli-common",
"jumpstarter-cli-driver",
"jumpstarter-driver-ble",
"jumpstarter-driver-can",
"jumpstarter-driver-composite",
"jumpstarter-driver-corellium",
Expand Down
61 changes: 61 additions & 0 deletions packages/jumpstarter-driver-ble/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Bluetooth Low Energy (BLE) driver

`jumpstarter-driver-ble` provides communication functionality via ble with the DUT.
The driver expects a ble service with a write and notify characteristic to send and receive data respectively.

## Installation

```{code-block} console
:substitutions:
$ pip3 install --extra-index-url {{index_url}} jumpstarter-driver-ble
```

## Configuration
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to add a reference in:

https://github.com/jumpstarter-dev/jumpstarter/blob/main/docs/source/reference/package-apis/drivers/index.md?plain=1#L87

Also above in the that index.md under the "communication interfaces"

And also a symbolic link from docs/source/reference/package-apis/drivers/ble.md to this README.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Example configuration:

```yaml
export:
ble:
type: "jumpstarter_driver_ble.driver.BleWriteNotifyStream"
config:
address: "00:11:22:33:44:55"
service_uuid: "0000180a-0000-1000-8000-000000000000"
write_char_uuid: "0000fe41-8e22-4541-9d4c-000000000000"
notify_char_uuid: "0000fe42-8e22-4541-9d4c-000000000000"
```

### Config parameters

| Parameter | Description | Type | Required | Default |
| ---------------- | -------------------------------------------------- | ---- | -------- | ------- |
| address | BLE address to connect to | str | yes | |
| service_uuid | BLE service uuid to connect to | str | yes | |
| write_char_uuid | BLE write characteristic to send data to DUT | str | yes | |
| notify_char_uuid | BLE notify characteristic to receive data from DUT | str | yes | |

## API Reference

```{eval-rst}
.. autoclass:: jumpstarter_driver_ble.client.BleWriteNotifyStreamClient()
:members:
```

### CLI

The ble driver client comes with a CLI tool that can be used to interact with
the target device.

```console
jumpstarter ⚡ local ➤ j ble
Usage: j ble [OPTIONS] COMMAND [ARGS]...

ble client

Options:
--help Show this message and exit.

Commands:
info Get target information
start-console Start BLE console
```
17 changes: 17 additions & 0 deletions packages/jumpstarter-driver-ble/examples/exporter.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
apiVersion: jumpstarter.dev/v1alpha1
kind: ExporterConfig
metadata:
namespace: default
name: demo
endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082
token: "<token>"
export:
ble:
type: "jumpstarter_driver_ble.driver.BleWriteNotifyStream"
config:
address: "00:11:22:33:44:55"
service_uuid: "0000180a-0000-1000-8000-000000000000"
write_char_uuid: "0000fe41-8e22-4541-9d4c-000000000000"
notify_char_uuid: "0000fe42-8e22-4541-9d4c-000000000000"


65 changes: 65 additions & 0 deletions packages/jumpstarter-driver-ble/jumpstarter_driver_ble/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from contextlib import contextmanager

import click
from jumpstarter_driver_network.adapters import PexpectAdapter
from pexpect.fdpexpect import fdspawn

from .console import BleConsole
from jumpstarter.client import DriverClient
from jumpstarter.client.decorators import driver_click_group

Comment thread
coderabbitai[bot] marked this conversation as resolved.

class BleWriteNotifyStreamClient(DriverClient):
"""
Client interface for Bluetooth Low Energy (BLE) WriteNotifyStream driver.

This client allows to communication with BLE devices, by leveraging a:
- write characteristic for sending data
- notify characteristic for receiving data
"""

def info(self) -> str:
"""Get BLE information about the target"""
return self.call("info")

def open(self) -> fdspawn:
"""
Open a pexpect session. You can find the pexpect documentation
here: https://pexpect.readthedocs.io/en/stable/api/pexpect.html#spawn-class

Returns:
fdspawn: The pexpect session object.
"""
return self.stack.enter_context(self.pexpect())

@contextmanager
def pexpect(self):
"""
Create a pexpect adapter context manager.

Yields:
PexpectAdapter: The pexpect adapter object.
"""
with PexpectAdapter(client=self) as adapter:
yield adapter

def cli(self): # noqa: C901
@driver_click_group(self)
def base():
"""ble client"""
pass

@base.command()
def info():
"""Get target information"""
print(self.info())

@base.command()
def start_console():
"""Start BLE console"""
click.echo(
"\nStarting ble console ... exit with CTRL+B x 3 times\n")
console = BleConsole(ble_client=self)
console.run()

return base
64 changes: 64 additions & 0 deletions packages/jumpstarter-driver-ble/jumpstarter_driver_ble/console.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import sys
import termios
import tty
from contextlib import contextmanager

from anyio import create_task_group
from anyio.streams.file import FileReadStream, FileWriteStream

from jumpstarter.client import DriverClient


class BleConsoleExit(Exception):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would (in a later PR, or... also feel free to do it now, ..), move the pyserial console functionality here under "console.py" https://github.com/jumpstarter-dev/jumpstarter/tree/main/packages/jumpstarter/jumpstarter/streams and, then reuse it from both places pyserial, and your driver. It is the same code, we can maintain it just once :)

The console could receive a init parameter for the banner: BLE Console, vs Serial Console , vs... something else.

Happy to help with that later, no worries.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, removing the duplicate code is a good idea. I can work on this later

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure how smart the AI (coderabbitai) is, but maybe it complains because the BLE driver does not have a EOF read function (has a pass).
In addition, the read blocks without a timeout via queue.get().
Hence, to receive an empty data set is not possible.

Should I remove the if and code block?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't worry, always take the comments from coderabbitai with a grain of salt, it is not perfect. If it's working for you keep it as it is, we can refactor code later.

pass


class BleConsole:
def __init__(self, ble_client: DriverClient):
self.ble_client = ble_client

def run(self):
with self.setraw():
self.ble_client.portal.call(self.__run)

@contextmanager
def setraw(self):
original = termios.tcgetattr(sys.stdin.fileno())
try:
tty.setraw(sys.stdin.fileno())
yield
finally:
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, original)
# Clear screen and move cursor to top-left (like \033c\033[2J\033[H).
print("\033c\033[2J\033[H", end="")

async def __run(self):
async with self.ble_client.stream_async(method="connect") as stream:
try:
async with create_task_group() as tg:
tg.start_soon(self.__ble_to_stdout, stream)
tg.start_soon(self.__stdin_to_ble, stream)
except* BleConsoleExit:
pass

async def __ble_to_stdout(self, stream):
stdout = FileWriteStream(sys.stdout.buffer)
while True:
data = await stream.receive()
await stdout.send(data)
sys.stdout.flush()

async def __stdin_to_ble(self, stream):
stdin = FileReadStream(sys.stdin.buffer)
ctrl_b_count = 0
while True:
data = await stdin.receive(max_bytes=1)
if not data:
continue
if data == b"\x02": # Ctrl-B
ctrl_b_count += 1
if ctrl_b_count == 3:
raise BleConsoleExit
else:
ctrl_b_count = 0
await stream.send(data)
Comment thread
mangelajo marked this conversation as resolved.
147 changes: 147 additions & 0 deletions packages/jumpstarter-driver-ble/jumpstarter_driver_ble/driver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass
from functools import partial

from anyio.abc import ObjectStream
from bleak import BleakClient, BleakGATTCharacteristic
from bleak.exc import BleakError

from jumpstarter.driver import Driver, export, exportstream


def _ble_notify_handler(_sender: BleakGATTCharacteristic, data: bytearray, data_queue: asyncio.Queue):
"""Notification handler that puts received data into a queue."""
try:
data_queue.put_nowait(data)
except asyncio.QueueFull:
print("Warning: Data queue is full, dropping message")


class AsyncBleConfig():
def __init__(
self,
address: str,
service_uuid: str,
write_char_uuid: str,
notify_char_uuid: str,
):
self.address = address
self.service_uuid = service_uuid
self.write_char_uuid = write_char_uuid
self.notify_char_uuid = notify_char_uuid


@dataclass(kw_only=True)
class AsyncBleWrapper(ObjectStream):
client: BleakClient
config: AsyncBleConfig
notify_queue: asyncio.Queue

async def send(self, data: bytes):
await self.client.write_gatt_char(self.config.write_char_uuid, data)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a question for later improvement,

Is there a limit in the number of bytes you can send via gatt?

May be above MTU may need chunking, or just refusing to send it (I guess we would get that via an exception anyway) :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bleak does the chunking for us.


async def receive(self):
return bytes(await self.notify_queue.get())

async def send_eof(self):
# BLE characteristics don't have an explicit EOF mechanism
pass

Comment thread
coderabbitai[bot] marked this conversation as resolved.
async def aclose(self):
await self.client.disconnect()


@dataclass(kw_only=True)
class BleWriteNotifyStream(Driver):
"""
Bluetooth Low Energy (BLE) driver for Jumpstarter
This driver connects to the specified BLE device and
provides a write (write_char_uuid) and a read (notify_char_uuid) stream
for data transfer.
"""

address: str
service_uuid: str
write_char_uuid: str
notify_char_uuid: str

def __post_init__(self):
if hasattr(super(), "__post_init__"):
super().__post_init__()

@classmethod
def client(cls) -> str:
return "jumpstarter_driver_ble.client.BleWriteNotifyStreamClient"

@export
async def info(self) -> str:
return f"""BleWriteNotifyStream Driver connected to
- Address: {self.address}
- Service UUID: {self.service_uuid}
- Write Char UUID: {self.write_char_uuid}
- Notify Char UUID: {self.notify_char_uuid}"""

async def _check_ble_characteristics(self, client: BleakClient):
"""Check if the required BLE service and characteristics are available."""
svcs = list(client.services)
for svc in svcs:
if svc.uuid == self.service_uuid:
chars_uuid = [char.uuid for char in svc.characteristics]
if self.write_char_uuid not in chars_uuid:
raise BleakError(
f"Write characteristic UUID {self.write_char_uuid} not found on device.")
if self.notify_char_uuid not in chars_uuid:
raise BleakError(
f"Notify characteristic UUID {self.notify_char_uuid} not found on device.")
return

raise BleakError(
f"Service UUID {self.service_uuid} not found on device.")

@exportstream
@asynccontextmanager
async def connect(self):
self.logger.info(
"Connecting to BLE device at Address: %s", self.address)
async with BleakClient(self.address) as client:
try:
if client.is_connected:
notify_queue = asyncio.Queue(maxsize=1000)
self.logger.info(
"Connected to BLE device at Address: %s", self.address)

# check if required characteristics are available
await self._check_ble_characteristics(client)

# register notification handler if notify_char_uuid is provided
notify_handler = partial(
_ble_notify_handler, data_queue=notify_queue)
await client.start_notify(self.notify_char_uuid, notify_handler)
self.logger.info(
"Setting up notification handler for characteristic UUID: %s", self.notify_char_uuid)

async with AsyncBleWrapper(
client=client,
notify_queue=notify_queue,
config=AsyncBleConfig(
address=self.address,
service_uuid=self.service_uuid,
write_char_uuid=self.write_char_uuid,
# read_char_uuid=self.read_char_uuid,
notify_char_uuid=self.notify_char_uuid,
),
) as stream:
yield stream
self.logger.info(
"Disconnecting from BLE device at Address: %s", self.address)

else:
self.logger.error(
"Failed to connect to BLE device at Address: %s", self.address)
raise BleakError(
f"Failed to connect to BLE device at Address: {self.address}")

except BleakError as e:
self.logger.error("Failed to connect to BLE device: %s", e)
raise
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# TODO: add some test if possible
Loading
Loading