Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit cdc37c3

Browse files
committed
feat: ble driver: added basic ble stream driver
1 parent ca77ec1 commit cdc37c3

11 files changed

Lines changed: 727 additions & 0 deletions

File tree

packages/jumpstarter-all/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies = [
1212
"jumpstarter-cli-admin",
1313
"jumpstarter-cli-common",
1414
"jumpstarter-cli-driver",
15+
"jumpstarter-driver-ble",
1516
"jumpstarter-driver-can",
1617
"jumpstarter-driver-composite",
1718
"jumpstarter-driver-corellium",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Bluetooth Low Energy (BLE) driver
2+
3+
`jumpstarter-driver-ble` provides communication functionality via ble with the DUT.
4+
The driver expects a ble service with a write and notify characteristic to send and receive data respectively.
5+
6+
## Installation
7+
8+
```{code-block} console
9+
:substitutions:
10+
$ pip3 install --extra-index-url {{index_url}} jumpstarter-driver-ble
11+
```
12+
13+
## Configuration
14+
15+
Example configuration:
16+
17+
```yaml
18+
export:
19+
ble:
20+
type: "jumpstarter_driver_ble.driver.Ble"
21+
config:
22+
address: "00:11:22:33:44:55"
23+
service_uuid: "0000180a-0000-1000-8000-000000000000"
24+
write_char_uuid: "0000fe41-8e22-4541-9d4c-000000000000"
25+
notify_char_uuid: "0000fe42-8e22-4541-9d4c-000000000000"
26+
```
27+
28+
### Config parameters
29+
30+
| Parameter | Description | Type | Required | Default |
31+
| ---------------- | -------------------------------------------------- | ---- | -------- | ------- |
32+
| address | BLE address to connect to | str | yes | |
33+
| service_uuid | BLE service uuid to connect to | str | yes | |
34+
| write_char_uuid | BLE write characteristic to send data to DUT | str | yes | |
35+
| notify_char_uuid | BLE notify characteristic to receive data from DUT | str | yes | |
36+
37+
## API Reference
38+
39+
```{eval-rst}
40+
.. autoclass:: jumpstarter_driver_ble.client.BleClient()
41+
:members:
42+
```
43+
44+
### CLI
45+
46+
The ble driver client comes with a CLI tool that can be used to interact with
47+
the target device.
48+
49+
```console
50+
jumpstarter ⚡ local ➤ j ble
51+
Usage: j ble [OPTIONS] COMMAND [ARGS]...
52+
53+
ble client
54+
55+
Options:
56+
--help Show this message and exit.
57+
58+
Commands:
59+
info Get target information
60+
start-console Start BLE console
61+
```
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
apiVersion: jumpstarter.dev/v1alpha1
2+
kind: ExporterConfig
3+
metadata:
4+
namespace: default
5+
name: demo
6+
endpoint: grpc.jumpstarter.192.168.0.203.nip.io:8082
7+
token: "<token>"
8+
export:
9+
ble:
10+
type: "jumpstarter_driver_ble.driver.Ble"
11+
config:
12+
address: "00:11:22:33:44:55"
13+
service_uuid: "0000180a-0000-1000-8000-000000000000"
14+
write_char_uuid: "0000fe41-8e22-4541-9d4c-000000000000"
15+
notify_char_uuid: "0000fe42-8e22-4541-9d4c-000000000000"
16+
17+

packages/jumpstarter-driver-ble/jumpstarter_driver_ble/__init__.py

Whitespace-only changes.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from contextlib import contextmanager
2+
3+
import click
4+
from jumpstarter_driver_network.adapters import PexpectAdapter
5+
from pexpect.fdpexpect import fdspawn
6+
7+
from .console import BleConsole
8+
from jumpstarter.client import DriverClient
9+
from jumpstarter.client.decorators import driver_click_group
10+
11+
12+
class BleClient(DriverClient):
13+
"""
14+
Client interface for Bluetooth Low Energy (BLE) driver.
15+
16+
This client allows to communication with BLE devices, by leveraging a:
17+
- write characteristic for sending data
18+
- notify characteristic for receiving data
19+
"""
20+
21+
def info(self) -> str:
22+
"""Get BLE information about the target"""
23+
return self.call("info")
24+
25+
def open(self) -> fdspawn:
26+
"""
27+
Open a pexpect session. You can find the pexpect documentation
28+
here: https://pexpect.readthedocs.io/en/stable/api/pexpect.html#spawn-class
29+
30+
Returns:
31+
fdspawn: The pexpect session object.
32+
"""
33+
return self.stack.enter_context(self.pexpect())
34+
35+
@contextmanager
36+
def pexpect(self):
37+
"""
38+
Create a pexpect adapter context manager.
39+
40+
Yields:
41+
PexpectAdapter: The pexpect adapter object.
42+
"""
43+
with PexpectAdapter(client=self) as adapter:
44+
yield adapter
45+
46+
def cli(self): # noqa: C901
47+
@driver_click_group(self)
48+
def base():
49+
"""ble client"""
50+
pass
51+
52+
@base.command()
53+
def info():
54+
"""Get target information"""
55+
print(self.info())
56+
57+
@base.command()
58+
def start_console():
59+
"""Start BLE console"""
60+
click.echo(
61+
"\nStarting ble console ... exit with CTRL+B x 3 times\n")
62+
console = BleConsole(ble_client=self)
63+
console.run()
64+
65+
return base
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import sys
2+
import termios
3+
import tty
4+
from contextlib import contextmanager
5+
6+
from anyio import create_task_group
7+
from anyio.streams.file import FileReadStream, FileWriteStream
8+
9+
from jumpstarter.client import DriverClient
10+
11+
12+
class BleConsoleExit(Exception):
13+
pass
14+
15+
16+
class BleConsole:
17+
def __init__(self, ble_client: DriverClient):
18+
self.ble_client = ble_client
19+
20+
def run(self):
21+
with self.setraw():
22+
self.ble_client.portal.call(self.__run)
23+
24+
@contextmanager
25+
def setraw(self):
26+
original = termios.tcgetattr(sys.stdin.fileno())
27+
try:
28+
tty.setraw(sys.stdin.fileno())
29+
yield
30+
finally:
31+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, original)
32+
# Clear screen and move cursor to top-left (like \033c\033[2J\033[H).
33+
print("\033c\033[2J\033[H", end="")
34+
35+
async def __run(self):
36+
async with self.ble_client.stream_async(method="connect") as stream:
37+
try:
38+
print(
39+
"BLE console connected. Press Ctrl-B three times to exit.\r\n", end="")
40+
async with create_task_group() as tg:
41+
tg.start_soon(self.__ble_to_stdout, stream)
42+
tg.start_soon(self.__stdin_to_ble, stream)
43+
except* BleConsoleExit:
44+
pass
45+
46+
async def __ble_to_stdout(self, stream):
47+
stdout = FileWriteStream(sys.stdout.buffer)
48+
while True:
49+
data = await stream.receive()
50+
await stdout.send(data)
51+
sys.stdout.flush()
52+
53+
async def __stdin_to_ble(self, stream):
54+
stdin = FileReadStream(sys.stdin.buffer)
55+
ctrl_b_count = 0
56+
while True:
57+
data = await stdin.receive(max_bytes=1)
58+
if not data:
59+
continue
60+
if data == b"\x02": # Ctrl-B
61+
ctrl_b_count += 1
62+
if ctrl_b_count == 3:
63+
raise BleConsoleExit
64+
else:
65+
ctrl_b_count = 0
66+
await stream.send(data)
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import asyncio
2+
from contextlib import asynccontextmanager
3+
from dataclasses import dataclass
4+
from functools import partial
5+
6+
from anyio.abc import ObjectStream
7+
from bleak import BleakClient, BleakGATTCharacteristic
8+
from bleak.exc import BleakError
9+
10+
from jumpstarter.driver import Driver, export, exportstream
11+
12+
13+
def _ble_notify_handler(_sender: BleakGATTCharacteristic, data: bytearray, data_queue: asyncio.Queue):
14+
"""Notification handler that puts received data into a queue."""
15+
try:
16+
data_queue.put_nowait(data)
17+
except asyncio.QueueFull:
18+
print("Warning: Data queue is full, dropping message")
19+
20+
21+
class AsyncBleConfig():
22+
def __init__(
23+
self,
24+
address: str,
25+
service_uuid: str,
26+
write_char_uuid: str,
27+
notify_char_uuid: str,
28+
):
29+
self.address = address
30+
self.service_uuid = service_uuid
31+
self.write_char_uuid = write_char_uuid
32+
self.notify_char_uuid = notify_char_uuid
33+
34+
35+
@dataclass(kw_only=True)
36+
class AsyncBleWrapper(ObjectStream):
37+
client: BleakClient
38+
config: AsyncBleConfig
39+
notify_queue: asyncio.Queue
40+
41+
async def send(self, data: bytes):
42+
await self.client.write_gatt_char(self.config.write_char_uuid, data)
43+
44+
async def receive(self):
45+
return bytes(await self.notify_queue.get())
46+
47+
async def send_eof(self):
48+
# BLE characteristics don't have an explicit EOF mechanism
49+
pass
50+
51+
async def aclose(self):
52+
await self.client.disconnect()
53+
54+
55+
@dataclass(kw_only=True)
56+
class Ble(Driver):
57+
"""Bluetooth Low Energy (BLE) driver for Jumpstarter"""
58+
59+
address: str
60+
service_uuid: str
61+
write_char_uuid: str
62+
notify_char_uuid: str
63+
64+
def __post_init__(self):
65+
if hasattr(super(), "__post_init__"):
66+
super().__post_init__()
67+
68+
@classmethod
69+
def client(cls) -> str:
70+
return "jumpstarter_driver_ble.client.BleClient"
71+
72+
@export
73+
async def info(self) -> str:
74+
return f"""Ble Driver connected to
75+
- Address: {self.address}
76+
- Service UUID: {self.service_uuid}
77+
- Write Char UUID: {self.write_char_uuid}
78+
- Notify Char UUID: {self.notify_char_uuid}"""
79+
80+
async def _check_ble_characteristics(self, client: BleakClient):
81+
"""Check if the required BLE service and characteristics are available."""
82+
svcs = list(client.services)
83+
for svc in svcs:
84+
if svc.uuid == self.service_uuid:
85+
chars_uuid = [char.uuid for char in svc.characteristics]
86+
if self.write_char_uuid not in chars_uuid:
87+
raise BleakError(
88+
f"Write characteristic UUID {self.write_char_uuid} not found on device.")
89+
if self.notify_char_uuid not in chars_uuid:
90+
raise BleakError(
91+
f"Notify characteristic UUID {self.notify_char_uuid} not found on device.")
92+
return
93+
94+
raise BleakError(
95+
f"Service UUID {self.service_uuid} not found on device.")
96+
97+
@exportstream
98+
@asynccontextmanager
99+
async def connect(self):
100+
self.logger.info(
101+
"Connecting to BLE device at Address: %s", self.address)
102+
async with BleakClient(self.address) as client:
103+
try:
104+
if client.is_connected:
105+
notify_queue = asyncio.Queue(maxsize=1000)
106+
self.logger.info(
107+
"Connected to BLE device at Address: %s", self.address)
108+
109+
# check if required characteristics are available
110+
await self._check_ble_characteristics(client)
111+
112+
# register notification handler if notify_char_uuid is provided
113+
if self.notify_char_uuid:
114+
notify_handler = partial(
115+
_ble_notify_handler, data_queue=notify_queue)
116+
await client.start_notify(self.notify_char_uuid, notify_handler)
117+
self.logger.info(
118+
"Setting up notification handler for characteristic UUID: %s", self.notify_char_uuid)
119+
120+
async with AsyncBleWrapper(
121+
client=client,
122+
notify_queue=notify_queue,
123+
config=AsyncBleConfig(
124+
address=self.address,
125+
service_uuid=self.service_uuid,
126+
write_char_uuid=self.write_char_uuid,
127+
# read_char_uuid=self.read_char_uuid,
128+
notify_char_uuid=self.notify_char_uuid,
129+
),
130+
) as stream:
131+
yield stream
132+
self.logger.info(
133+
"Disconnecting from BLE device at Address: %s", self.address)
134+
135+
else:
136+
self.logger.error(
137+
"Failed to connect to BLE device at Address: %s", self.address)
138+
raise BleakError(
139+
f"Failed to connect to BLE device at Address: {self.address}")
140+
141+
except BleakError as e:
142+
self.logger.error("Failed to connect to BLE device: %s", e)
143+
raise
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# TODO: add some test if possible

0 commit comments

Comments
 (0)