A Unity client for the uart-ble-websocket bridge. The bridge owns the Bluetooth radio and exposes one BLE UART device as a WebSocket; this package turns that into an ordinary C# API.
- Lines in, lines out —
SendLine("AT")and aLineReceivedevent, with the stream reassembly BLE forces on you handled for you. - Binary too —
Send(bytes)/DataReceived, plus an optional length-prefix framer for structured packets and chunked upload with progress. - Scanning, connecting, state — the whole control protocol as awaitable methods.
- Reconnects to the bridge on its own, with backoff.
- No dependencies. Works on every player, including WebGL.
Requires Unity 6.3 LTS (6000.3) or newer.
The bridge repo carries the package under unity/com.uartble.bridge. Point the
Package Manager at it:
Window → Package Manager → + → Install package from disk… and pick
unity/com.uartble.bridge/package.json.
Or add it to your project's Packages/manifest.json:
{
"dependencies": {
"com.uartble.bridge": "file:../../uart-ble-websocket/unity/com.uartble.bridge"
}
}A relative path is resolved from the Packages folder. To vendor it instead,
copy the directory into your project's Packages/.
The package talks to a running bridge — it does not do Bluetooth itself. On the machine with the radio:
.venv/Scripts/uart-ble-ws --name MyDeviceThat binds ws://127.0.0.1:8765, which is the package's default.
using UartBle.Bridge;
var client = new UartBridgeClient();
client.LineReceived += line => Debug.Log($"device: {line}");
await client.StartAsync();
await client.WaitForDeviceAsync(TimeSpan.FromSeconds(30));
client.SendLine("AT");StartAsync returns once the bridge has answered; WaitForDeviceAsync returns
once the BLE device is actually connected. Those are two different things — the
bridge is up long before the peripheral is.
Prefer the inspector? Add a UART Bridge component, set the URL, and hook
OnLineReceived. It owns a client and exposes it as .Client.
Frames arrive on a background thread and are queued. Every event fires on the
main thread, drained once per frame from the player loop — so you can touch
Transforms and UI directly in a handler, and no MonoBehaviour is required
anywhere.
Turn off AutoPump and call Pump() yourself if you want a different cadence
(a fixed step, or a worker thread of your own).
Because the pump runs from the player loop, awaiting any of this from the main
thread works — but never block on it. client.ConnectAsync().Wait() will
deadlock: the reply it is waiting for arrives through a pump that the blocked
frame will never run.
var options = new UartBridgeOptions();
options.Line.Terminator = LineTerminator.Lf; // default is CRLF
options.Line.Encoding = UartTextEncoding.Utf8;
var client = new UartBridgeClient(options);
client.LineReceived += Handle;Or poll, if an Update loop suits you better — both work at once:
void Update()
{
while (client.TryReadLine(out string line)) Handle(line);
}A device that prints a prompt without a newline ("OK> ") leaves those bytes
buffered forever, by design — call FlushPartialLine() when you decide the
device has finished talking.
DataReceived gives you every chunk exactly as the peripheral sent it:
client.DataReceived += data =>
{
// data is a ReadOnlySpan<byte>, valid only inside this call
Process(data);
};
client.Send(new byte[] { 0x01, 0xFF });
await client.SendAsync(payload); // awaits the actual sendBLE UART is a stream. One notification is not one message: a payload can be
split across chunks, and several can share one. If your firmware sends
structured binary, frame it. LengthPrefixFramer implements the simplest scheme
that works — a little-endian uint16 length, then that many bytes:
var framer = new LengthPrefixFramer();
client.DataReceived += data =>
{
framer.Append(data);
while (framer.TryRead(out byte[] packet)) Handle(packet);
};
client.SendPacket(payload); // extension method; frames and sendsYour firmware has to write the same shape. If it already uses COBS or a length-and-CRC header, write the equivalent class for that — the point is that something must delimit messages, and it cannot be the transport.
For a large payload:
await client.SendChunkedAsync(
firmwareImage,
chunkSize: 0, // 0 = use the negotiated MTU
progress: new Progress<float>(p => bar.value = p));Two links, tracked separately:
| Property | Meaning |
|---|---|
Connection |
Our WebSocket to the bridge process |
Device |
The bridge's BLE link to the peripheral |
IsDeviceConnected |
Both are up — writes will actually arrive |
client.DeviceStateChanged += change =>
{
if (change.IsFailure) Debug.LogWarning(change.Error);
status.text = change.State.ToString();
};Writes while the socket is down throw InvalidOperationException rather than
being buffered — matching the bridge, which fails writes instead of silently
queueing them, so your application decides whether to retry or drop. Use
TrySend / TrySendLine for the paths where you would rather branch than
catch.
The bridge reconnects to the device on its own, and this client reconnects to
the bridge on its own. Treat any state other than Connected as "the link is
down, wait".
Skip this if the bridge was started with --name or --address; it will
already be connecting.
DeviceInfo[] found = await client.ScanAsync(timeoutSeconds: 5f);
// strongest signal first; Name is often null, and plenty of UART peripherals
// do not advertise their service UUIDs, so this is everything nearby
await client.ConnectDeviceAsync(address: found[0].Address);
await client.WaitForDeviceAsync(TimeSpan.FromSeconds(20));ConnectDeviceAsync returns when the request is accepted, not when the device
is connected — that is what WaitForDeviceAsync is for.
To have the client do this at startup, set AutoConnectDevice with a
DeviceAddress or DeviceName. Leave both empty to use the bridge's own
configured target.
A bridge started with --token needs one:
var options = new UartBridgeOptions
{
Url = "ws://192.168.1.20:8765",
Token = "…",
};TokenPlacement.Auto sends Authorization: Bearer where headers work and falls
back to ?token= on WebGL, where browsers will not let anything set them. The
header is preferable — URLs end up in logs and proxy history.
There is no TLS in the bridge. Off localhost, put it behind a reverse proxy that
terminates wss:// and point Url at that.
Supported, through a .jslib that wraps the browser's own WebSocket. Two
things change:
- The token has to travel in the query string.
- A page served over
https://cannot open aws://socket. You needwss://, which means the reverse proxy above.
| What | Where it shows up |
|---|---|
| Reply to a command failed | The awaited call throws BridgeException with a Code |
| Unsolicited bridge error | ErrorReceived |
| Bridge speaks another protocol version | BridgeProtocolException; no reconnect is attempted |
| Command got no reply | TimeoutException after RequestTimeout |
| Dropped for being too slow | BridgeDisconnected with WasTooSlow |
BridgeErrorCodes has the codes as constants: NotConnected, ConnectFailed,
WriteFailed, Busy, ScanFailed, and the rest.
Falling behind is worth taking seriously. The bridge buffers a bounded amount
per client and then either sheds messages or closes the connection with 1013,
depending on its slow_client_policy. If you see that, your DataReceived
handler is too slow — queue the work, do not do it inline.
Everything on UartBridgeOptions, all serializable so the component can expose
them:
| Option | Default | |
|---|---|---|
Url |
ws://127.0.0.1:8765 |
|
Token, TokenPlacement |
none, Auto |
|
AutoReconnect |
true |
With ReconnectInitialDelay / ReconnectMaxDelay |
AutoConnectDevice |
false |
With DeviceAddress / DeviceName |
RequestTimeout |
10 s | Per control command |
ScanTimeout |
5 s | Default for ScanAsync |
AutoPump |
true |
Off means you call Pump() |
EnforceProtocolVersion |
true |
|
LogToConsole |
false |
Connection diagnostics |
Line.* |
Encoding, terminator, max length, poll queue |
Options are copied on construction — edit them before, not after.
Import from the Package Manager:
- Line Console — scan, connect, and exchange text with a device.
- Binary Transfer — length-prefixed packets and a chunked upload with a progress bar.
The package ships edit-mode tests for the JSON reader, the control protocol, the
line assembler, and the framer. To run them, add the package to testables in
your project's Packages/manifest.json:
{
"testables": ["com.uartble.bridge"]
}They need no bridge and no Bluetooth hardware.
MIT