Stream heart rate from a strap or watch into the ride and the FIT file
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped

A new HRM client in the BLE crate follows the crate's split: the 0x2A37
decoder is a pure function over bytes (u8/u16 formats, the three-state
sensor-contact field, straps that append energy/RR data), and only the
actor touches the radio. Anything exposing the standard Heart Rate
Service works — a chest strap, or a Garmin watch with Broadcast Heart
Rate on.

A single-slot supervisor in the app owns the link, shaped like the
trainer's and the controller's. It publishes bpm on a watch channel the
session backend stamps onto each tick's telemetry — never over a heart
rate FTMS itself reported, on the same authority rule as the Zwift
cadence merge — and it clears the reading after eight silent seconds,
so a strap taken off records nothing rather than a flatline of the
last real value. From there the existing pipeline does the rest: ride
screen tile, FIT records, avg/max in lap and session.

The device list routes heart-rate rows to the supervisor, keeps the row
alive while connected (a connected monitor stops advertising), and
shows the live bpm as proof data is flowing — a connected-but-silent
monitor otherwise looks exactly like a working one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 19:27:04 +02:00
co-authored by Claude Fable 5
parent be046f341c
commit 3274928a5f
10 changed files with 1467 additions and 24 deletions
+26 -1
View File
@@ -140,6 +140,28 @@
: ''}
</span>
</span>
{:else if device.kind === 'heartRate'}
<!-- The live reading is the row's proof that data is flowing: a
monitor that is connected but silent looks exactly like a
working one otherwise. -->
<span class="state">
<span class="label">Heart rate</span>
<span
class="value"
class:tone-ok={device.heartRateBpm != null}
class:tone-idle={device.heartRateBpm == null}
>
<span class="dot"></span>{device.heartRateBpm != null
? `${device.heartRateBpm} bpm`
: '—'}
</span>
</span>
{#if device.batteryPct != null}
<span class="state">
<span class="label">Battery</span>
<span class="value tone-idle"><span class="dot"></span>{device.batteryPct}%</span>
</span>
{/if}
{:else if device.batteryPct != null}
<span class="state">
<span class="label">Battery</span>
@@ -173,7 +195,10 @@
<ul>
<li><strong>Trainer</strong> — turn the pedals for a few seconds.</li>
<li><strong>Zwift Click</strong> — press any button on the pod.</li>
<li><strong>Heart rate strap</strong> — wet the contacts and put it on.</li>
<li>
<strong>Heart rate</strong> — wet a strap's contacts and put it on; on a sports watch,
switch on <em>Broadcast Heart Rate</em>.
</li>
</ul>
<p class="quiet">
They will appear here as soon as they advertise. Scanning continues in the background.
+2
View File
@@ -252,6 +252,8 @@ export interface DeviceInfo {
services: string[];
remembered: boolean;
batteryPct: number | null;
/** Live reading from a connected heart rate monitor — proof data is flowing. */
heartRateBpm: number | null;
error: string | null;
}