mirror of
https://github.com/espressif/esp-idf.git
synced 2026-10-02 11:10:54 +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
|
```bash
|
||||||
python main.py daemon-status
|
python main.py daemon-status
|
||||||
python main.py daemon-send --op echo "hello"
|
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).
|
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 DEVICE_ID
|
||||||
python main.py daemon-status
|
python main.py daemon-status
|
||||||
python main.py daemon-send DATA
|
python main.py daemon-send DATA
|
||||||
|
python main.py daemon-notify DATA
|
||||||
```
|
```
|
||||||
|
|
||||||
### Typical Console workflow
|
### 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-status
|
||||||
python main.py daemon-send --op echo "hello"
|
python main.py daemon-send --op echo "hello"
|
||||||
python main.py daemon-send --op set_led --json '{"state": true}'
|
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).
|
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.
|
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:
|
Main responsibilities:
|
||||||
|
|
||||||
- Keep one BLE UART connection open in a background server process.
|
- 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.
|
- Encode requests as newline-delimited JSON messages over BLE UART.
|
||||||
- Correlate device responses by request ID.
|
- Correlate device responses by request ID.
|
||||||
- Provide a small JSONL RPC-style envelope as an example protocol.
|
- 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 |
|
| Console | Manual BLE UART smoke tests | Interactive TUI |
|
||||||
| Daemon | Local IPC and automation | HTTP + CLI client |
|
| 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
|
## Profile compatibility
|
||||||
|
|
||||||
@@ -255,9 +258,9 @@ The tool depends on:
|
|||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- Custom GATT UUIDs require constructing `BLEUARTProfile` in Python code.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.
|
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
|
## HTTP API
|
||||||
|
|
||||||
Daemon mode exposes a local HTTP API.
|
Daemon mode exposes a local HTTP API.
|
||||||
@@ -135,7 +157,7 @@ Response fields:
|
|||||||
| `is_connected` | Whether the BLE client is currently connected |
|
| `is_connected` | Whether the BLE client is currently connected |
|
||||||
| `pending_requests` | Number of pending request futures |
|
| `pending_requests` | Number of pending request futures |
|
||||||
| `single_flight` | Whether the daemon serializes requests |
|
| `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 |
|
| `protocol` | Wire protocol name and version |
|
||||||
|
|
||||||
### `POST /request`
|
### `POST /request`
|
||||||
@@ -189,6 +211,54 @@ HTTP error behavior:
|
|||||||
| `502` | Device returned a protocol error or invalid response |
|
| `502` | Device returned a protocol error or invalid response |
|
||||||
| `504` | Timed out waiting for the device 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
|
## BLE JSONL RPC protocol
|
||||||
|
|
||||||
The daemon communicates with the BLE device using newline-delimited JSON. Every message is one JSON object followed by `\n`.
|
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 |
|
| Field | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `v` | Protocol version. Current value is `1`. |
|
| `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. |
|
| `op` | Operation name selected by the client. |
|
||||||
| `data` | Request payload. |
|
| `data` | Request payload. |
|
||||||
|
|
||||||
@@ -295,7 +365,8 @@ On the BLE device, implement this loop conceptually:
|
|||||||
3. Parse each line as JSON.
|
3. Parse each line as JSON.
|
||||||
4. Read `id`, `op`, and `data`.
|
4. Read `id`, `op`, and `data`.
|
||||||
5. Execute the requested operation.
|
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:
|
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"}
|
{"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
|
## Single-flight behavior
|
||||||
|
|
||||||
The daemon currently processes one `/request` at a time. This is exposed as:
|
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_connection_check
|
||||||
from src import run_console
|
from src import run_console
|
||||||
from src import run_daemon
|
from src import run_daemon
|
||||||
|
from src import run_daemon_notify
|
||||||
from src import run_daemon_send
|
from src import run_daemon_send
|
||||||
from src import run_daemon_status
|
from src import run_daemon_status
|
||||||
from src import run_list_devices
|
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)
|
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:
|
def main() -> None:
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .console import run_console
|
|||||||
from .core import run_connection_check
|
from .core import run_connection_check
|
||||||
from .core import run_list_devices
|
from .core import run_list_devices
|
||||||
from .daemon import run_daemon
|
from .daemon import run_daemon
|
||||||
|
from .daemon import run_daemon_notify
|
||||||
from .daemon import run_daemon_send
|
from .daemon import run_daemon_send
|
||||||
from .daemon import run_daemon_status
|
from .daemon import run_daemon_status
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ __all__ = [
|
|||||||
'run_list_devices',
|
'run_list_devices',
|
||||||
'run_daemon',
|
'run_daemon',
|
||||||
'run_daemon_send',
|
'run_daemon_send',
|
||||||
|
'run_daemon_notify',
|
||||||
'run_daemon_status',
|
'run_daemon_status',
|
||||||
'run_console',
|
'run_console',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
# SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
from .api import run_daemon
|
from .api import run_daemon
|
||||||
|
from .api import run_daemon_notify
|
||||||
from .api import run_daemon_send
|
from .api import run_daemon_send
|
||||||
from .api import run_daemon_status
|
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
|
raise SystemExit(1) from e
|
||||||
result = response.get('data', response.get('response', ''))
|
result = response.get('data', response.get('response', ''))
|
||||||
print(result if isinstance(result, str) else json.dumps(result))
|
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')
|
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')
|
data: Any = Field(..., description='Request payload to send to BLE device')
|
||||||
timeout: float = Field(10.0, gt=0, description='Response timeout in seconds')
|
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 encode_jsonl_request
|
||||||
from .jsonl import resolve_pending_response
|
from .jsonl import resolve_pending_response
|
||||||
from .models import MAX_REQUEST_DATA_BYTES
|
from .models import MAX_REQUEST_DATA_BYTES
|
||||||
|
from .models import BLEUARTNotifyPayload
|
||||||
from .models import BLEUARTRequestPayload
|
from .models import BLEUARTRequestPayload
|
||||||
|
|
||||||
|
|
||||||
@@ -110,3 +111,18 @@ async def request(payload: BLEUARTRequestPayload) -> dict:
|
|||||||
app.state.pending_requests.pop(request_id, None)
|
app.state.pending_requests.pop(request_id, None)
|
||||||
|
|
||||||
return {'ok': True, 'data': response}
|
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