Every route talks to the Puck over the Nordic UART service (6e400001-b5a3-f393-e0a9-e50e24dcca9e): text written to its TX characteristic runs at the Puck’s prompt, in RAM, and what the Puck prints comes back as notifications on RX. A line starting with \x10 is not echoed. Nothing reaches flash unless the code itself calls save() or writes to Storage.
What each route did
Measured on 2026-10-20 against a Puck.js v2.1 on firmware 2v29, sending the same 925-byte script.
| Route | Finding the Puck | Connect | Packet (MTU) | 925 bytes sent in | Device identity it gives |
|---|---|---|---|---|---|
| Web Bluetooth, Chrome 155 on Windows 11 | Chrome’s own chooser; the user picks it | under 10 ms after the pick | not reported | 31 to 51 ms | an opaque id per site (Mf1GVBIiVM+tHk5lnXK5YA==) |
| tauri-plugin-blec 0.17.0, Tauri 2 on Windows 11 | scan, 1.1 s | 809 ms | 53 | 87 ms | the Bluetooth address (C1:CB:BE:71:39:3A) |
| tauri-plugin-blec 0.17.0, Tauri 2 on Android 16 (Pixel 7 Pro) | scan, 3.6 s among 10 devices | 423 ms | 23 | 339 ms | the Bluetooth address |
A command’s reply came back in about 100 ms on all three. Button presses watched with setWatch arrived on all three.
Gadgetbridge takes a Puck as it is: its Bangle.js driver accepts any device named Puck.js…, Pixl.js…, MDBT42Q… or Espruino…. A Puck that defines GB() and prints {t:"act", ts, stp} lines has its steps stored by Gadgetbridge, which can sync them to Health Connect. Gadgetbridge asks for history since a time with {t:"actfetch", ts}, answered with {t:"actfetch", state:"start"}, the samples, and {t:"actfetch", state:"end"}. On connect it sets the Puck’s clock with setTime and E.setTimeZone, and rewrites setting.json only where that file already exists, which on a Puck it does not.
What it means for the design
- A device names itself. The same Puck is an opaque id to Chrome and its address to a native app, and iOS gives neither, so the device model identifies a device by an id the device reports, never by what the platform hands back.
- One transport interface, two implementations. Web Bluetooth (
webBluetooth,packages/sensors/src/reader/transport.ts) serves the dev page on the desktop; tauri-plugin-blec (blecTransport,packages/sensors/src/reader/blec-transport.ts) serves Datler Sensors on the phone, and is the route to an iPhone later. The app scans for sensors in range (scan), and connects to an added sensor by its address, scanning until it is seen, then writes 20 bytes at a time with a response for each. - Gadgetbridge is a reader to support, not to replace. Answering its Bangle.js protocol puts a Puck’s steps into Gadgetbridge and Health Connect with no app of ours.
Adverts
A scan with tauri-plugin-blec on Windows, which never connects, read the firmware’s BTHome advert on 2026-10-20 as service data under 0000fcd2-0000-1000-8000-00805f9b34fb: [64, 0, 15, 1, 100, 2, 184, 11], which is version 2 unencrypted, packet id 15, battery 100% and 30.00 °C, as bthomeAdvert encodes it. A connected Puck does not advertise, so a reader holding a connection hides it from every other.
An advert holds 31 bytes, and with BTHome’s data in it Espruino shortens the advertised name to fit: “Puck.js 393a” is advertised as “Puck.js 39”. Every reader here matches on the “Puck.js” prefix, and a device’s full id comes from DS.hello, but two Pucks whose names agree in those two characters look alike in a picker until one is connected.
Pairing
Read from Espruino’s source at the 2v29 release and from each reader’s source or maintainers on 2026-10-20, then tried on the Puck.js v2.1 (2v29) the same day with a test passkey set in RAM (results below the table).
NRF.setWhitelist(true)filters no connection. It records bonded peers in a list, but advertising always starts with filter policyBLE_GAP_ADV_FP_ANY(targets/nrf5x/bluetooth.c), so the radio never applies the list. Espruino’s BLE Security page still describes it as blocking other devices. Bangle.js keeps its whitelist in JavaScript instead, aconnecthandler that checks the address and disconnects strangers.- A passkey locks the console: with
NRF.setSecurity({passkey, mitm:1, display:1})the UART needs an encrypted link with MITM protection, so only a bonded central can use it, and a central that cannot enter a passkey cannot pair at all.pair:0(2v19 and later) refuses every new pairing while bonded centrals reconnect with their stored keys, and switching it costs nothing, so a pairing window opened by the button is cheap. Bonds survive a power cycle; security settings are set again inonInit. - Recovery: the button held about 10 seconds through a reset, until the red LED blinks 5 times, clears bonds and saved code.
- Each reader:
| Reader | Pairs with a passkey | How |
|---|---|---|
| Chrome on Windows | Yes | Chrome pairs on demand when a protected characteristic refuses, with its own PIN dialog (device/bluetooth/bluetooth_pairing_winrt.cc); operations run one at a time while pairing |
| Chrome on Android | Yes | The operating system bonds on demand with its own PIN dialog; the first write may fail while it does |
| Gadgetbridge | Yes | It asks whether to bond; bonding shows Android’s PIN dialog |
| tauri-plugin-blec | Not itself | btleplug has no pairing API (its issue 232); on Windows the Puck is paired in Windows Settings first, after which the app connects over the bond |
Tried on the Puck, 2026-10-20, with NRF.setSecurity({passkey, mitm:1, display:1}) in RAM:
| Check | What happened |
|---|---|
| An unpaired reader (tauri-plugin-blec on Windows) writes to the UART | Refused by the Puck: HRESULT 0x80650005, “The attribute requires authentication before it can be read or written”. Forum reports from 2021 saw the UART reachable without the passkey; on 2v29 it is not. |
| Chrome on Windows connects | Chrome asked for the PIN; the link was encrypted with MITM protection. Chrome’s pairing went through Windows, which then listed the Puck among its paired devices. |
| Chrome reconnects | No PIN, link encrypted: the bond held. |
Pairing closed (pair:0), Chrome reconnects | Got in on its bond. |
| Gadgetbridge, removed and added again, chooses to pair while pairing is open | Android’s PIN prompt, then it synced. |
| Pairing closed, Gadgetbridge reconnects | Got in on its bond, no PIN. |
What else it showed:
NRF.getSecurityStatus().bondedreadfalseon a link that reconnected encrypted with no PIN, so it does not say whether a bond exists; whether a reader gets back in without a PIN does.- Changing the security settings while connected printed “BLE Connected, queueing BLE restart for later”: Bluetooth restarts, with the new settings, when the connection drops. Setting
pairagain printed nothing. - A Puck paired at the Windows level is slow to appear in Chrome’s device picker, from seconds to about a minute.
- A paired Gadgetbridge reconnects whenever it sees the Puck advertise, and a connected Puck stops advertising, so while the phone is near, Chrome cannot find it; Gadgetbridge has to be disconnected first.
- The first operation after Chrome’s PIN prompt can fail, so a reader retries subscribing once.
AES on the Puck
Checked on the Puck.js v2.1 on 2v29, 2026-10-20: AES.encrypt in ECB mode gives FIPS-197’s answer (69c4e0d86a7b0430d8cdb78070b4c55a for 00112233…ff under 00010203…0f), and AES.ccmEncrypt is undefined, so an encrypted BTHome advert is built with ccmEncrypt in packages/sensors/src/format/ccm.ts on that one AES block.
Untried
- tauri-plugin-blec asking Android for a larger MTU (
set_android_mtu). - Gadgetbridge’s Health Connect sync with a Puck’s steps.
- Home Assistant reading the BTHome advert.
- tauri-plugin-blec on iOS.