Skip to content

Repository files navigation

uart-ble-websocket

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.1 by default, and refuses to bind anywhere else without a token unless you explicitly opt out.

Full wire format: PROTOCOL.md.

Requirements

  • Python 3.12 (this repo pins 3.12.10 via .python-version for pyenv-win)
  • A Bluetooth LE adapter, powered on
  • Windows 10 1709+, Linux with BlueZ 5.43+, or macOS 11+

Install

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.

Find your device

.venv/Scripts/uart-ble-ws --scan
AA: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.

Run

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 MyDevice

With 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-ws

For 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.toml

Flags override the config file. --help lists them all.

Try it

With the bridge running, in another terminal:

.venv/Scripts/python tools/ws_console.py

Type 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.

Talking to it from code

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.

From Unity

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");

Things worth knowing

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.

Exposing it beyond localhost

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.

Development

.venv/Scripts/python -m pytest

The 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

Licence

MIT

About

UART BLE to Websocket bridge script & Unity package

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages