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_HIGHright 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, 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).
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 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 —
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. 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".
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.