A bridge between a BLE UART device and a WebSocket server. It connects to a peripheral running the Nordic UART Service (or any service you point it at), keeps that connection alive, and exposes it as a WebSocket on localhost.
One process bridges one device. Run several on different ports for several devices.
- Binary WebSocket frames are raw UART bytes, in both directions, with nothing added or stripped.
- Text frames are JSON control messages — scan, connect, status, errors.
- Reconnects on its own with exponential backoff.
- Binds to
127.0.0.1by default, and refuses to bind anywhere else without a token unless you explicitly opt out.
Full wire format: PROTOCOL.md.
- Python 3.12 (this repo pins 3.12.10 via
.python-versionfor pyenv-win) - A Bluetooth LE adapter, powered on
- Windows 10 1709+, Linux with BlueZ 5.43+, or macOS 11+
python -m venv .venv.venv/Scripts/python -m pip install -e ".[dev]"On Windows the venv scripts live in .venv/Scripts; on Linux/macOS use
.venv/bin. If python does not resolve to 3.12, point pyenv at it first
(pyenv local 3.12.10) or call the interpreter by full path.
.venv/Scripts/uart-ble-ws --scanAA:BB:CC:DD:EE:FF -47 dBm MyDevice
11:22:33:44:55:66 -83 dBm <unnamed>
Not every peripheral advertises its service UUIDs, so a UART device may look unremarkable in this list. If nothing shows up at all, check that Bluetooth is switched on — the bridge will tell you if the radio is off.
Point it at a device by address or by advertised name:
.venv/Scripts/uart-ble-ws --address AA:BB:CC:DD:EE:FF.venv/Scripts/uart-ble-ws --name MyDeviceWith no target it starts idle and waits for a client to send a connect command,
which is what you want if device selection belongs in your application:
.venv/Scripts/uart-ble-wsFor anything beyond a couple of flags, use a config file. Copy config.example.toml — every option is documented there.
.venv/Scripts/uart-ble-ws -c config.tomlFlags override the config file. --help lists them all.
With the bridge running, in another terminal:
.venv/Scripts/python tools/ws_console.pyType text to send it to the device; incoming bytes are shown as hex and ASCII.
/scan, /connect <addr|name>, /status, /hex 01 ff 0a, and /quit are
also available.
import asyncio, json
from websockets.asyncio.client import connect
async def main():
async with connect("ws://127.0.0.1:8765") as ws:
hello = json.loads(await ws.recv())
assert hello["protocol"] == 1
await ws.send(b"AT\r\n") # binary frame -> device
while True:
message = await ws.recv()
if isinstance(message, bytes):
print("device said:", message)
else:
print("control:", json.loads(message))
asyncio.run(main())Binary frames are the data path; you only need the JSON messages if you care about connection state or want to drive scanning and connecting yourself.
unity/com.uartble.bridge is a Unity
package implementing this protocol — line-oriented and binary UART, scanning,
connection state, and reconnection, with events delivered on the main thread.
Install it from disk through the Package Manager.
var client = new UartBridgeClient();
client.LineReceived += line => Debug.Log(line);
await client.StartAsync();
client.SendLine("AT");BLE UART is a stream. Payloads larger than the negotiated MTU are split across several BLE writes, and device output arrives as however many notifications the peripheral chose to send. One WebSocket frame does not equal one device message. If your firmware protocol needs framing — length prefixes, newlines, COBS — implement it on top.
RX and TX are named from the peripheral's side, matching Nordic's
documentation: the bridge writes to rx_char_uuid and subscribes to
tx_char_uuid. If data flows the wrong way, swap them in your config.
Writes while disconnected fail with a not_connected error rather than being
buffered, so your application decides whether to retry or drop.
Multiple clients share one device. Every client sees all device output, and any client can write. There is no arbitration — that is deliberate, but it means two clients issuing interleaved commands to the same peripheral will confuse it.
The bridge hands whoever connects control of a Bluetooth radio, so it refuses to bind a non-loopback address without authentication:
.venv/Scripts/uart-ble-ws --host 0.0.0.0 --token "$(python -c 'import secrets;print(secrets.token_urlsafe(24))')"Clients then send Authorization: Bearer <token>, or ?token=<token> if their
WebSocket library cannot set handshake headers. Prefer the header — URLs end up
in logs.
There is no TLS. On an untrusted network, put it behind a reverse proxy that
terminates wss://. --allow-insecure skips the token requirement and exists
only for already-trusted networks.
.venv/Scripts/python -m pytestThe suite runs a real WebSocket server against an in-memory fake device, so no Bluetooth hardware is needed.
Layout:
| File | Role |
|---|---|
link.py |
Shared types and the UartLink protocol the server depends on |
ble.py |
BleUartLink — one supervisor task owning connect/subscribe/reconnect |
server.py |
WebSocket server, fan-out, backpressure, auth |
protocol.py |
JSON control message parsing and construction |
config.py |
TOML loading, validation, CLI overrides |
cli.py |
Argument parsing and process lifecycle |
MIT