-
Notifications
You must be signed in to change notification settings - Fork 18
feat: ble write notify driver: added basic ble write and notify stream driver and cli console #726
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ../../../../../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 | ||
|
|
||
| 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 | ||
| ``` | ||
| 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" | ||
|
|
||
|
|
| 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 | ||
|
|
||
|
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 | ||
| 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): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). Should I remove the if and code block?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
mangelajo marked this conversation as resolved.
|
||
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) :)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
|
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cool https://deploy-preview-726--jumpstarter-docs.netlify.app/main/reference/package-apis/drivers/ble ! :D