Skip to main content

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 test covers 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 to Uint8Array at 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 lifecycle setReconnecting / reattach). Everything UI-side types its sensor as SensorAdapter and never cares which platform produced it.

Entry points

ble/index.ts is the public surface:

  • getBleService() — singleton, picks WebBluetoothService on web or CapacitorBleService on a native platform.
  • searchAndConnect({ signal }) — the one "tap Connect → live sensor" flow for all platforms: pickDeviceconnectnew EnodeSensor(connection)initialize(). Honors an AbortSignal at every step (cancel = AbortError the 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

PlatformBleService implNative plugin
Web (Chrome)web-bluetooth.ts— (Web Bluetooth API)
iOScapacitor-ble.tsapps/tracking/ios/App/App/Plugins/EnodeBlePlugin.swift
Androidcapacitor-ble.tsapps/tracking/android/.../plugins/EnodeBlePlugin.kt
  • Live scan with streaming RSSI requires Chrome's experimental flag on web (supportsLiveScan()); without it, pickDevice falls 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 requests CONNECTION_PRIORITY_HIGH right after connect (next to the MTU request) — 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, missing EndValids and byte-corrupted event packets. EnodeBlePlugin now batches: the GATT callback only enqueues (returns instantly); a single FIFO flushes every 50 ms as ONE notificationBatch bridge 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 both notification (web/iOS, unchanged) and notificationBatch (Android).

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.

  • parseDataPoint builds per-mode samples (strength / jump / flywheel). prepareQuaternion(raw, deviceName) applies iOS prepare(quaternion:): a base raw × (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):
    iOSherenotes
    .multiply(with:)multiplyQuaternionw-scalar Hamilton product
    .multiply(by:)multiplyQuaternionByx-scalar product (rep-data only)
    .rotate(by:)rotateVectorq⊗v⊗q⁻¹; used only inside referenceQuaternion
    .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) — the Mode enum, setMode/writeSettings byte 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 iOS RecordingDevice: keeps buffering past EccentricEndValid until ConcentricStart so 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 } where position: "left" | "right" | null.
  • A station owns ONE logical tracking source. A single sensor = its own source; a SmartBar = 1–2 VMP_SB sensors 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.tsstationSensors, isSmartBarStation, canAddSecond (only a second VMP_SB is allowed; NotASmartBarError otherwise), addSecond (assigns the opposite side), swapSides, removeSide.
  • ConnectSensorSheet.tsxSmartBarConnectPanel: L/R chips, side-by-side MultiSensorViewer, per-sensor battery ring + Hz, shared note, "Switch side" (works on a lone sensor too), "Add second sensor".
  • 3D (SensorViewer.tsx) — MultiSensorViewer renders 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-aware useSensorModel (FBX still supported for smartDevice.fbx); the GLBs preserve the FBX world axes exactly, so the orientation offsets apply unchanged. Bounding-box fit uses multiplyScalar (not setScalar, which clobbers the baked root scale).
  • Calibration (packages/core/src/calibration/) — six axis cones (FaceID). useCalibratedFaces seeds synchronously from a cached WeakMap; cone visibility is driven by a raw-pulse animationStartRef (not the live calibrated set), with an animationStart === null dedup guard so held-face re-emits don't flicker, plus a mount-init effect for cached faces. Bar model/view orientation offsets in model-orientation.ts are currently identity (0,0,0) / (-90,0,0); dev console helpers setBarSensorOffset / setBarViewOffset tune them live.

Fusion (strength + weightlifting only)

  • Math (rep-fusion.ts, tested) — 1:1 port of iOS EnodeSensorMath: rotateRepRows (right sensor → shared frame via Q0=(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 the PendingRep canonicalizers that each produce the single canonical rawData: 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 a useSensorReps per slot (≤2):

    • 1 sensor: canonicalize each rep at finalization — lone rightrotateSingleRightRep, otherwise correctSingleRep.
    • 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.
  • 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 single WorkoutRep.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 rawData verbatim and never re-corrects: the chart maps it straight to samples, the upload mapper (api/mappers/sessions.ts) just encodeRepData(rawData), and rep-validation reads it as-is. The old rawData(raw) + fusedData(corrected blob) split (and decodeRepRows) was collapsed into this single field.

  • 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). chooseFusion only 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):

    1. 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.
    2. 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.
    3. 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.
    4. Bar-rigidity validation passes (the two trajectories stay 2.18 m ± 0.25 apart). iOS computes-and-discards this flag; the web now consumes it.
  • 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.

⚠️ Architecture divergence. Fusion landed at the rep-coordinator layer (use-station-reps.ts), not as the SmartBarUnit adapter originally sketched. ble/smart-bar-unit.ts's selectTrackingSensor returns 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 in FeedbackTrainingFlow (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's id:connectionTimestamp. minDistance reaches both sensors identically in the exercise view (override → exercise default); the feedback flow has no exercise context and leaves the firmware default (20 cm).

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 — diffWrittenSettings in enode-sensor-commands.ts (pure, tested against a real device capture).
  • Only indices 1…15 are comparable. Index 0 differs by design (response opcode 0x01 vs command 0x02); index 16+ diverges — the command carries iirFilter / accRangeTh there while the response carries device state (handleSettings reads 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) and repAyMax (13).
  • Observational only. A mismatch, or no answer at all, is logged via console.warn and never changes control flow — configure still 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 retry try so that decision is a one-line change later.
  • Flip VERIFY_SETTINGS to false to 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. 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 / releaseStation with graceful: true), which sends shouldDisconnect (command 0xF1), waits GRACEFUL_DISCONNECT_DELAY_MS (200 ms; iOS uses 250 ms), then disconnects — mirroring iOS userDisconnect. Skipped for a dead link (state disconnected/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 as reconnecting and searches for the same id (live startScan rediscovery → connect; connect-retry fallback when live scan is unavailable), up to RECONNECT_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 reattach calls reapplyConfig() (replays the last configure), and the per-station effect also re-fires (via connectionTimestamp) 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 / BluetoothAdapter state; web reports isSupported() (Web Bluetooth can't read the radio's on/off state, and probing it would risk the requestDevice user-activation window). searchAndConnect gates on it before scanning and throws BluetoothDisabledError when 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".
  • adapterState plugin event ({ enabled }) fires whenever the adapter powers on/off — iOS centralManagerDidUpdateState, Android an ACTION_STATE_CHANGED BroadcastReceiver. On off, CapacitorBleService drives every live connection's handleDisconnect(), so each sensor enters the normal auto-reconnect path (§ Disconnect & auto-reconnect) rather than showing as connected; it reattaches automatically once the radio returns within RECONNECT_TIMEOUT_MS. iOS also clears its stale connected/charMaps on off (keeping discovered so 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) drops t === 0 firmware idle samples — matching iOS, whose WL capture runs through the isRecording path 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 in makeRepData handles 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 repConcentricStart arrives 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 rawDataencodeRepData null → 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".

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 rawData per rep, baked at rep finalization; fusedData / decodeRepRows removed; 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 over adb against an SM-X230 with a real ELEIKO pair.

npm run typecheck / lint / test (1548) are clean.

Open follow-ups

  • Visible rep-rejection cue. Firmware EndInvalid rejections are silent — indistinguishable from "the app lost my rep" without a USB cable. iOS exposes repRejectedCallback for 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), and mergeEccConData'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 rangeError to rep validity.
  • apiIncompatible has 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_WIRE drives 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.ts is 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.swiftmakeRepData, rotateRepData, the merge.
  • EnodeSensorPackage/.../EnodeSensor/Device/EnodeSensorStream.swiftprocessUpdate (builds s/a RAW, only q gets prepare).
  • 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.