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.

RouteFinding the PuckConnectPacket (MTU)925 bytes sent inDevice identity it gives
Web Bluetooth, Chrome 155 on Windows 11Chrome’s own chooser; the user picks itunder 10 ms after the picknot reported31 to 51 msan opaque id per site (Mf1GVBIiVM+tHk5lnXK5YA==)
tauri-plugin-blec 0.17.0, Tauri 2 on Windows 11scan, 1.1 s809 ms5387 msthe 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 devices423 ms23339 msthe 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 policy BLE_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, a connect handler 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 in onInit.
  • Recovery: the button held about 10 seconds through a reset, until the red LED blinks 5 times, clears bonds and saved code.
  • Each reader:
ReaderPairs with a passkeyHow
Chrome on WindowsYesChrome 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 AndroidYesThe operating system bonds on demand with its own PIN dialog; the first write may fail while it does
GadgetbridgeYesIt asks whether to bond; bonding shows Android’s PIN dialog
tauri-plugin-blecNot itselfbtleplug 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:

CheckWhat happened
An unpaired reader (tauri-plugin-blec on Windows) writes to the UARTRefused 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 connectsChrome 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 reconnectsNo PIN, link encrypted: the bond held.
Pairing closed (pair:0), Chrome reconnectsGot in on its bond.
Gadgetbridge, removed and added again, chooses to pair while pairing is openAndroid’s PIN prompt, then it synced.
Pairing closed, Gadgetbridge reconnectsGot in on its bond, no PIN.

What else it showed:

  • NRF.getSecurityStatus().bonded read false on 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 pair again 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.