BLE & sensors
How a physical Enode sensor becomes live reps in the app, across web / iOS /
Android, including the SmartBar (VMP_SB) dual-sensor flow. This is the source of
truth for the BLE subsystem and the handoff doc for anyone (human or agent)
picking up sensor work. Code lives in packages/core/src/ble/,
packages/core/src/calibration/, packages/ui/src/sensor-3d/, and the
in-session hooks under apps/tracking/src/app/workouts/today/.
The pure byte-parsing, quaternion math, fusion, and pool logic all ship with co-located
*.test.ts.npm run testcovers them; the native transports and on-device timing are not unit-tested and need hardware (see Device-verify loop).
Layered architecture
The whole point of the stack is that nothing above the transport touches
navigator.bluetooth or a Capacitor plugin — platform code is sealed under
two interfaces.
UI / hooks ConnectSensorSheet · use-sensor-connection · use-station-reps
use-sensor-reps · calibration hooks · SensorViewer (3D)
│ consumes SensorAdapter + the sensor pool
────────────────────────┼────────────────────────────────────────────────
domain sensor-pool.ts (stations, units, L/R) rep-recorder.ts
rep-fusion.ts (dual-sensor merge) throughput-monitor.ts
│
sensor object enode-sensor.ts → EnodeSensor implements SensorAdapter
commands (enode-sensor-commands.ts) + parsing
(enode-sensor-protocol.ts) + steady-state-tracker.ts
│ talks bytes through BleConnection
────────────────────────┼────────────────────────────────────────────────
transport BleService / BleConnection (ble/types.ts)
web-bluetooth.ts │ capacitor-ble.ts → native EnodeBle plugin
│ (iOS EnodeBlePlugin.swift /
│ Android EnodeBlePlugin.kt)
Two contracts define the seams:
BleService/BleConnection(ble/types.ts) — the raw transport: scan/pick a device, connect, subscribe/read/write GATT characteristics. Bytes are normalized toUint8Arrayat this boundary; DataView / base64 quirks stay platform-internal.SensorAdapter(ble/enode-sensor.ts) — a connected, initialized sensor: identity (id,deviceName), live readouts (battery/rssi/temperature/versions), a typed event surface (on("data" | "event" | "settings" | "connected" | "disconnected" | "apiIncompatible")), and commands (configure= settings+mode with retry,setMode,writeSettings,resetOrientation,shutDown,shouldDisconnect, plus the auto-reconnect lifecyclesetReconnecting/reattach). Everything UI-side types its sensor asSensorAdapterand never cares which platform produced it.
Entry points
ble/index.ts is the public surface:
getBleService()— singleton, picksWebBluetoothServiceon web orCapacitorBleServiceon a native platform.searchAndConnect({ signal })— the one "tap Connect → live sensor" flow for all platforms:pickDevice→connect→new EnodeSensor(connection)→initialize(). Honors anAbortSignalat every step (cancel =AbortErrorthe sheet swallows).
searchAndConnect routes all three platforms through the unified
BleService path (commit 7b02b99). The old CapacitorEnodeSensor package
bridge (ble/capacitor-enode-sensor.ts) has been deleted — it was
unreferenced (no import anywhere), so its top-level registerPlugin never ran.
Transports
| Platform | BleService impl | Native plugin |
|---|---|---|
| Web (Chrome) | web-bluetooth.ts | — (Web Bluetooth API) |
| iOS | capacitor-ble.ts | apps/tracking/ios/App/App/Plugins/EnodeBlePlugin.swift |
| Android | capacitor-ble.ts | apps/tracking/android/.../plugins/EnodeBlePlugin.kt |
- Live scan with streaming RSSI requires Chrome's experimental flag on web
(
supportsLiveScan()); without it,pickDevicefalls back to the native chooser. Native transports support live scan. - Service UUIDs live in
ble/config.ts(SENSOR_ADVERTISED_SERVICE_UUID,SENSOR_SERVICE_UUID,SENSOR_GATT_SERVICE_UUIDS).
Android transport hardening (2026-07-16)
Two Android-only failure modes were found on a budget tablet (SM-X230) with a streaming SmartBar pair — both invisible on iOS, whose CoreBluetooth queues notifications reliably no matter how slow the consumer is:
- Connection starvation. With two sensors at the default BALANCED
connection interval, one link delivered seconds-late, smeared buffers
(583-sample "eccentrics", non-monotonic time).
EnodeSensorDevice. requestHighPriority()now requestsCONNECTION_PRIORITY_HIGHduring connect (next to the MTU request, after service discovery — see below) — required for a two-sensor pair. - Notification loss under bridge load. Android's GATT silently drops
notifications when the app's callback path is slow, and the plugin used to
JSON-serialize + bridge-eval every notification (~100/s streaming) from
inside that callback — observed as dropped
repConcentricStarts, missingEndValids and byte-corrupted event packets.EnodeBlePluginnow batches: the GATT callback only enqueues (returns instantly); a single FIFO flushes every 50 ms as ONEnotificationBatchbridge event (~10× fewer WebView evals). A rep-event notification (characteristic…1524) flushes the queue immediately, so rep latency stays at milliseconds and data-vs-event ordering — which the rep recorder depends on — is preserved by the single FIFO. The TS transport (capacitor-ble.ts) handles bothnotification(web/iOS, unchanged) andnotificationBatch(Android).
Android connect hardening (2026-08-21)
A customer on a Lenovo Idea Tab (TB336ZU, Android 16) reported every connect
attempt ending in connect: service discovery failed, while the same sensor
worked on older, weaker hardware. The transport had no way to say why, so the
first goal was making the cause observable and the second was removing the ways
a healthy link could still land in that message:
- Unbounded GATT operations. Every
deferred.await()could wait forever. Android delivers no callback for a request it refuses, so a refused operation held the per-device mutex indefinitely — a silent hang that looks like a slow connect, never an error. All operations are now bounded (CONNECT_TIMEOUT_MS/DISCOVER_TIMEOUT_MS/OP_TIMEOUT_MS). - Unchecked request results. On API 33+
requestMtu,discoverServices,writeCharacteristicandwriteDescriptorall report whether the request was accepted; the 33+ branch ignored those values while the legacy branch checked them. Since Android 13 every device takes the unchecked path. Both branches now check and log. - Service discovery ran last. Connect issued
requestMtu+requestConnectionPrioritybeforediscoverServices(). Any link loss in that widened window nulls the GATT client, so discovery finds nothing and reportsservice discovery failed— hiding a disconnect behind a discovery error. Discovery now runs first; MTU and priority follow once the link has proven itself. - Orphaned GATT clients.
connect()overwroteconnected[deviceId]without closing the previousBluetoothGatt. Android caps GATT clients per app; past the cap every connect fails with status 133 until the process restarts, which presents as hardware slowly going bad. The previous client is now closed, and pool removal only drops the entry if it is still the same device (forget). - The disconnect status was never logged. The
STATE_DISCONNECTEDbranch — the path a link loss during connect actually takes — logged nothing. It now logs, andEnodeSensorDevice.lastStatusis appended to the connect rejection so a support screenshot carries the cause: 8 = supervision timeout (peer went away), 133 = generic/early failure, 19 = peer terminated, 22 = local.
Silent "connected but no reps" hardening (2026-09-25)
Customer reports (iPads in feedback mode, and a single-sensor workout user) described a sensor that connects, shows its battery and moves the 3D model, yet records no reps. Three code paths could produce exactly that without any error:
-
Overlapping GATT operations orphaned a call on iOS.
EnodeBlePlugin.swiftkeeps one pending call perdeviceId|characteristic; a second write to the command characteristic before the first was acknowledged replaced the first call, which then never settled (e.g.configurehung before itssetMode).CapacitorBleConnectionnow runs every read / write / notify toggle through a per-connection FIFO queue with aGATT_OP_TIMEOUT_MS(5 s) bound, so operations never overlap and a lost callback fails the operation (andconfigure's retry takes over) instead of stalling the queue. -
A failed API-version read left the sensor silently idle. Without the Software Revision string
apiVersionNumberstayednull, sosetModesent the idlenonemode and every rep-end decoded to no payload. The read is now retried (API_VERSION_READ_ATTEMPTS, 3×) and otherwiseinitializethrowsSensorIdentificationError:searchAndConnectdrops the link and the connect sheet shows a localized "try connecting again"; the auto-reconnect drops the link and keeps retrying until its timeout. -
The feedback flow configured the sensor while the connect sheet was open (see Device view ↔ tracking view mode ownership).
-
The feedback flow ran without its tracking setting. A feedback mode resolves its sensor mode from
trackingSourceSettingID; when that setting was missing,configurenever ran anduseStationRepsgotsensorMode = null, which drops every rep-end. The tracking view now gates on the setting withRequireData(error + "Try again" instead of a silent sensor). -
"Connect" took the first sensor heard, not the nearest. In a room with several powered-on sensors (the reporting customer runs nine) the first advertisement is often a sensor on another rack, so the device connected — battery and 3D model included — to a sensor nobody was lifting with. The native iOS package behaves the same (first device above -98 dBm, i.e. any).
CapacitorBleService.pickDevicenow keeps listeningPICK_COLLECT_MS(1.5 s) after the first advertisement and connects to the strongest RSSI (strongestDevice). Web keeps the browser's own chooser. -
A failed reconnect attempt was never retried on iOS. The pool's auto-reconnect only attempted a reattach on scan updates, and iOS reports each peripheral once per scan, so one failed attempt left the sensor in "reconnecting" until the 60 s timeout dropped it. The first sighting now starts the retry loop (found by the simulated-sensor suite below).
Every tracking view decides its sensor configuration through one pure function,
planSensorConfiguration (ble/sensor-configuration.ts),
so the exercise view and the feedback flow can no longer drift apart.
Not device-verified yet. Whether the firmware mis-handles settings writes in the middle of the calibration handshake is an assumption, not a measurement.
Simulated-sensor suite
sensor-scenarios.test.ts
runs the real stack — transport queue, EnodeSensor, sensor pool,
auto-reconnect — against a simulated sensor, and checks that every lift reaches
the app as a rep. It runs with npm run test; no hardware needed.
FakeEnodeBle(ble/testing/fake-enode-ble.ts) implements the native plugin surface with the iOS plugin's bookkeeping (one pending call per characteristic; a second call replaces the first). Its first test proves the model reproduces the orphaned call.VirtualSensorspeaks the protocol as the app parses it: device-info reads (with injectable Software Revision failures), the settings / mode / read-back commands, calibration, and rep events in the current hardware mode's byte layout. Its assumptions (idlenonemode after connect, no reps while calibrating) are listed at the top of the file.- Injection:
new CapacitorBleService(fake)+setBleService(...)(ble/index.ts) — the same seam a debug-build "virtual sensor" could use later. - Scenarios (customer reports 2026-09): nearest of nine sensors wins; API-version read retried / connect failed visibly; bar speed recorded after connecting from the picker; no writes while the sheet is open on a calibrating sensor; jump mode survives re-opening the sheet; overlapping SET_MODE / configure in both orders; no setting → no reps; reconnect mid-set with the view re-applying; reconnect retried after a failed attempt; SmartBar pair configured on both sides.
- Mutation-checked (2026-09-25): reverting any single fix — transport queue, sheet gate, strongest-RSSI pick, version retry, reconnect retry loop — turns its scenario red.
- Not covered: real radio behaviour, firmware rep rejection, and the React
effect dependencies themselves (the suite models the views' effects;
planSensorConfigurationis what they share). Those need the device check.
Protocol & quaternion math (enode-sensor-protocol.ts)
The pure byte layer: parses rep-event packets and the raw 50 Hz data-package
frames into SensorDataPoints, and holds all quaternion/vector math. It
mirrors iOS EnodeMath / EnodeSensorMath.
parseDataPointbuilds per-mode samples (strength/jump/flywheel).prepareQuaternion(raw, deviceName)applies iOSprepare(quaternion:): a baseraw × (1,0,0,0)180°-about-X, plus — for VMP_SB only — a 90°X then 90°Y display rotation (net 120° about the (1,1,1) diagonal). This is a display rotation for the 3D model; it is fed to.reference()exactly as iOS does (see below).- Quaternion convention map (this trips everyone up):
iOS here notes .multiply(with:)multiplyQuaternionw-scalar Hamilton product .multiply(by:)multiplyQuaternionByx-scalar product (rep-data only) .rotate(by:)rotateVectorq⊗v⊗q⁻¹; used only insidereferenceQuaternion.rotateBy(_:)rotateVectorByMatrixrotation matrix = transpose of the above (rotates by q⁻¹).reference()referenceQuaternionyaw-only reference frame .slerp(with:t:)slerpQuaternionshortest-path, for fusion
⚠️ The rotate(by:) vs rotateBy(_:) gotcha (fixed in 22c1899)
iOS has two opposite-handed vector rotations. rotate(by:) is the
quaternion product q⊗v⊗q⁻¹ (rotates by q). rotateBy(_:) builds a rotation
matrix that is the transpose — it rotates by q⁻¹, i.e. it cancels the
start heading instead of applying it.
makeRepData / rotateRepData rotate position+accel with .rotateBy(_:)
(the matrix). We had originally ported them with rotateVector (the quaternion
product), which rotated the trajectory the wrong way — rotating the sensor's
start orientation about Z leaked sagittal motion into the frontal plane. Fix:
rotateVectorByMatrix (verbatim port of the iOS matrix), used in
correctedStrengthSamples (rep-data.ts)
and rotateRepRows (rep-fusion.ts).
referenceQuaternion still uses rotateVector internally — that one is correct,
because iOS .reference() genuinely calls .rotate(by:). The fix is
sensor-agnostic: clip-on and bar reps stay byte-identical.
The sensor object (enode-sensor.ts)
EnodeSensor wires a BleConnection to the typed SensorAdapter:
- Commands (
enode-sensor-commands.ts) — theModeenum,setMode/writeSettingsbyte builders (shared by web + the native bridges). Co-located test. - Events — parses the event characteristic into
repConcentricEndValid, eccentric, jump variants,calibrationOnline, etc., and re-emits on"event". - Recording (
rep-recorder.ts) — buffers the raw stream per rep. Mirrors iOSRecordingDevice: keeps buffering pastEccentricEndValiduntilConcentricStartso a rep's full continuation is captured (also extends WL concentric reps). - Steady-state (
steady-state-tracker.ts) — detects the sensor sitting still (rack/re-rack) for the connect+rest UI.
Sensor pool & the station model (sensor-pool.ts)
A module-level store (usePool / useStationSensors via useSyncExternalStore,
the repo's prefetch-store pattern) of every connected sensor:
PooledSensor = { id, sensor: SensorAdapter, stationId, position }whereposition: "left" | "right" | null.- A station owns ONE logical tracking source. A single sensor = its own
source; a SmartBar = 1–2
VMP_SBsensors on one station, tagged L/R. - Key functions:
addSensor,assignSensor,assignUnit(move both bar sensors as a unit),swapSmartBarSides,groupIntoUnits,orderStationSensors(always left-before-right — fusion depends on this ordering),removeSensor,releaseStation.isSmartBarName(name)=name?.includes("VMP_SB").
SmartBar (VMP_SB) dual-sensor
Connect / assignment / 3D
use-sensor-connection.ts—stationSensors,isSmartBarStation,canAddSecond(only a second VMP_SB is allowed;NotASmartBarErrorotherwise),addSecond(assigns the opposite side),swapSides,removeSide.ConnectSensorSheet.tsx—SmartBarConnectPanel: L/R chips, side-by-sideMultiSensorViewer, per-sensor battery ring + Hz, shared note, "Switch side" (works on a lone sensor too), "Add second sensor".- 3D (
SensorViewer.tsx) —MultiSensorViewerrenders one independent<SensorViewer>per pane keyed by index (two<Canvas>or keying by sensor id loses the WebGL context on swap). Both bodies ship as quantized GLBs (/models/sensor-compressed.glb,/models/barSensor-compressed.glb) loaded via the extension-awareuseSensorModel(FBX still supported forsmartDevice.fbx); the GLBs preserve the FBX world axes exactly, so the orientation offsets apply unchanged. Bounding-box fit usesmultiplyScalar(notsetScalar, which clobbers the baked root scale). - Calibration (
packages/core/src/calibration/) — six axis cones (FaceID).useCalibratedFacesseeds synchronously from a cachedWeakMap; cone visibility is driven by a raw-pulseanimationStartRef(not the live calibrated set), with ananimationStart === nulldedup guard so held-face re-emits don't flicker, plus a mount-init effect for cached faces. Bar model/view orientation offsets inmodel-orientation.tsare currently identity(0,0,0)/(-90,0,0); dev console helperssetBarSensorOffset/setBarViewOffsettune them live.
Fusion (strength + weightlifting only)
-
Math (
rep-fusion.ts, tested) — 1:1 port of iOSEnodeSensorMath:rotateRepRows(right sensor → shared frame viaQ0=(0,0,1,0)),mergeRepRows(transpose → interpolate to 101 samples → average cols 0–7 → slerp relative-delta quaternions → bar-length validation 2.18 m ±0.25 → re-stamp time), and thePendingRepcanonicalizers that each produce the single canonicalrawData:fuseStrengthConcentric/fuseEccentric(fused bar-centre),rotateSingleRightRep(corrected + rotated),correctSingleRep(corrected, lone/left). -
Coordinator (
use-station-reps.ts) — the runtime fusion seam; reactive so a rep extended after its end (the WL-eccentric continuation) re-fuses with the extra samples. Runs auseSensorRepsper slot (≤2):- 1 sensor: canonicalize each rep at finalization — lone right →
rotateSingleRightRep, otherwisecorrectSingleRep. - 2 sensors: buffer each rep per phase, wait
PAIR_WINDOW_MS(500 ms) for the partner, record a pair/single decision, then derive the fused stream from the current source reps (so a later append re-fuses). Partner → fuse (L/R by pool position, right rotated in the merge); timeout → single.
- 1 sensor: canonicalize each rep at finalization — lone right →
-
One canonical trajectory per rep (no
fusedData). All trajectory manipulation happens once, at rep finalization (the coordinator), and the result is stored as the rep's singleWorkoutRep.rawData:- dual sensors → fused bar-centre (
fuseStrengthConcentric/fuseEccentric), - lone right → corrected + rotated into the shared frame (
rotateSingleRightRep), - lone left / single → corrected (
correctSingleRep).
The makeRepData reference-frame correction is baked in here, once — every consumer reads
rawDataverbatim and never re-corrects: the chart maps it straight to samples, the upload mapper (api/mappers/sessions.ts) justencodeRepData(rawData), and rep-validation reads it as-is. The oldrawData(raw) +fusedData(corrected blob) split (anddecodeRepRows) was collapsed into this single field. - dual sensors → fused bar-centre (
-
By design, a fused dual-sensor rep is indistinguishable from a single-sensor rep. Only the merged center-of-bar result is stored — both sensors' metrics, positions, accel and velocity are averaged and orientations slerped, so downstream consumers treat it like any normal rep. The two per-sensor raw streams are intentionally not stored separately; the merge is one-way and the originals are not recoverable. Intended contract, not a gap.
-
WL-eccentric is the one read-vs-write nuance. The weightlifting eccentric continuation is appended to the concentric rep's stream; the dual coordinator re-fuses reactively when that append lands (the jump eccentric→concentric merge, by contrast, stays a chart-read concern).
-
Merge acceptance gates (2026-07-16, all device-driven).
chooseFusiononly accepts the bar-centre merge when ALL hold; otherwise the pair falls back to ONE side — the left, unless only the right is healthy — using that side's corrected trajectory and its own event metrics (averaging with a drifted partner poisoned metrics: a −6.22 m displacement was observed):- Spans match within
SPAN_MISMATCH_TOLERANCE(20%) — the merge normalizes each side's time to [0,1], so differing recorded windows (asymmetric WL-continuation appends) would average mid-LIFT against mid-LOWERING. - Both streams healthy (
rowsHealthy): monotonic time, no sample-to-sample jump > 0.3 m, |s| ≤ 3 m — a starved link's smeared buffer stays "plausible" but fails these. - iOS
unfold()data check inside the interpolation: every time step ≥ 0 and ≤ 0.06 s (MAX_SAMPLE_GAP_S) — dropped packets reject the merge instead of interpolating across missing movement. - Bar-rigidity
validationpasses (the two trajectories stay 2.18 m ± 0.25 apart). iOS computes-and-discards this flag; the web now consumes it.
- Spans match within
-
Pairing hardening (2026-07-16,
pairing-machine.ts). Pure reducer (PAIRING_REDUCER), scripted-timeline tested:- Late-partner upgrade. A slow bridge delivers the second sensor's rep
after the 500 ms window; the flushed single stays upgrade-eligible for
UPGRADE_WINDOW_MS(2 s) and the late partner converts it into a pair IN PLACE (decision replaced, order kept) — previously every late partner became a second single: one physical rep counted twice. (iOS instead waits 0.8 s silently and has a latent double-store when the partner exceeds it — the upgrade is deliberately better, and time-bounded because an idle sensor's garbage rep once claimed a single 19 s later.) - Duration compatibility gate. Pair/upgrade only when the two reps'
firmware durations agree within
PAIR_DURATION_TOLERANCE(30%) — the same physical rep measured from both bar ends always matches; two independent movements almost never do (alternating single-sided reps were being merged, silently eating the count). No iOS counterpart; on a real bar it never triggers.
- Late-partner upgrade. A slow bridge delivers the second sensor's rep
after the 500 ms window; the flushed single stays upgrade-eligible for
⚠️ Architecture divergence. Fusion landed at the rep-coordinator layer (
use-station-reps.ts), not as theSmartBarUnitadapter originally sketched.ble/smart-bar-unit.ts'sselectTrackingSensorreturns the left sensor on purpose: it remains the tracking source for settings / 3D / connection, while reps are fused downstream. (Its doc comment now says exactly this.)
Per-station configuration
The per-station settings effect in
ExerciseDetailView.tsx
(and FeedbackTrainingFlow.tsx) calls sensor.configure(options) on every
station sensor. configure applies the rep-detection settings + the data-layout
setMode as one unit, with retry (3× / 300 ms) — a write can transiently
fail right after connect/service-discovery, which used to leave a sensor
unconfigured for the session. The effect is keyed on adapterKey =
id:connectionTimestamp, so it also re-fires when a sensor (re)connects (the
id is unchanged across an auto-reconnect, but the timestamp changes).
options.isBarbell is the firmware's inclination-rejection flag (tighter
lateral-accel ceiling — repAyMax 30 vs 100), set from
trackingSetting.inclinationRejection (true for barbell equipment groups; iOS
passes it as isBarbell).
The original "only the left sensor triggers reps" bug was the right sensor never getting
setMode, so it never streamed — hence "every station sensor". The same bug re-surfaced inFeedbackTrainingFlow(fixed 2026-07-16): it configured only the primary sensor, leaving a pair partner in its previous mode. Both call sites now loop all station sensors, keyed on every adapter'sid:connectionTimestamp.minDistancereaches both sensors identically: the device-wide override, else the exercise default (exercise view) orFEEDBACK_DEFAULT_DISTANCE_THRESHOLD_CM= 20 cm (feedback flow), always clamped to 5–100 cm (training/distance-threshold.ts).
Settings verification (observational, 2026-07-21)
A WRITE_SETTINGS write is only acknowledged at the GATT layer — nothing
confirms the firmware applied the values, so a silently-unapplied config was
indistinguishable from a working one. configure now follows a successful
write + setMode with a READ_SETTINGS round-trip and diffs the echo against
the command it just sent (VERIFY_SETTINGS in
enode-sensor.ts, 1.5 s timeout).
- The response echoes the write payload at the same indices, so the diff is
a plain byte compare —
diffWrittenSettingsinenode-sensor-commands.ts(pure, tested against a real device capture). - Only indices 1…15 are comparable. Index 0 differs by design (response
opcode
0x01vs command0x02); index 16+ diverges — the command carriesiirFilter/accRangeThthere while the response carries device state (handleSettingsreads response byte 16 as the calibrating flag), and v100 firmware zero-fills the tail. 1…15 still covers every rep-detection parameter: the no-motion and rep-velocity thresholds,minDistance(11) andrepAyMax(13). - Observational only. A mismatch, or no answer at all, is logged via
console.warnand never changes control flow —configurestill resolves and nothing retries or throws on it. This is deliberate: we want to learn how often it fires on real hardware before letting it drive the retry loop, since firmware layouts vary more than the code assumes. The call sits inside the existing retrytryso that decision is a one-line change later. - Flip
VERIFY_SETTINGStofalseto drop the extra round-trip entirely.
Found while investigating a SmartBar pair on Android (2026-07-21). It did not explain that issue — both sensors verified byte-identical every run, and the cause turned out to be firmware-side. It closes a real blind spot regardless.
Device view ↔ tracking view mode ownership
Only strength / weightlifting / science / isometric modes carry the
quaternion stream that drives the 3D model (parseDataPoint). The connect-sensor
sheet ("device view",
ConnectSensorSheet.tsx)
therefore setMode("strength")s every connected station sensor while it's open
(skipped during active calibration so that handshake is untouched) — without
this, a sensor left in an exercise mode (jump/flywheel carry no quaternion) would
render a frozen model when the sheet is re-opened.
Because the device view and the per-station tracking effect both want to own the
mode, the tracking effect defers while the sheet is open (connectSheetOpen
is in its deps and short-circuits it) and re-applies the correct per-exercise
configure(options) the moment the sheet closes. This holds for both call
sites — ExerciseDetailView and FeedbackTrainingFlow. The feedback flow
lacked the gate until 2026-09-25, so a coach who picked a mode first and then
connected had the sensor configured mid-calibration, and re-opening the sheet
left the sensor in strength mode for the rest of the session. Net: device view ⇒ strength for
visualisation; active tracking view ⇒ the exercise's backend-resolved mode.
Disconnect & auto-reconnect
- Graceful disconnect (user-initiated). Tapping Disconnect / finishing
training tears the station down via the pool (
removeSensor/releaseStationwithgraceful: true), which sendsshouldDisconnect(command0xF1), waitsGRACEFUL_DISCONNECT_DELAY_MS(200 ms; iOS uses 250 ms), then disconnects — mirroring iOSuserDisconnect. Skipped for a dead link (statedisconnected/reconnecting). Fire-and-forget so the UI updates immediately. - Auto-reconnect (involuntary drop). A user disconnect removes the pool's
"disconnected"listener first, so reaching it means the device dropped on its own → the pool keeps the sensor asreconnectingand searches for the same id (livestartScanrediscovery →connect; connect-retry fallback when live scan is unavailable), up toRECONNECT_TIMEOUT_MS(60 s; iOS uses 120 s), then gives up and drops it. On rediscovery,EnodeSensor.reattach(connection)re-wires the same sensor object onto the fresh link (keeps identity + emitter listeners + station/side) and re-initializes. - Config restored on reconnect. A fresh connection resets the firmware to
its default mode, so
reattachcallsreapplyConfig()(replays the lastconfigure), and the per-station effect also re-fires (viaconnectionTimestamp) to apply the current desired config on top. - UI. While any station sensor is
reconnecting, the Disconnect button reads "Stop reconnect" (station-wide — covers a dual SmartBar with one side dropped); tapping it cancels the search and removes the sensor. - Web caveat. Reliable on native; on web it needs Chrome's experimental scan
flag, otherwise it falls back to
gatt.connect()polling (flaky).
Adapter state (Bluetooth turned off)
The stack reacts to the phone's Bluetooth adapter being toggled, not just to individual peripheral drops. Both are needed because turning the radio off does not reliably deliver a per-peripheral disconnect (notably iOS CoreBluetooth, which just invalidates the connections), so a connected sensor used to keep "looking connected" until the app was restarted.
BleService.isEnabled()reports whether the adapter is powered on. Native reads the real CoreBluetooth /BluetoothAdapterstate; web reportsisSupported()(Web Bluetooth can't read the radio's on/off state, and probing it would risk therequestDeviceuser-activation window).searchAndConnectgates on it before scanning and throwsBluetoothDisabledErrorwhen off — the connect sheet surfaces a localized "Bluetooth is off" message immediately instead of spinning until the ~20 s scan timeout fails with "no sensor found".adapterStateplugin event ({ enabled }) fires whenever the adapter powers on/off — iOScentralManagerDidUpdateState, Android anACTION_STATE_CHANGEDBroadcastReceiver. On off,CapacitorBleServicedrives every live connection'shandleDisconnect(), so each sensor enters the normal auto-reconnect path (§ Disconnect & auto-reconnect) rather than showing as connected; it reattaches automatically once the radio returns withinRECONNECT_TIMEOUT_MS. iOS also clears its staleconnected/charMapson off (keepingdiscoveredso the reconnect can re-establish the same peripheral).
Rep recording resilience (rep-recorder.ts, 2026-07-16)
The recorder is a faithful port of iOS RecordingDevice — which implicitly
assumes a lossless, ordered transport. Android BLE is neither, so the port
gained explicit defenses (each one traced to a device capture; all unit-tested):
- WL continuation idle filter. The WL lowering window (concentric end →
repEccentricWLEndValid) dropst === 0firmware idle samples — matching iOS, whose WL capture runs through theisRecordingpath after its t==0 guard. The ecc→conc turnaround continuation stays unfiltered (jump eccentrics stream all-t=0 by design; the degenerate-span 62.5 Hz resample inmakeRepDatahandles their time axis). Flag:WL_CONTINUATION_IDLE_FILTER. - WL continuation re-base on append (
rep-data.ts appendContinuationData, WL window only): if the appended samples' time base restarted (first t not after the rep's last t), time is shifted to continue at the concentric's end and positions chain onto its last position — otherwise the bar path drew one straight teleport line back to the origin. v201 firmware continues its clock through the WL window, so this is normally a no-op safety. - WL window discarded at the next rep. A WL continuation still open when
the next
repConcentricStartarrives is dropped (iOS clears its buffer at concentric start) — it used to be APPENDED to the previous rep, gluing the whole inter-rep window (plus the next rep's lead-in) onto it. - Dropped-start defense. A rep-end arriving without its start event
(dropped notification) seals an EMPTY trajectory instead of whatever
accumulated since the last seal (observed: a 202-sample "concentric"
spanning two reps). The rep keeps its firmware event metrics — reps are
never rejected for data problems; only the trajectory / uploaded data
package degrades (empty
rawData→encodeRepDatanull → package omitted). - Backlog trim (
trailingMonotonicRun). Rep events and data samples ride different characteristics; a starved link can drain the previous phase's queued samples AFTER the start event cleared the buffer. Every seal keeps only the trailing run whose time never steps backwards — stale backlog sits before a time restart and is dropped; clean buffers pass through unchanged; all-t=0 streams pass whole.
The only true rep rejection is the firmware's own repConcentricEndInvalid /
repEccentricEndInvalid (inclination rejection via isBarbell, minDistance,
detection profile) — currently silent in the UI (see follow-ups). It creates no
rep at all, which is a different thing from a recorded rep whose valid flag is
cleared later; invalid-reps.md is the source of truth for
both.
Diagnostics (all off in normal builds)
Flip together with loggingBehavior: 'debug' (capacitor.config.ts — on
Android it gates WebView console → logcat, so adb logcat captures them;
never ship it, the native per-notification logging costs real bridge
throughput):
REP_EVENT_DEBUG (enode-sensor.ts — every rep event with sensor serial,
mode, api + raw bytes), REP_DATA_DEBUG (use-sensor-reps.ts — per-rep
stream stats: n, t-range, monotonicity, max |s|, max sample jump, plus each
continuation append), FUSION_DEBUG (rep-fusion.ts — spans/health/merge
outcome per pair), PAIRING_DEBUG (use-station-reps.ts — every pairing
decision). window.__stationPendingReps exposes the live pending reps for a
raw-sample dump. Gradle CLI builds need
JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home".
Debug Sensors page (/debug/sensors)
The in-app answer to "the sensor is connected but doesn't track — where are
the reps lost?", readable on the device without a cable. Collected by
ble/sensor-diagnostics.ts,
which the sensor pool starts / stops next to the throughput monitor; the page
is debug/sensors/SensorDiagnosticsView.tsx.
- Per sensor: identity (serial, firmware, API version, battery), live data
rate, connects / involuntary drops, the last SET_MODE with the mode that
actually went over the wire (
nonewhen the API version is unknown), the lastconfigureoutcome and attempts, and the settings read-back result. - Rep counters: rep starts, valid rep-ends, rep-ends the firmware rejected
(
EndInvalid), undecodable rep-ends, and reps the app dropped (recordRepDroppedfromuse-sensor-reps.ts: no tracking mode, or a mode mismatch). - Findings:
diagnoseSensorturns the record into plain-language problems (unknown API version, idle wire mode, failed configure, read-back mismatch, rejected / dropped reps, starts without ends, weak link, drops). - Timeline of the last 80 events, and Copy report (JSON with findings and data rates) for a support ticket.
- Records outlive the connection — the feedback flow disconnects when it closes, so its session stays inspectable afterwards. "Reset" clears counters and forgets removed sensors.
- The data comes from a new
diagnosticevent onEnodeSensor(SensorDiagnostic:setMode/configure/settingsVerify); collecting only listens and never changes control flow. - On a device: the static export hides
/debugunless it is built withNEXT_PUBLIC_ENABLE_DEBUG_VIEW=true npm run build tracking.
Throughput monitor (throughput-monitor.ts)
Counts "data" events per sensor over a rolling 2 s window. Per-sensor rate is
a direct measure of link health (EXPECTED_STREAM_HZ = 50, degraded
< MIN_STABLE_HZ = 40). The pool starts/stops monitoring as sensors
connect/disconnect; UI shows the Hz + the per-sensor "weak connection" badge.
Two 2026-07-16 fixes make the weak signal honest: the rate divides by the
observed span (first stamp in window → now), not the fixed window — a
half-filled window used to read a healthy fresh connection at ~25 Hz and flash
the badge on every connect — and "weak" requires the low rate to persist
3 consecutive ticks (WEAK_CONSECUTIVE_TICKS, ~3 s; recovery clears
instantly), so single-tick jank dips can't flicker it. Tested
(throughput-monitor.test.ts).
Status (committed on tracking-features-v2)
-
7b02b99— unified BLE transport + multi-station pool (web/iOS/Android, device-verified) + throughput monitor + calibration persist. -
45167bf— SmartBar dual-sensor connect flow + side-by-side 3D. -
22c1899— SmartBar dual-sensor fusion + coordinator + the reference-frame (rotateVectorByMatrix) fix. -
Canonical-trajectory refactor — one
rawDataper rep, baked at rep finalization;fusedData/decodeRepRowsremoved; chart + upload read it verbatim (see Fusion). -
Disconnect & reconnect — graceful disconnect (
0xF1), auto-reconnect (rediscovery, 60 s, "Stop reconnect" +reattach/reapplyConfig),configure()retry, and inclination rejection (isBarbell). -
2026-07-16 — SmartBar-pair hardening (branch
sensor-video-fixes): the full device-log-driven pass documented above — WL continuation fixes, merge gates + health-based fallback, pairing late-upgrade + duration gate, recorder transport defenses, Android connection priority + notification batching, weak-badge debounce, compressed GLB sensor models, feedback-flow pair configuration. Diagnosed live overadbagainst an SM-X230 with a real ELEIKO pair.
npm run typecheck / lint / test (1548) are clean.
Open follow-ups
- Visible rep-rejection cue. Firmware
EndInvalidrejections are silent — indistinguishable from "the app lost my rep" without a USB cable. iOS exposesrepRejectedCallbackfor exactly this; wire it to a small UI signal (pulse / counter). - Un-adopted iOS knobs (from the 2026-07-16 full package comparison —
everything else is 1:1 or deliberately better): the 0.8 s pair window (web:
0.5 s + bounded late-upgrade),
repRejectionCountThreshold(skip the first N reps of a set), andmergeEccConData's quaternion-continuity stitch in the jump chart's ecc→con merge (web re-bases time only). - Single-sensor integration drift has no guard. The health gates only
protect pairs; a lone sensor's runaway integration (impact / missed
no-motion reset) flows through. Candidates: event-level plausibility bound,
or tying
rangeErrorto rep validity. apiIncompatiblehas no consumer. iOS un-subscribes all notifies on a too-new sensor API; the web only emits the event.- v100 jump raw stream is misparsed (strength layout parsed as jump while
V100_JUMP_STRENGTH_WIREdrives squat/CMJ through the strength engine) — reps/metrics are event-based and unaffected; per-rep charts for v100 jumps are empty/wrong. - Fusion is strength + weightlifting only (jump/flywheel fusion is commented out in iOS too).
enode-sensor.tsis large; a split is deferred (extract the pure event decoders + the type surface, keep the class a thin shell).
iOS reference sources
The Swift originals live in a sibling checkout outside this repo (a local
development/packages/ tree — not vendored here). Key files:
EnodeSensorPackage/.../EnodeSensor/Device/EnodeSensorMath.swift—makeRepData,rotateRepData, the merge.EnodeSensorPackage/.../EnodeSensor/Device/EnodeSensorStream.swift—processUpdate(builds s/a RAW, onlyqgetsprepare).EnodeSensorPackage/.../EnodeSensor/Recording/RecordingDevice.swift— buffering.EnodeGeneralExtensions/.../Math/MBQuaternion.swift+MBVector.swift—.reference(),.rotate(by:)vs.rotateBy(_:), the products.
When porting, read the Swift, match the convention exactly, add a boundary test. The exact local base path is recorded in agent memory.
Device-verify loop
Web changes are live on the dev server (npm run dev tracking, :3000) — best
debugged in a Chrome session (Web Bluetooth + experimental flag for live scan).
Native is not live-reload; the app bundles the static export:
npm run build tracking
cd apps/tracking && npx cap sync ios # or: npx cap sync android
# then Run (⌘R) in Xcode / Android Studio
cap sync does not build the web bundle — run npm run build first, and run
cap sync from apps/tracking, not the repo root.