Stream heart rate from a strap or watch into the ride and the FIT file
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user