mirror of
https://github.com/espressif/esp-idf.git
synced 2026-09-22 13:01:16 +03:00
feat(ble): add BLE UART daemon notify API
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user