feat(ble): add BLE UART daemon notify API

This commit is contained in:
Zhou Xiao
2026-04-27 16:24:02 +08:00
parent 7f714384f3
commit 8faeca070d
8 changed files with 151 additions and 9 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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()

View File

@@ -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',
]

View File

@@ -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']

View File

@@ -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

View File

@@ -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')

View File

@@ -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}