Files
2026-07-13 13:04:25 +08:00

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

  1. A host machine with Bluetooth access.

  2. 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.txt
    

    On Windows, run export.bat or export.ps1 from the ESP-IDF root directory before installing requirements.txt. If you use your own Python virtual environment instead, activate it before installing requirements.txt.

  3. 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:

  • op must be 1 to 64 characters.
  • JSON-encoded data must 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:

  • op must be 1 to 64 characters.
  • JSON-encoded data must 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:

  • id must be a string and must match a pending request.
  • If present, v must be 1.
  • ok must be a boolean.
  • ok: true requires a data field.
  • ok: false requires a non-empty string error field.

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:

  1. Accumulate bytes received on the BLE UART RX characteristic.
  2. Split input on \n.
  3. Parse each line as JSON.
  4. Read id, op, and data.
  5. Execute the requested operation.
  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:

{"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 id as the request.
  • Confirm the firmware handles the requested op.
  • Increase --timeout if the operation is slow.
  • If the BLE link was disconnected, make sure the device is advertising again; the next /request or /notify will 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 id matches the request id exactly.