13 KiB
Quick Start: ESP-BLE-UART Daemon
This guide shows how to use ESP-BLE-UART Daemon mode and the lightweight JSONL RPC protocol used between the host and the BLE device.
Daemon mode is useful when another local process needs to communicate with a BLE UART device without owning the BLE connection itself.
Prerequisites
-
A host machine with Bluetooth access.
-
Python environment prepared. You can reuse the ESP-IDF Python environment, or use your own Python virtual environment. If you reuse the ESP-IDF environment, export it first and then install the ESP-BLE-UART Bridge dependencies:
cd $IDF_PATH . ./export.sh cd tools/ble/ble_uart_bridge python -m pip install -r requirements.txtOn Windows, run
export.batorexport.ps1from the ESP-IDF root directory before installingrequirements.txt. If you use your own Python virtual environment instead, activate it before installingrequirements.txt. -
A BLE UART device that understands the daemon JSONL request/response protocol, or a device implementation you can adapt.
Start the daemon
First, scan for a device:
cd tools/ble/ble_uart_bridge
python main.py list-devices
Then start the daemon:
python main.py daemon AA:BB:CC:DD:EE:FF
By default, the daemon listens on 127.0.0.1:8888.
To choose another host or port:
python main.py daemon AA:BB:CC:DD:EE:FF --host 127.0.0.1 --port 8899
The daemon keeps one BLE connection open until it is stopped.
Security note: the daemon HTTP API does not implement authentication or authorization. Keep --host on 127.0.0.1 for local-only access unless you place the daemon behind your own access control.
Check daemon status
In another terminal:
python main.py daemon-status
Example response:
{
"device_id": "AA:BB:CC:DD:EE:FF",
"connection_state": "CONNECTED",
"is_connected": true,
"pending_requests": 0,
"single_flight": true,
"max_request_data_bytes": 4096,
"protocol": "esp-jsonl-rpc-lite-v1"
}
If the daemon uses a non-default address:
python main.py daemon-status --host 127.0.0.1 --port 8899
Send a request from the CLI
Send a raw string payload with the default operation name raw:
python main.py daemon-send "hello"
Send a request with an explicit operation name:
python main.py daemon-send --op echo "hello"
Send a JSON payload:
python main.py daemon-send --op set_led --json '{"state": true}'
Set the request timeout:
python main.py daemon-send --op echo --timeout 5.0 "hello"
Use a non-default daemon address:
python main.py daemon-send --host 127.0.0.1 --port 8899 --op echo "hello"
Do not send requests to a daemon bound to a shared network interface unless that network path is trusted or protected by your own access control.
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:
python main.py daemon-notify --op set_led --json '{"state": true}'
Send a raw string notification with the default operation name raw:
python main.py daemon-notify "hello"
Use a non-default daemon address:
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.
GET /status
Returns daemon and BLE connection state:
curl http://127.0.0.1:8888/status
Response fields:
| Field | Meaning |
|---|---|
device_id |
BLE device ID used by the daemon |
connection_state |
Bridge connection state |
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 and /notify |
protocol |
Wire protocol name and version |
reconnect_failures |
Consecutive BLE transport failures, including reconnect and write failures |
max_reconnect_failures |
Maximum consecutive BLE transport failures before the daemon exits |
daemon_state |
Daemon lifecycle state, such as running or exiting |
POST /request
Sends one request to the BLE device and waits for the response:
curl -X POST http://127.0.0.1:8888/request \
-H 'Content-Type: application/json' \
-d '{"op":"echo","data":"hello","timeout":10}'
Request body:
{
"op": "echo",
"data": "hello",
"timeout": 10.0
}
Fields:
| Field | Required | Meaning |
|---|---|---|
op |
No | Operation name. Defaults to raw. |
data |
Yes | Request payload. Can be a string, number, boolean, array, object, or null. |
timeout |
No | Response timeout in seconds. Defaults to 10.0. |
Limits:
opmust be 1 to 64 characters.- JSON-encoded
datamust not exceed 4096 bytes.
Successful response:
{
"ok": true,
"data": "hello"
}
HTTP error behavior:
| HTTP status | Meaning |
|---|---|
413 |
Request data exceeds the daemon payload limit |
502 |
Device returned a protocol error or invalid response |
503 |
BLE device is disconnected and the reconnect attempt failed, or the BLE write failed |
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:
curl -X POST http://127.0.0.1:8888/notify \
-H 'Content-Type: application/json' \
-d '{"op":"set_led","data":{"state":true}}'
Request body:
{
"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:
opmust be 1 to 64 characters.- JSON-encoded
datamust not exceed 4096 bytes.
Successful response:
{
"ok": true
}
HTTP error behavior:
| HTTP status | Meaning |
|---|---|
413 |
Request data exceeds the daemon payload limit |
503 |
BLE device is disconnected and the reconnect attempt failed, or the BLE write failed |
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 protocol is named:
esp-jsonl-rpc-lite-v1
It is intentionally small:
- Human-readable during debugging.
- Easy to generate and parse on ESP-IDF firmware with
cJSON. - No schema registry or capability negotiation.
- No built-in routing framework.
- One request at a time in the current daemon implementation.
Host to device request
The daemon sends this JSONL message to the BLE device:
{"v":1,"id":"6f8f...","op":"echo","data":"hello"}
Actual wire bytes include a final newline:
{"v":1,"id":"6f8f...","op":"echo","data":"hello"}\n
Fields:
| Field | Meaning |
|---|---|
v |
Protocol version. Current value is 1. |
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. |
Device to host success response
{"v":1,"id":"6f8f...","ok":true,"data":"hello"}
Fields:
| Field | Meaning |
|---|---|
v |
Protocol version. Recommended value is 1. |
id |
The request ID from the host message. |
ok |
true for success. |
data |
Response payload. |
The daemon requires data to be present when ok is true.
Device to host error response
{"v":1,"id":"6f8f...","ok":false,"error":"unsupported op"}
Fields:
| Field | Meaning |
|---|---|
id |
The request ID from the host message. |
ok |
false for error. |
error |
Human-readable error message. |
The daemon requires error to be a non-empty string when ok is false.
Response validation rules
For the preferred ok/data/error format, the daemon validates these rules:
idmust be a string and must match a pending request.- If present,
vmust be1. okmust be a boolean.ok: truerequires adatafield.ok: falserequires a non-empty stringerrorfield.
Messages without a matching pending id are treated as unsolicited messages and are logged only.
Legacy response compatibility
The daemon also accepts older response shapes:
{"id":"6f8f...","response":"hello"}
{"id":"6f8f...","error":"failed"}
New device firmware should prefer the ok/data/error format.
Minimal firmware-side behavior
On the BLE device, implement this loop conceptually:
- Accumulate bytes received on the BLE UART RX characteristic.
- Split input on
\n. - Parse each line as JSON.
- Read
id,op, anddata. - Execute the requested operation.
- If
idis non-empty, send a JSON response with the sameidand a final\n. - If
idis 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:
{"v":1,"id":"6f8f...","ok":true,"data":"hello"}
For notifications sent through /notify, the daemon uses an empty id:
{"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:
"single_flight": true
This keeps the firmware-side example simple because the device only needs to handle one active request at a time. The request id is still included so the protocol can be extended later if concurrent requests are needed.
Disconnect behavior
The daemon attempts an on-demand reconnect before each /request or /notify
when it detects that the BLE link is disconnected. If reconnect succeeds, the
HTTP call continues without restarting the daemon. If reconnect fails, the call
returns HTTP 503.
The daemon does not run a background reconnect loop, so GET /status may show
DISCONNECTED until the next /request or /notify triggers a reconnect
attempt. Daemon startup still requires the initial BLE connection to succeed.
The daemon records consecutive BLE transport failures, including failed
on-demand reconnects and failed writes after a stale connection is detected. A
successful reconnect or write clears the counter. After three consecutive BLE
transport failures, the daemon returns HTTP 503 for the triggering call and
then exits.
If the BLE link drops after a /request has already been written to the device,
the daemon does not replay the request. The HTTP call may time out with 504
while the daemon waits for a response that never arrives.
Troubleshooting
daemon-send times out
- Confirm the device sends a newline after the JSON response.
- Confirm the device response contains the same
idas the request. - Confirm the firmware handles the requested
op. - Increase
--timeoutif the operation is slow. - If the BLE link was disconnected, make sure the device is advertising again;
the next
/requestor/notifywill attempt to reconnect.
Daemon returns HTTP 503
-
The BLE device is disconnected or not advertising.
-
The daemon tried to reconnect before sending the request or notification, but the reconnect attempt failed.
-
On Linux, reset the system Bluetooth service if reconnects keep failing, pairing gets stuck, or service discovery cannot find the BLE UART service or characteristics:
sudo systemctl stop bluetooth sudo systemctl start bluetooth -
Restore the BLE device and retry the same command; the daemon does not replay failed requests automatically.
-
After three consecutive BLE transport failures, the daemon exits. Restart it after the BLE device is advertising again.
Daemon returns HTTP 502
- The device returned an error response, or the response was missing required fields.
- Check daemon logs for the exact error.
Device receives data but daemon never resolves the request
- Check that the response is valid JSON.
- Check that the response is an object, not a JSON array or string.
- Check that the response is newline terminated.
- Check that the response
idmatches the requestidexactly.