diff --git a/tools/ble/ble_uart_bridge/README.md b/tools/ble/ble_uart_bridge/README.md index 6a4024204ba..ad8c7e87b47 100644 --- a/tools/ble/ble_uart_bridge/README.md +++ b/tools/ble/ble_uart_bridge/README.md @@ -73,6 +73,7 @@ In another terminal, check daemon status and send a request: ```bash python main.py daemon-status python main.py daemon-send --op echo "hello" +python main.py daemon-notify --op set_led --json '{"state": true}' ``` For Daemon details, the HTTP API, and the JSONL RPC protocol, see [Quick-Start-BLE-UART-Daemon.md](docs/Quick-Start-BLE-UART-Daemon.md). @@ -94,6 +95,7 @@ python main.py console DEVICE_ID python main.py daemon DEVICE_ID python main.py daemon-status python main.py daemon-send DATA +python main.py daemon-notify DATA ``` ### Typical Console workflow @@ -137,6 +139,7 @@ Terminal 2 checks status and sends requests through the daemon: python main.py daemon-status python main.py daemon-send --op echo "hello" python main.py daemon-send --op set_led --json '{"state": true}' +python main.py daemon-notify --op set_led --json '{"state": true}' ``` For the HTTP API and JSONL RPC wire protocol, see [Quick-Start-BLE-UART-Daemon.md](docs/Quick-Start-BLE-UART-Daemon.md). @@ -208,12 +211,12 @@ Main responsibilities: The Daemon component exposes BLE UART as a local HTTP service. -Use it when another local tool, script, editor integration, or automation process needs request/response IPC with a BLE UART device. +Use it when another local tool, script, editor integration, or automation process needs request/response or fire-and-forget IPC with a BLE UART device. Main responsibilities: - Keep one BLE UART connection open in a background server process. -- Expose local HTTP endpoints for status and request/response calls. +- Expose local HTTP endpoints for status, request/response, and fire-and-forget calls. - Encode requests as newline-delimited JSON messages over BLE UART. - Correlate device responses by request ID. - Provide a small JSONL RPC-style envelope as an example protocol. @@ -230,7 +233,7 @@ By default, the daemon binds to `127.0.0.1`. Keep it on a loopback address unles | Console | Manual BLE UART smoke tests | Interactive TUI | | Daemon | Local IPC and automation | HTTP + CLI client | -Use Core when your business logic lives in Python. Use Console when you only need to manually test a BLE UART endpoint. Use Daemon when multiple local processes need to share one BLE connection through a simple request/response boundary. +Use Core when your business logic lives in Python. Use Console when you only need to manually test a BLE UART endpoint. Use Daemon when multiple local processes need to share one BLE connection through a simple request/response or notification boundary. ## Profile compatibility @@ -255,9 +258,9 @@ The tool depends on: ## Limitations - Custom GATT UUIDs require constructing `BLEUARTProfile` in Python code. -- Daemon mode is single-flight: it processes one `/request` at a time. +- Daemon mode is single-flight for request/response calls: it processes one `/request` at a time. `/notify` sends without waiting for a device response. - Daemon mode does not automatically reconnect after a BLE disconnect; restart the daemon after the device starts advertising again. -- Daemon `/request` limits `op` to 64 characters and JSON-encoded `data` to 4096 bytes. +- Daemon `/request` and `/notify` limit `op` to 64 characters and JSON-encoded `data` to 4096 bytes. - The JSONL RPC protocol is a demonstration envelope, not a complete RPC framework. - Unmatched device messages are logged as unsolicited messages and are not exposed as a streaming API. - The daemon request framing is newline-delimited JSON; device firmware must send a newline after every JSON response. diff --git a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md index b5f4ae6eefe..74852758f6d 100644 --- a/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md +++ b/tools/ble/ble_uart_bridge/docs/Quick-Start-BLE-UART-Daemon.md @@ -114,6 +114,28 @@ Do not send requests to a daemon bound to a shared network interface unless that The CLI prints only the response payload. If the device returns a JSON object, the CLI prints it as JSON. +## Send a notification from the CLI + +Use `daemon-notify` for fire-and-forget operations where the caller only needs the daemon to write to the BLE device and does not need a protocol response: + +```bash +python main.py daemon-notify --op set_led --json '{"state": true}' +``` + +Send a raw string notification with the default operation name `raw`: + +```bash +python main.py daemon-notify "hello" +``` + +Use a non-default daemon address: + +```bash +python main.py daemon-notify --host 127.0.0.1 --port 8899 --op set_led --json '{"state": true}' +``` + +`daemon-notify` returns after the local BLE write completes. It does not wait for the device to send a JSONL response. + ## HTTP API Daemon mode exposes a local HTTP API. @@ -135,7 +157,7 @@ Response fields: | `is_connected` | Whether the BLE client is currently connected | | `pending_requests` | Number of pending request futures | | `single_flight` | Whether the daemon serializes requests | -| `max_request_data_bytes` | Maximum JSON-encoded `data` size accepted by `/request` | +| `max_request_data_bytes` | Maximum JSON-encoded `data` size accepted by `/request` and `/notify` | | `protocol` | Wire protocol name and version | ### `POST /request` @@ -189,6 +211,54 @@ HTTP error behavior: | `502` | Device returned a protocol error or invalid response | | `504` | Timed out waiting for the device response | +### `POST /notify` + +Sends one notification to the BLE device and returns without waiting for a protocol response: + +```bash +curl -X POST http://127.0.0.1:8888/notify \ + -H 'Content-Type: application/json' \ + -d '{"op":"set_led","data":{"state":true}}' +``` + +Request body: + +```json +{ + "op": "set_led", + "data": { + "state": true + } +} +``` + +Fields: + +| Field | Required | Meaning | +| --- | --- | --- | +| `op` | No | Operation name. Defaults to `raw`. | +| `data` | Yes | Notification payload. Can be a string, number, boolean, array, object, or null. | + +Limits: + +- `op` must be 1 to 64 characters. +- JSON-encoded `data` must not exceed 4096 bytes. + +Successful response: + +```json +{ + "ok": true +} +``` + +HTTP error behavior: + +| HTTP status | Meaning | +| --- | --- | +| `413` | Request data exceeds the daemon payload limit | +| `500` | Failed to send data to the BLE device | + ## BLE JSONL RPC protocol The daemon communicates with the BLE device using newline-delimited JSON. Every message is one JSON object followed by `\n`. @@ -226,7 +296,7 @@ Fields: | Field | Meaning | | --- | --- | | `v` | Protocol version. Current value is `1`. | -| `id` | Request ID generated by the daemon. The device must echo this in the response. | +| `id` | Request ID generated by the daemon. The device must echo this in `/request` responses. `/notify` uses an empty string because no response is expected. | | `op` | Operation name selected by the client. | | `data` | Request payload. | @@ -295,7 +365,8 @@ On the BLE device, implement this loop conceptually: 3. Parse each line as JSON. 4. Read `id`, `op`, and `data`. 5. Execute the requested operation. -6. Send a JSON response with the same `id` and a final `\n`. +6. If `id` is non-empty, send a JSON response with the same `id` and a final `\n`. +7. If `id` is empty, treat the message as fire-and-forget and normally do not send a response. For example, an `echo` operation can return the same data: @@ -303,6 +374,14 @@ For example, an `echo` operation can return the same data: {"v":1,"id":"6f8f...","ok":true,"data":"hello"} ``` +For notifications sent through `/notify`, the daemon uses an empty `id`: + +```json +{"v":1,"id":"","op":"set_led","data":{"state":true}} +``` + +Firmware can execute the operation without responding. If it does respond with `id: ""`, the daemon will log the message as unsolicited because no pending request is waiting for that ID. + ## Single-flight behavior The daemon currently processes one `/request` at a time. This is exposed as: diff --git a/tools/ble/ble_uart_bridge/main.py b/tools/ble/ble_uart_bridge/main.py index 4158ca1c02b..567169a561c 100644 --- a/tools/ble/ble_uart_bridge/main.py +++ b/tools/ble/ble_uart_bridge/main.py @@ -3,6 +3,7 @@ from src import run_connection_check from src import run_console from src import run_daemon +from src import run_daemon_notify from src import run_daemon_send from src import run_daemon_status from src import run_list_devices @@ -60,6 +61,17 @@ def daemon_send( run_daemon_send(data=data, op=op, json_payload=json_payload, timeout=timeout, host=host, port=port) +@app.command() +def daemon_notify( + data: str, + op: str = Option('raw', help='Operation name in the JSONL request envelope'), + json_payload: bool = Option(False, '--json', help='Parse DATA as JSON instead of sending it as a string'), + host: str = '127.0.0.1', + port: int = 8888, +) -> None: + run_daemon_notify(data=data, op=op, json_payload=json_payload, host=host, port=port) + + def main() -> None: app() diff --git a/tools/ble/ble_uart_bridge/src/__init__.py b/tools/ble/ble_uart_bridge/src/__init__.py index 770559e46fc..5f497e34f38 100644 --- a/tools/ble/ble_uart_bridge/src/__init__.py +++ b/tools/ble/ble_uart_bridge/src/__init__.py @@ -4,6 +4,7 @@ from .console import run_console from .core import run_connection_check from .core import run_list_devices from .daemon import run_daemon +from .daemon import run_daemon_notify from .daemon import run_daemon_send from .daemon import run_daemon_status @@ -12,6 +13,7 @@ __all__ = [ 'run_list_devices', 'run_daemon', 'run_daemon_send', + 'run_daemon_notify', 'run_daemon_status', 'run_console', ] diff --git a/tools/ble/ble_uart_bridge/src/daemon/__init__.py b/tools/ble/ble_uart_bridge/src/daemon/__init__.py index 1aefe89058c..d0fdbd5c70d 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/__init__.py +++ b/tools/ble/ble_uart_bridge/src/daemon/__init__.py @@ -1,7 +1,8 @@ # SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD # SPDX-License-Identifier: Apache-2.0 from .api import run_daemon +from .api import run_daemon_notify from .api import run_daemon_send from .api import run_daemon_status -__all__ = ['run_daemon', 'run_daemon_status', 'run_daemon_send'] +__all__ = ['run_daemon', 'run_daemon_status', 'run_daemon_send', 'run_daemon_notify'] diff --git a/tools/ble/ble_uart_bridge/src/daemon/api.py b/tools/ble/ble_uart_bridge/src/daemon/api.py index 212f76a60d8..a2e5f2ea15e 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/api.py +++ b/tools/ble/ble_uart_bridge/src/daemon/api.py @@ -90,3 +90,27 @@ def run_daemon_send( raise SystemExit(1) from e result = response.get('data', response.get('response', '')) print(result if isinstance(result, str) else json.dumps(result)) + + +def run_daemon_notify( + data: str, + op: str = 'raw', + json_payload: bool = False, + host: str = '127.0.0.1', + port: int = 8888, +) -> None: + try: + payload_data: Any = json.loads(data) if json_payload else data + except json.JSONDecodeError as e: + print(f'Invalid JSON payload: {e}') + raise SystemExit(1) from e + + try: + _request_json( + 'POST', + _daemon_url(host, port, '/notify'), + payload={'op': op, 'data': payload_data}, + ) + except RuntimeError as e: + print(e) + raise SystemExit(1) from e diff --git a/tools/ble/ble_uart_bridge/src/daemon/models.py b/tools/ble/ble_uart_bridge/src/daemon/models.py index 1eb59ea3aca..99e8bce2d74 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/models.py +++ b/tools/ble/ble_uart_bridge/src/daemon/models.py @@ -14,3 +14,8 @@ class BLEUARTRequestPayload(BaseModel): op: str = Field('raw', min_length=1, max_length=MAX_OP_LENGTH, description='Operation name to send to BLE device') data: Any = Field(..., description='Request payload to send to BLE device') timeout: float = Field(10.0, gt=0, description='Response timeout in seconds') + + +class BLEUARTNotifyPayload(BaseModel): + op: str = Field('raw', min_length=1, max_length=MAX_OP_LENGTH, description='Operation name to send to BLE device') + data: Any = Field(..., description='Notification payload to send to BLE device') diff --git a/tools/ble/ble_uart_bridge/src/daemon/server.py b/tools/ble/ble_uart_bridge/src/daemon/server.py index 2e5afbc2f02..b4bb915c038 100644 --- a/tools/ble/ble_uart_bridge/src/daemon/server.py +++ b/tools/ble/ble_uart_bridge/src/daemon/server.py @@ -17,6 +17,7 @@ from .jsonl import drain_jsonl_messages from .jsonl import encode_jsonl_request from .jsonl import resolve_pending_response from .models import MAX_REQUEST_DATA_BYTES +from .models import BLEUARTNotifyPayload from .models import BLEUARTRequestPayload @@ -110,3 +111,18 @@ async def request(payload: BLEUARTRequestPayload) -> dict: app.state.pending_requests.pop(request_id, None) return {'ok': True, 'data': response} + + +@app.post('/notify') +async def notify(payload: BLEUARTNotifyPayload) -> dict: + if _request_data_size(payload.data) > MAX_REQUEST_DATA_BYTES: + raise HTTPException(status_code=413, detail=f'Request data exceeds {MAX_REQUEST_DATA_BYTES} bytes') + + success = await app.state.bridge.send( + encode_jsonl_request('', payload.op, payload.data), + with_response=False, + ) + if not success: + raise HTTPException(status_code=500, detail='Failed to send data to device') + + return {'ok': True}