Razor Class Library that bridges Blazor WebAssembly apps to a SpawnWear watch (Waveshare ESP32-S3-Touch-AMOLED-2.06 running .NET nanoFramework). Provides a typed BridgeClient that pairs over Web Bluetooth and surfaces watch state - battery, IMU, RTC, button events, WiFi status / scan results, and the live debug log - through ordinary C# events. WebRTC peer-to-peer transport is live (Phase 7, proven end to end 2026-06-23 - see Docs/transport.md): an authenticated, multiplexed data-channel bus over SpawnDev.RTC, working in both browser and .NET desktop.
No raw JavaScript. No IJSRuntime. All interop goes through SpawnDev.BlazorJS typed wrappers.
<ProjectReference Include="..\SpawnWear.Bridge\SpawnWear.Bridge.csproj" />(NuGet package coming once the watch firmware stabilizes.)
Program.cs:
using SpawnDev.BlazorJS;
using SpawnWear.Bridge;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.Services.AddBlazorJSRuntime();
builder.Services.AddSpawnWearBridge(); // <-- this line
await builder.Build().BlazorJSRunAsync();AddSpawnWearBridge registers BleTransport and BridgeClient as scoped services (one per browser tab). The default transport is BLE; consumers can swap to a different ITransport via client.UseTransportAsync(...).
@page "/"
@using SpawnWear.Bridge
@inject BridgeClient Bridge
@implements IDisposable
<button @onclick="Connect" disabled="@Bridge.IsConnected">Pair watch</button>
<p>Battery: @(_battery?.Percent.ToString() ?? "-")%</p>
@code {
BatteryState? _battery;
protected override void OnInitialized()
{
Bridge.BatteryChanged += b => { _battery = b; InvokeAsync(StateHasChanged); };
}
public void Dispose() { /* unsub */ }
async Task Connect() => await Bridge.ConnectAsync();
}The first ConnectAsync triggers the browser's Web Bluetooth picker. The Bridge filters on the SpawnWear primary GATT service UUID (a0e4f2c1-0001-1000-8000-00805f9b34fb) with an SW- name-prefix fallback, so the user only sees SpawnWear watches in the list.
| Event | Type | Source channel | Notes |
|---|---|---|---|
ConnectionChanged |
bool |
(transport) | Fires true after ConnectAsync, false after DisconnectAsync or when the watch goes out of range. |
BatteryChanged |
BatteryState |
Battery |
Percent + IsCharging / IsVbusPresent / IsLowBattery flags + VoltageMillivolts + CurrentMilliamps. |
ImuSampleReceived |
ImuSample |
ImuSample |
Six raw short axes - accel xyz, gyro xyz. |
RtcTimeReceived |
RtcTime |
RtcTime |
Year + month/day/hour/min/sec/weekday. |
ButtonEventReceived |
ButtonEvent |
Button |
Button enum (Boot / Pwr) + Action enum (Down / Up / Click / DoubleClick / LongPress). |
WifiStatusChanged |
WifiStatus |
WifiStatus |
State enum (Disconnected / Connecting / Connected / Failed) + IP. |
WifiScanResultsReceived |
WifiScanResult[] |
WifiScan |
Array of (Ssid, RssiDbm). |
DebugLogReceived |
string |
DebugLog |
UTF-8 decoded line from the watch's Debug.WriteLine. |
The watch no longer runs an HTTP server, so the old WatchHttp helper (GetScreenshotAsync / PostAppAsync against http://<watch-ip>:8080/) is retired. Replacements:
- Live screen capture is pulled over USB with
tools/nf-screenshot.cs(BOOT-button triggered), not over HTTP. - App install is loose
.pefiles on the SD card underD:\apps\<AppName>\(enumerated by the firmware'sAppRepositoryService), pushed over the WebRTCsys.fileslane or copied with the card out.
// WiFi: scan, save credentials, connect, disconnect, forget
await Bridge.SendAsync(new TransportMessage(ChannelIds.WifiScan, new[] { (byte)0x01 }));
await Bridge.SendAsync(new TransportMessage(ChannelIds.WifiCredentials,
System.Text.Encoding.UTF8.GetBytes("MySSID\nMyPassword")));
await Bridge.SendAsync(new TransportMessage(ChannelIds.WifiCommand, new[] { BleUuids.WifiCmdConnect }));
await Bridge.SendAsync(new TransportMessage(ChannelIds.WifiCommand, new[] { BleUuids.WifiCmdDisconnect }));
await Bridge.SendAsync(new TransportMessage(ChannelIds.WifiCommand, new[] { BleUuids.WifiCmdForget }));
// Send a debug command line to the watch's DebugConsoleService
await Bridge.SendAsync(new TransportMessage(ChannelIds.DebugCmd,
System.Text.Encoding.UTF8.GetBytes("redraw")));Your Blazor WASM page
│ inject BridgeClient
▼
┌─────────────────────────────────────────────┐
│ BridgeClient (events, channel-id dispatch) │
│ .UseTransportAsync(ITransport) │
└──────────────────┬──────────────────────────┘
│
┌──────────┴──────────┐
▼ ▼
┌────────────────┐ ┌────────────────────────┐
│ BleTransport │ │ WebRtcTransport │
│ (Web Bluetooth │ │ (SpawnDev.RTC, LIVE; │
│ via SpawnDev. │ │ signals over BLE so │
│ BlazorJS) │ │ no shared network │
│ │ │ needed) │
└────────┬───────┘ └─────────┬──────────────┘
│ │
▼ ▼
BLE GATT WebRTC data channel
│ │
└────────────┬─────────┘
▼
SpawnWear watch firmware
The Bridge mirrors the firmware's BLE schemas (single-source-of-truth lives in SpawnWear/BleUuids.cs + SpawnWear/WatchProfileService.cs + SpawnWear/WifiConfigService.cs):
| Channel | Bytes | Layout |
|---|---|---|
Battery |
6 | [percent:u8][flags:u8][voltage_mV:u16-LE][current_mA:i16-LE] |
ImuSample |
12 | six i16-LE: ax, ay, az, gx, gy, gz |
RtcTime |
8 | [year:u16-LE][month:u8][day:u8][hour:u8][min:u8][sec:u8][weekday:u8] |
Button |
2 | [button:u8][action:u8] |
WifiStatus |
≥1 | [state:u8][ip_string_utf8...] |
WifiScan |
var | `"SSID |
WifiCredentials (write) |
var | "SSID\nPassword" UTF-8 (single string) |
WifiCommand (write) |
1 | BleUuids.WifiCmdConnect/Disconnect/Forget |
DebugLog |
var | UTF-8 text |
DebugCmd (write) |
var | UTF-8 text |
These layouts are locked by the regression tests in SpawnWear.Bridge.Tests. Adding a channel? Mirror the firmware schema, add a decoder branch in BridgeClient.OnMessageReceived, register the channel-id-to-characteristic mapping in BleTransport, and add at least one bit-exact byte-array test before merging.
Same as the parent SpawnWear repository.
- LostBeard (Todd Tanner) - Captain, library author, keeper of the vision
- Riker (Claude CLI) - First Officer, implementation lead on consuming projects
- Data, Tuvok, Geordi, Seven - Crew on adjacent SpawnDev libraries
Live long and prosper. 🖖