Reconnect to remembered hardware instead of pairing every launch

`remembered` was a HashSet inside DeviceRegistry, so it lasted exactly as
long as the process. Every launch started from nothing: find the trainer,
press Connect, find the strap, press Connect, and only then ride. FR-1.5
has been a Should since the beginning and was never actually true.

It is a file now — devices.json in the app data directory, written when a
link actually comes up rather than when Connect is pressed. A Connect the
hardware then refuses is not a pairing, and writing one down would mean a
trainer the rider gave up on getting chased on every launch afterwards.
Forgetting is recorded too, in its own list: absent means never seen,
forgotten means the rider looked at this device and said no, and
auto-connect has to keep honouring that on the next launch as well. The
file is advisory — a corrupt one costs auto-connect, never a ride.

Auto-connect is driven by the scan rather than fired once at startup. The
hardware is asleep at startup — a trainer wakes when the cranks turn, a
strap when it is put on (A-4) — so a remembered device is reconnected the
moment it advertises, through the same path the rider's own click takes,
scan suspension included. Bounded by AUTO_ATTEMPTS on an AUTO_RETRY
cooldown and cleared when the link comes up or the rider connects by
hand: an app that never stops trying can never honestly say it has
stopped (FR-1.11). A device disconnected by hand is left alone for the
rest of the session, since a disconnect that undoes itself two ticks
later is not a disconnect.

Pods now prefer the pod we know. Every Click advertises the same name and
the same type byte, so before this a rider whose partner was warming up
in the next room got whichever pod woke first. With nothing of that kind
remembered anything still goes, or there could never be a first pairing.

And the pair is one pod, not two. Confirmed on this hardware 2026-08-21:
pairing the − pod alone delivers all ten buttons, its twin's included —
which §2.3.1 had established for the frames but not for the pairing. So
take_plus_pod holds the + pod back while a known − pod may merely be
asleep, and connect_controller with no pod named means the − pod rather
than both. The wait is bounded by PLUS_GRACE, because a flat − pod should
cost the rider a D-pad and not a controller, and Buttons is untouched: it
is what makes the handover between the two configurations invisible.

Not yet tested against real hardware — nothing was advertising here. The
store, the retry budget and the pod-preference rules have unit tests, and
a seeded devices.json was confirmed to load and seed the − pod at launch,
but the connect path itself waits for a ride.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:36:34 +02:00
co-authored by Claude Opus 5
parent 5dff2500e2
commit 7497a5d602
8 changed files with 877 additions and 85 deletions
+13
View File
@@ -188,6 +188,17 @@ button we have found drives them.
> so it is evidence of a wrong-way-round pair only while that pod has never sent its own —
> otherwise the connection screen asks the rider to swap a pair that is filed correctly.
> **One link is the whole controller — confirmed 2026-08-21.** Pairing the `` pod *alone*
> delivers all ten buttons on this hardware: its own paddle and D-pad, plus the `+` paddle and
> face buttons relayed from its twin. There is nothing a second link adds, and there is one
> thing it takes away — connected as a pair, the `` pod stops reporting its own paddle.
>
> So the app pairs the `` pod and stops there (`controller::take_plus_pod`). The `+` pod is a
> **substitute, not a second half**: it is connected on sight only when no `` pod is known, or
> when a known one has been unreachable for `PLUS_GRACE`, so a flat `` pod costs the rider a
> D-pad rather than a controller. `Buttons` stays exactly as it is — it is what makes the
> handover between the two configurations invisible.
### 2.3.2 The D100's own Zwift service — telemetry, not shifting
The trainer answers the same handshake (`RideOn 00 09``RideOn 02 00`) on the original
@@ -390,6 +401,8 @@ bikecontrol/
| FR-1.3 | Connect to trainer and controller independently; either may connect first | Must |
| FR-1.4 | Track the left and right pods as separate connections, since each is an independent peripheral | Must |
| FR-1.5 | Remember paired devices and auto-connect on launch | Should |
| FR-1.5a | Remembering survives the process: trainer, `` pod and heart rate monitor are written to `devices.json` in the app data directory when a link actually comes up, and a device the rider forgets is recorded as refused rather than merely dropped | Should |
| FR-1.5b | Auto-connect is driven by the scan, not by startup — the hardware is asleep at launch (A-4), so a remembered device is reconnected the moment it advertises. Bounded by `AUTO_ATTEMPTS`, so "gave up" (FR-1.11) is not contradicted two ticks later, and suspended for any device the rider disconnected by hand this session | Should |
| FR-1.6 | Auto-reconnect on unexpected disconnect, with backoff, without ending the ride | Must |
| FR-1.7 | Surface per-device connection state (scanning / connecting / connected / lost) | Must |
| FR-1.8 | When nothing is found, prompt to wake the device (per A-4) — pedal the trainer, press a Click button | Must |
+9 -6
View File
@@ -728,7 +728,14 @@ pub fn controller_status(state: State<'_, AppState>) -> ControllerStatus {
state.controller().status()
}
/// Connect a Click pod, or both when `pod` is omitted (FR-1.4).
/// Connect a Click pod, or the one that matters when `pod` is omitted (FR-1.4).
///
/// **Omitting `pod` means the `` pod, not both.** One link is the whole
/// controller: the `` pod relays its twin's paddle and face buttons, so all
/// ten buttons arrive over it alone (§2.3.1, confirmed in the field
/// 2026-08-21). Connecting the pair adds nothing and breaks one thing — the
/// `` pod stops reporting its own paddle. The `+` pod keeps its own button on
/// the connection screen for the case where the `` pod is flat or absent.
///
/// `device_id` is an address, for a specific pod the scanner has already
/// listed. Without one the supervisor looks the pod up by the type byte in its
@@ -753,12 +760,8 @@ pub fn connect_controller(
if address.is_some() {
return Err("An address names one pod, so say which pod it is".into());
}
// Both, each on its own schedule: a pod that is awake connects now
// rather than queueing behind its sleeping twin.
let known = state.lock().devices.click_pod_addresses();
for id in PodId::BOTH {
controller.connect(id, known.get(&id).cloned());
}
controller.connect(PodId::Minus, known.get(&PodId::Minus).cloned());
}
}
Ok(())
+131 -15
View File
@@ -74,6 +74,19 @@ const NO_INPUT_AFTER: Duration = Duration::from_secs(150);
/// trainer's: there is no reset sequence here, only an unsubscribe and a
/// disconnect, and this budget is spent on the same window close (NFR-9).
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
/// How long a known `` pod is waited for before a lone `+` pod will do.
///
/// One link is the whole controller: the `` pod relays its twin's paddle and
/// face buttons (§2.3.1), so with a `` pod in the house there is nothing for a
/// second link to add and one specific thing for it to break — connected as a
/// pair, the `` pod stops reporting its *own* paddle. Confirmed in the field
/// 2026-08-21: pairing the `` pod alone gives all ten buttons.
///
/// But holding out forever would mean a flat or lost `` pod costs the rider
/// their `+` paddle as well, which is a worse trade than a redundant link. So
/// the wait is bounded: long enough for a rider to wake both pods in whatever
/// order they like, short enough that half a controller beats none.
const PLUS_GRACE: Duration = Duration::from_secs(45);
/// How long auto-reconnect keeps chasing a pod before it gives up and says so.
///
/// FR-1.11. More generous than the trainer's, because a Click genuinely does
@@ -379,6 +392,14 @@ enum Cmd {
/// Connect one pod. `address` is used when the scanner has already seen it,
/// which is both faster and unambiguous.
Connect { pod: PodId, address: Option<String> },
/// This is the pod we paired with last time (FR-1.5).
///
/// Sets the slot's address without touching the radio, so a pod that is
/// still asleep is nonetheless *known* — which is what lets the `+` pod
/// hold back for a `` pod that has not woken up yet, and what gives a
/// manual connect an address to go straight to instead of a type byte to
/// go hunting with.
Remember { pod: PodId, address: String },
/// The scanner has this pod in view *right now* (FR-1.5).
///
/// The whole difficulty with a Click is that it advertises for only a few
@@ -456,6 +477,14 @@ impl ControllerHandle {
let _ = self.cmd_tx.try_send(Cmd::Connect { pod, address });
}
/// Seed the pod we paired with last time, from the remembered-device store.
pub fn remember(&self, pod: PodId, address: &str) {
let _ = self.cmd_tx.try_send(Cmd::Remember {
pod,
address: address.to_string(),
});
}
/// Tell the supervisor a pod is advertising right now (FR-1.5).
///
/// Called from the device scan on every pass. Cheap and idempotent: the
@@ -587,6 +616,10 @@ async fn run(
let mut swapped = false;
// One press is one press, whichever pods report it (see `Buttons`).
let mut buttons = Buttons::default();
// When a lone `+` pod stops being worse than no controller at all. Pushed
// back whenever the `` pod is reachable, so it only ever expires against a
// `` pod that is genuinely not coming (see `PLUS_GRACE`).
let mut plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
@@ -618,29 +651,43 @@ async fn run(
let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
start_attempt(slot, pod, selector, &attempt_tx);
if pod == PodId::Minus {
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
}
status_tx.send_modify(|s| s.get_mut(pod).reset_link(PodState::Searching));
}
Cmd::Remember { pod, address } => {
// Only fills a gap. A pod we have actually talked to
// this session knows its own address better than a file
// written last week does.
status_tx.send_modify(|s| {
let p = s.get_mut(pod);
if p.address.is_none() && !address.trim().is_empty() {
tracing::info!(pod = pod.as_str(), %address, "controller: remembered pod");
p.address = Some(address);
}
});
}
Cmd::Seen { pod, address } => {
// One link is the whole controller.
//
// The `` pod relays its twin: connected on its own it
// delivers its own paddle and D-pad *and* the `+`
// paddle and face buttons — all ten buttons, measured
// over 445 frames on one characteristic (§2.3.1). So
// the `+` pod is not connected while the `` pod is
// there to speak for it. It stays a fallback for the
// case where the `` pod is absent or the rider only
// owns that half.
//
// Connecting both is what the pair-merge in `Buttons`
// One link is the whole controller — see
// `take_plus_pod`, which owns this rule. The short of
// it: the `` pod relays its twin, so all ten buttons
// arrive over it alone (§2.3.1, confirmed in the field
// 2026-08-21), and a `+` link is a substitute for a ``
// pod that is missing rather than the other half of a
// pair. Connecting both is what the merge in `Buttons`
// exists to paper over, and it is also the
// configuration in which the `` pod stops reporting
// its own paddle — the failure that cost an evening.
// Not opening the second link removes both.
if pod == PodId::Plus && !slot_mut(&mut minus, &mut plus, PodId::Minus).idle()
if pod == PodId::Plus
&& !take_plus_pod(
minus.idle(),
status_tx.borrow().minus.address.is_some(),
tokio::time::Instant::now() >= plus_gate,
)
{
tracing::debug!(
"controller: + pod seen but the pod is already speaking for it"
"controller: + pod seen; the pod speaks for the pair"
);
continue;
}
@@ -653,6 +700,9 @@ async fn run(
// already found are the seconds it goes back to sleep in.
tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
start_attempt(slot, pod, PodSelector::Address(address.clone()), &attempt_tx);
if pod == PodId::Minus {
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
}
status_tx.send_modify(|s| {
let p = s.get_mut(pod);
p.address = Some(address);
@@ -738,6 +788,13 @@ async fn run(
}
_ = housekeeping.tick() => {
// A `` pod that is up, or on its way up, is a `` pod worth
// waiting for. Only a slot that has been idle for the whole
// grace period lets the `+` pod in.
if !minus.idle() {
plus_gate = tokio::time::Instant::now() + PLUS_GRACE;
}
// Converge on one link. The `+` pod may have connected first —
// it is the one the rider happened to wake — and once the ``
// pod is up it speaks for both, so the second link is redundant
@@ -820,6 +877,25 @@ async fn run(
}
}
/// May a `+` pod the scan has just seen be connected?
///
/// The `` pod is the controller (§2.3.1): connected on its own it delivers all
/// ten buttons, its twin's included. So a `+` link is only ever a *substitute*,
/// and opening one alongside a working `` link is the configuration in which
/// the `` pod stops reporting its own paddle.
///
/// Three inputs, in the order they decide:
/// - `minus_idle` — false when the `` pod is connected or being connected.
/// Nothing else matters then: it is already speaking for both.
/// - `minus_known` — we have an address for a `` pod, from this session or
/// from the remembered-device store. With none, there is no `` pod to wait
/// for and the `+` pod is the whole controller.
/// - `gate_expired` — the `` pod has been unreachable for [`PLUS_GRACE`].
/// A flat or lost `` pod must not cost the rider their `+` paddle too.
fn take_plus_pod(minus_idle: bool, minus_known: bool, gate_expired: bool) -> bool {
minus_idle && (!minus_known || gate_expired)
}
fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
match pod {
PodId::Minus => minus,
@@ -1574,6 +1650,46 @@ mod tests {
assert!(!s.plus.contradicted && !s.minus.contradicted);
}
#[test]
fn one_link_is_the_whole_controller() {
// Confirmed in the field 2026-08-21: pairing the pod alone gives all
// ten buttons, because it relays its twin (§2.3.1). So the + pod is a
// substitute, never a second half.
// The pod is up, or on its way up. Nothing else matters.
assert!(!take_plus_pod(false, true, true));
assert!(!take_plus_pod(false, false, true));
// We know a pod exists and it has not been out of reach for long. It
// is almost certainly just asleep — a Click only advertises while awake
// (A-4) — so wait rather than open a link we would only close again.
assert!(!take_plus_pod(true, true, false));
// No pod has ever been seen or remembered: this rider's + pod *is*
// their controller, and making them wait for a pod they do not own
// would be waiting forever.
assert!(take_plus_pod(true, false, false));
// The pod is known but has stayed out of reach. Flat, or left in the
// garage. Half a controller beats none.
assert!(take_plus_pod(true, true, true));
}
#[test]
fn waiting_for_the_minus_pod_is_shorter_than_giving_up_on_it() {
// The grace period is a pause, not a policy: it must expire long before
// the reconnect budget does, or a rider whose pod is flat sits with no
// controller at all while the app keeps hoping.
assert!(
PLUS_GRACE <= Duration::from_secs(60),
"a rider with a flat pod waits {PLUS_GRACE:?} for their + paddle"
);
assert!(
PLUS_GRACE >= Duration::from_secs(20),
"shorter than the time it takes to wake two pods by hand"
);
}
#[test]
fn a_missing_pod_is_reported_as_something_to_do_not_as_not_found() {
let advice = connect_advice(PodId::Minus, &FtmsError::NotFound("the Click pod".into()));
+311 -27
View File
@@ -19,7 +19,8 @@
//! point is refused.
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use bikecontrol_ble::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
use bikecontrol_ble::uuids;
@@ -31,6 +32,7 @@ use uuid::Uuid;
use crate::controller::{ControllerHandle, PodState};
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
use crate::known::KnownDevices;
use crate::trainer::{TrainerHandle, TrainerStatus};
/// One pass of the scanner. Long enough for a trainer to advertise, short
@@ -41,6 +43,23 @@ const IDLE_POLL: Duration = Duration::from_millis(400);
/// How long to wait before looking for the adapter again. Longer than the scan
/// cadence: nothing the rider can do about a missing radio happens in 400 ms.
const ADAPTER_RETRY: Duration = Duration::from_secs(2);
/// How long before auto-connect tries a remembered device again (FR-1.5).
///
/// An attempt only starts against a peripheral the scan can *see*, so the
/// common case needs no backing off at all. This is for the awkward one: a
/// trainer that advertises happily and then refuses the connect, which without
/// a cooldown would be retried every device tick — four failed connects a
/// second, all of them fighting the scan for the one adapter.
const AUTO_RETRY: Duration = Duration::from_secs(15);
/// How many times auto-connect will chase one device before leaving it alone.
///
/// FR-1.11 in the small: giving up has to be terminal until the rider acts, and
/// an unbounded retry would quietly undo it — the trainer supervisor announces
/// that it has stopped trying, and two ticks later the device list starts the
/// whole thing again. Cleared the moment the link does come up, and by any
/// Connect the rider presses themselves, so the budget is per problem rather
/// than per lifetime.
const AUTO_ATTEMPTS: u32 = 5;
/// What to suggest when there is no adapter. The remedy is platform-specific
/// and telling an Android rider to check BlueZ is worse than saying nothing.
@@ -126,6 +145,14 @@ pub struct PollResult {
pub hr_changed: Option<HeartRateStatus>,
}
/// Auto-connect's memory of one address: when it last tried, and how many
/// times it has tried since the last time the link actually came up.
#[derive(Debug, Clone, Copy)]
struct AutoAttempts {
last: Instant,
tries: u32,
}
/// What the scan task publishes.
#[derive(Debug, Clone, Default)]
pub struct ScanSnapshot {
@@ -146,8 +173,23 @@ pub struct DeviceRegistry {
hr: HeartRateHandle,
scan_rx: watch::Receiver<ScanSnapshot>,
scan_on: watch::Sender<bool>,
forgotten: HashSet<String>,
remembered: HashSet<String>,
/// Everything the rider has paired with, and everything they have refused.
/// Backed by a file, so it outlives the process (FR-1.5).
known: KnownDevices,
/// Links the rider closed by hand *this session*. Auto-connect must not
/// undo a deliberate disconnect two ticks later — that is not a disconnect,
/// it is a flicker. Deliberately not persisted: a fresh launch is a fresh
/// intention, and next time they start the app they do want their trainer.
auto_off: HashSet<String>,
/// What auto-connect has already tried, per address, so a device that
/// advertises but will not connect is retried on a schedule and then let
/// be (see [`AUTO_RETRY`], [`AUTO_ATTEMPTS`]).
auto: HashMap<String, AutoAttempts>,
/// Addresses the most recent scan pass actually saw. A remembered device is
/// only chased while it is advertising: a connect against a sleeping
/// peripheral burns the whole scan timeout for nothing, and the scan is
/// already telling us the moment it wakes (A-4).
seen_now: HashSet<String>,
/// The list published last tick, for change detection.
published: Vec<DeviceInfo>,
last_trainer: TrainerStatus,
@@ -173,8 +215,10 @@ impl DeviceRegistry {
hr,
scan_rx,
scan_on,
forgotten: HashSet::new(),
remembered: HashSet::new(),
known: KnownDevices::default(),
auto_off: HashSet::new(),
auto: HashMap::new(),
seen_now: HashSet::new(),
published: Vec::new(),
scanning: false,
scan_suspended: false,
@@ -182,6 +226,29 @@ impl DeviceRegistry {
}
}
/// Load the remembered devices and tell the supervisors what we know
/// (FR-1.5).
///
/// Separate from `new` because `AppState::new` runs before Tauri can hand
/// out an `AppHandle`, and without one there is no data directory to read.
/// Until this is called the registry remembers nothing, which is the
/// correct behaviour for the handful of milliseconds it lasts.
pub fn attach_store(&mut self, path: PathBuf) {
self.known = KnownDevices::load(path);
// Hand the controller the pod we paired with last time. It does not
// connect anything — it is what lets the supervisor hold out for *our*
// pod instead of grabbing the first Click that happens to wake up.
for kind in [DeviceKind::ClickMinus, DeviceKind::ClickPlus] {
if let (Some(pod), Some(known)) = (kind.pod_id(), self.known.first_of(kind)) {
self.controller.remember(pod, &known.address);
}
}
tracing::info!(
count = self.known.len(),
"remembered devices loaded; they will reconnect as they advertise"
);
}
/// Rider-initiated. Cancels any suspension: an explicit request outranks
/// our own bookkeeping in both directions.
pub fn start_scan(&mut self) {
@@ -229,6 +296,10 @@ impl DeviceRegistry {
let changed = next != self.published;
self.published = next;
// After publishing, never before: `connect` reads the published list,
// and a device discovered this tick has to be in it to be connectable.
self.auto_connect();
PollResult {
changed,
transitions,
@@ -244,10 +315,12 @@ impl DeviceRegistry {
let controller = self.controller.status();
self.seen_now.clear();
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
for d in &snapshot.devices {
let id = d.address.clone();
if self.forgotten.contains(&id) {
self.seen_now.insert(id.clone());
if self.known.is_forgotten(&id) {
continue;
}
let kind = classify(d);
@@ -257,8 +330,15 @@ impl DeviceRegistry {
// is the pod the rider already told us about by pressing a button
// on it. So the scan connects it (FR-1.5), unless they disconnected
// it on purpose, in which case the supervisor ignores this.
//
// Once we have paired with a pod, it is that pod we chase. Every
// Click advertises the same name and the same type byte, so without
// this a rider whose partner is warming up in the same room gets
// whichever pod woke first.
if let Some(pod) = kind.pod_id() {
self.controller.pod_seen(pod, &id);
if is_ours(&self.known, kind, &id) {
self.controller.pod_seen(pod, &id);
}
}
out.push(DeviceInfo {
kind,
@@ -272,7 +352,9 @@ impl DeviceRegistry {
},
control_acquired: false,
services: d.services.iter().map(|u| describe_service(*u)).collect(),
remembered: self.remembered.contains(&id),
// Filled in below, once every row exists: the pass that records
// a live link is the same pass that reads the store back.
remembered: false,
battery_pct: None,
heart_rate_bpm: None,
error: None,
@@ -296,7 +378,7 @@ impl DeviceRegistry {
state: ConnectionState::Idle,
control_acquired: false,
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
remembered: true,
remembered: false,
battery_pct: None,
heart_rate_bpm: None,
error: None,
@@ -308,7 +390,6 @@ impl DeviceRegistry {
device.kind = DeviceKind::Trainer;
device.state = trainer.state.clone();
device.control_acquired = trainer.control_acquired;
device.remembered = true;
device.error = trainer.error.clone().or_else(|| {
trainer
.stale
@@ -329,7 +410,7 @@ impl DeviceRegistry {
let Some(address) = pod.address.clone() else {
continue;
};
if self.forgotten.contains(&address) {
if self.known.is_forgotten(&address) {
continue;
}
let kind = match id {
@@ -348,7 +429,7 @@ impl DeviceRegistry {
state: ConnectionState::Idle,
control_acquired: false,
services: vec![describe_service(ZWIFT_SERVICE)],
remembered: true,
remembered: false,
battery_pct: None,
heart_rate_bpm: None,
error: None,
@@ -360,7 +441,6 @@ impl DeviceRegistry {
device.kind = kind;
device.battery_pct = pod.battery_percent;
device.error = pod.error.clone();
device.remembered = true;
device.state = match pod.state {
PodState::Connected => ConnectionState::Connected,
PodState::Searching => ConnectionState::Connecting,
@@ -380,7 +460,7 @@ impl DeviceRegistry {
// link.
let hr = self.hr.status();
if let Some(address) = hr.address.clone() {
if !self.forgotten.contains(&address) {
if !self.known.is_forgotten(&address) {
let idx = match out.iter().position(|d| d.id == address) {
Some(i) => i,
None => {
@@ -396,7 +476,7 @@ impl DeviceRegistry {
state: ConnectionState::Idle,
control_acquired: false,
services: vec![describe_service(HEART_RATE_SERVICE)],
remembered: true,
remembered: false,
battery_pct: None,
heart_rate_bpm: None,
error: None,
@@ -409,7 +489,6 @@ impl DeviceRegistry {
device.battery_pct = hr.battery_percent;
device.heart_rate_bpm = hr.bpm;
device.error = hr.error.clone();
device.remembered = true;
if let Some(name) = &hr.name {
device.name = name.clone();
}
@@ -429,9 +508,95 @@ impl DeviceRegistry {
.then(b.rssi.cmp(&a.rssi))
.then(a.id.cmp(&b.id))
});
// A link that actually came up is what "paired" means (FR-1.5) — not a
// Connect the rider pressed against a trainer that then refused them.
// So the store is written from the finished list rather than from the
// request, and the row's `remembered` flag is read straight back out of
// it, which keeps the screen and the file incapable of disagreeing.
for device in &out {
if matches!(
device.state,
ConnectionState::Connected | ConnectionState::Controlling
) {
self.known
.remember(&device.address, Some(&device.name), device.kind);
// And a link that came up settles whatever auto-connect was
// struggling with, so the next problem starts from a full
// budget rather than inheriting the last one's.
self.auto.remove(&device.id);
}
}
for device in &mut out {
device.remembered = self.known.contains(&device.address);
}
out
}
/// Reconnect to remembered hardware as it turns up (FR-1.5).
///
/// This is the whole feature from the rider's side: launch the app, get on
/// the bike, pedal, and the trainer and strap are simply there — no trip to
/// the device screen, no Connect button, no repairing what was paired last
/// week. Deliberately driven by the scan rather than fired once at startup,
/// because the hardware is asleep at startup: a trainer wakes when the
/// cranks turn and a strap when it is put on, and *that* is the moment
/// worth acting on (A-4, FR-1.8).
///
/// Click pods are absent here on purpose. They already have this, in the
/// shape of `pod_seen` above — a pod's advertising window is too short for
/// anything that waits for the list to be published.
fn auto_connect(&mut self) {
if !self.scanning {
return;
}
let candidates: Vec<DeviceInfo> = self
.published
.iter()
.filter(|d| matches!(d.kind, DeviceKind::Trainer | DeviceKind::HeartRate))
// Advertising right now, so the connect has something to reach.
.filter(|d| self.seen_now.contains(&d.address))
.filter(|d| d.remembered && !self.auto_off.contains(&d.id))
.cloned()
.collect();
for device in candidates {
// Whatever is holding the supervisor — a live link, a connect in
// flight, a reconnect the BLE layer is running — outranks this.
let busy = match device.kind {
DeviceKind::Trainer => self.trainer.status().is_attached(),
_ => self.hr.status().is_attached(),
};
if busy {
continue;
}
let previous = self.auto.get(&device.id).copied();
if !may_auto_connect(previous.as_ref()) {
continue;
}
let tries = previous.map_or(0, |a| a.tries);
self.auto.insert(
device.id.clone(),
AutoAttempts {
last: Instant::now(),
tries: tries + 1,
},
);
tracing::info!(
name = %device.name,
address = %device.address,
attempt = tries + 1,
"reconnecting to a remembered device"
);
// Through the same path the rider's own click takes, so a connect
// started by the scan and one started by a finger cannot diverge —
// including suspending the scan for a trainer.
if let Err(e) = self.connect(&device.id) {
tracing::debug!(address = %device.address, reason = %e, "auto-connect declined");
}
}
}
pub fn list(&self) -> Vec<DeviceInfo> {
self.published.clone()
}
@@ -449,13 +614,12 @@ impl DeviceRegistry {
// controller supervisor rather than handled here, so the device list
// and the connection screen drive the same one link per pod (FR-1.4).
if let Some(pod) = device.kind.pod_id() {
self.remembered.insert(id.to_string());
self.forgotten.remove(id);
self.wanted(&device.address);
self.controller.connect(pod, Some(device.address.clone()));
let mut info = device;
info.state = ConnectionState::Connecting;
info.error = None;
info.remembered = true;
info.remembered = self.known.contains(&info.address);
return Ok(info);
}
@@ -463,13 +627,12 @@ impl DeviceRegistry {
// the heart rate supervisor so this list and whatever else drives the
// link stay one path.
if device.kind == DeviceKind::HeartRate {
self.remembered.insert(id.to_string());
self.forgotten.remove(id);
self.wanted(&device.address);
self.hr.connect(Some(device.address.clone()));
let mut info = device;
info.state = ConnectionState::Connecting;
info.error = None;
info.remembered = true;
info.remembered = self.known.contains(&info.address);
return Ok(info);
}
@@ -508,8 +671,7 @@ impl DeviceRegistry {
// soon as the trainer is no longer attached (FR-1.12).
self.set_scanning(false);
self.scan_suspended = true;
self.remembered.insert(id.to_string());
self.forgotten.remove(id);
self.wanted(&device.address);
self.trainer
.connect(scan::TrainerSelector::Address(device.address.clone()));
@@ -517,7 +679,7 @@ impl DeviceRegistry {
info.state = ConnectionState::Connecting;
info.control_acquired = false;
info.error = None;
info.remembered = true;
info.remembered = self.known.contains(&info.address);
Ok(info)
}
@@ -525,6 +687,11 @@ impl DeviceRegistry {
let mut device = self
.get(id)
.ok_or_else(|| format!("no such device: {id}"))?;
// The device stays remembered — the rider closed this link, not the
// pairing — but auto-connect stops chasing it for the rest of the
// session. A "disconnect" that reconnects itself half a second later is
// not a disconnect (FR-1.5).
self.auto_off.insert(id.to_string());
if device.kind == DeviceKind::Trainer {
// SAF-2 runs inside the supervisor before the link drops.
self.trainer.disconnect();
@@ -550,12 +717,29 @@ impl DeviceRegistry {
if device.kind == DeviceKind::HeartRate {
self.hr.disconnect();
}
self.remembered.remove(id);
self.forgotten.insert(id.to_string());
self.known.forget(&device.address);
self.auto_off.remove(id);
self.auto.remove(id);
self.published.retain(|d| d.id != id);
Ok(())
}
/// The rider asked for this device: un-forget it and re-arm auto-connect.
///
/// It is deliberately *not* remembered here. A Connect that the hardware
/// then refuses is not a pairing, and writing one down would mean a trainer
/// the rider gave up on getting chased on every launch afterwards. The
/// store is written when the link actually comes up — see `build`.
///
/// A row's `id` *is* its address — see how every branch of `build`
/// constructs one — so the same string keys the store and the two
/// session-lifetime maps.
fn wanted(&mut self, address: &str) {
self.known.unforget(address);
self.auto_off.remove(address);
self.auto.remove(address);
}
/// The address of each Click pod the scanner has seen (FR-1.4).
///
/// Connecting by address is both faster and unambiguous: the pods share a
@@ -616,6 +800,33 @@ fn describe_service(uuid: Uuid) -> String {
}
}
/// May auto-connect try this address now?
///
/// Two brakes, and they answer different questions. [`AUTO_RETRY`] is about
/// *rate*: a device that advertises and then refuses must not be hammered four
/// times a second. [`AUTO_ATTEMPTS`] is about *ending*: an app that never stops
/// trying can never honestly tell the rider it has stopped (FR-1.11).
fn may_auto_connect(previous: Option<&AutoAttempts>) -> bool {
match previous {
None => true,
Some(a) => a.tries < AUTO_ATTEMPTS && a.last.elapsed() >= AUTO_RETRY,
}
}
/// Is this the device we paired with, or is the slot simply still empty?
///
/// With nothing of this kind remembered, anything will do — that is a first
/// pairing, and refusing it would mean the rider could never make one. Once
/// something *is* remembered, only that peripheral is chased automatically; a
/// replacement is adopted the moment the rider connects it by hand, which is
/// what the Connect button on the row is for.
///
/// A free function rather than a method so the rule can be checked without a
/// radio, two supervisors and a Tokio runtime.
fn is_ours(known: &KnownDevices, kind: DeviceKind, address: &str) -> bool {
known.contains(address) || !known.any_of_kind(kind)
}
/// May a scan we suspended for a connect be switched back on?
///
/// FR-1.12. Split out from [`DeviceRegistry::poll`] so the rule is checkable
@@ -819,6 +1030,79 @@ mod tests {
assert!(!should_resume_scan(false, &idle));
}
#[test]
fn auto_connect_paces_itself_and_eventually_stops() {
// Never tried: go.
assert!(may_auto_connect(None));
// Just tried. A trainer that advertises and then refuses would
// otherwise be retried on every device tick, four times a second, each
// attempt fighting the scan for the one adapter.
let just_now = AutoAttempts {
last: Instant::now(),
tries: 1,
};
assert!(!may_auto_connect(Some(&just_now)));
// Long enough ago, and budget left.
let stale = AutoAttempts {
last: Instant::now() - AUTO_RETRY - Duration::from_secs(1),
tries: 1,
};
assert!(may_auto_connect(Some(&stale)));
// Out of budget. FR-1.11: the app has to be able to stop, or "gave up"
// is a message it contradicts two ticks later. The rider's own Connect
// clears this — see `wanted`.
let spent = AutoAttempts {
last: Instant::now() - AUTO_RETRY - Duration::from_secs(1),
tries: AUTO_ATTEMPTS,
};
assert!(!may_auto_connect(Some(&spent)));
}
#[test]
fn any_pod_will_do_until_one_has_been_paired_with() {
// FR-1.5. Every Click advertises the same name and the same type byte,
// so "a pod is advertising" is not the same question as "*our* pod
// is advertising" the moment there is more than one in the room.
let mut known = KnownDevices::default();
assert!(is_ours(&known, DeviceKind::ClickMinus, "AA:BB:CC:DD:EE:01"));
known.remember(
"AA:BB:CC:DD:EE:01",
Some("Zwift Click"),
DeviceKind::ClickMinus,
);
assert!(is_ours(&known, DeviceKind::ClickMinus, "AA:BB:CC:DD:EE:01"));
assert!(is_ours(&known, DeviceKind::ClickMinus, "aa:bb:cc:dd:ee:01"));
// The one on the next bike.
assert!(!is_ours(
&known,
DeviceKind::ClickMinus,
"AA:BB:CC:DD:EE:02"
));
// A kind we have never paired with is still wide open.
assert!(is_ours(&known, DeviceKind::ClickPlus, "AA:BB:CC:DD:EE:03"));
assert!(is_ours(&known, DeviceKind::Trainer, "AA:BB:CC:DD:EE:04"));
}
#[test]
fn a_replacement_is_adopted_once_the_rider_connects_it() {
// The pod broke and a new one arrived. Auto-connect ignores it — it is
// not the one we know — but the rider's own Connect pairs it, and from
// then on it is chased like the old one.
let mut known = KnownDevices::default();
known.remember("AA:BB:CC:DD:EE:01", None, DeviceKind::ClickMinus);
assert!(!is_ours(
&known,
DeviceKind::ClickMinus,
"AA:BB:CC:DD:EE:99"
));
known.remember("AA:BB:CC:DD:EE:99", None, DeviceKind::ClickMinus);
assert!(is_ours(&known, DeviceKind::ClickMinus, "AA:BB:CC:DD:EE:99"));
}
#[test]
fn well_known_services_are_named_and_zwift_is_recognised() {
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
+311
View File
@@ -0,0 +1,311 @@
//! Devices the rider has paired before, remembered across launches (FR-1.5).
//!
//! Until now "remembered" was a `HashSet` inside [`crate::devices::DeviceRegistry`],
//! which meant it lasted exactly as long as the process. Every launch started
//! from nothing: find the trainer, press Connect, find the strap, press
//! Connect, and only then ride. This is that set written down.
//!
//! ```text
//! app_data_dir()/devices.json
//! { "version": 1,
//! "devices": [ { address, name, kind }, … ],
//! "forgotten": [ address, … ] }
//! ```
//!
//! Two lists rather than one, because *forgotten* is not merely "absent".
//! Absent means never seen; forgotten means the rider looked at this device and
//! said no, and auto-connect has to keep honouring that on the next launch too.
//!
//! The file is small and written only when something actually changes — a pair,
//! an unpair, a name learned — so this never lands in the ride loop's path. It
//! is also *advisory*: a corrupt or unreadable file costs the rider their
//! auto-connect, never their ride, so every failure here is logged and
//! swallowed rather than propagated.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::devices::DeviceKind;
/// Bumped only if the shape changes incompatibly. An older file with a version
/// we do not know is discarded rather than guessed at.
const VERSION: u32 = 1;
const FILE: &str = "devices.json";
/// One device the rider has paired with.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnownDevice {
/// As the adapter reports it. Matching is case-insensitive — see [`key`] —
/// but what is written down is what we were told.
pub address: String,
pub name: Option<String>,
pub kind: DeviceKind,
}
/// The file on disk. Kept separate from the in-memory form so the indexes below
/// are never serialised.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct Stored {
version: u32,
devices: Vec<KnownDevice>,
forgotten: Vec<String>,
}
/// Address as we match on it. BlueZ hands back `F4:C4:59:…` and Android
/// `f4:c4:59:…` for the same peripheral, and a pairing that survives a launch
/// but not a platform is not much of a pairing.
fn key(address: &str) -> String {
address.trim().to_ascii_uppercase()
}
/// Everything the app remembers about the hardware in the room, plus where to
/// write it.
#[derive(Debug, Default)]
pub struct KnownDevices {
devices: BTreeMap<String, KnownDevice>,
forgotten: BTreeSet<String>,
/// `None` before [`KnownDevices::load`] — `AppState::new` runs before there
/// is an `AppHandle` to ask for a data directory, so the registry spends
/// the first moments of the process with an in-memory-only store.
path: Option<PathBuf>,
}
impl KnownDevices {
/// Read the file, or start empty if it is missing, unreadable or from a
/// version we do not understand.
pub fn load(path: PathBuf) -> Self {
let mut out = Self {
path: Some(path.clone()),
..Self::default()
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return out,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "could not read remembered devices");
return out;
}
};
let stored: Stored = match serde_json::from_str(&text) {
Ok(s) => s,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "remembered devices are unreadable; starting fresh");
return out;
}
};
if stored.version != VERSION {
tracing::warn!(
found = stored.version,
expected = VERSION,
"remembered devices are from another version; starting fresh"
);
return out;
}
for device in stored.devices {
out.devices.insert(key(&device.address), device);
}
out.forgotten = stored.forgotten.iter().map(|a| key(a)).collect();
out
}
pub fn len(&self) -> usize {
self.devices.len()
}
pub fn is_empty(&self) -> bool {
self.devices.is_empty()
}
/// Has this device been paired with before?
pub fn contains(&self, address: &str) -> bool {
self.devices.contains_key(&key(address))
}
/// Did the rider say no to this device?
pub fn is_forgotten(&self, address: &str) -> bool {
self.forgotten.contains(&key(address))
}
/// The remembered device of this kind, if there is one. Used to prefer
/// *our* Click over the identically-named one in the next room.
pub fn first_of(&self, kind: DeviceKind) -> Option<&KnownDevice> {
self.devices.values().find(|d| d.kind == kind)
}
pub fn any_of_kind(&self, kind: DeviceKind) -> bool {
self.first_of(kind).is_some()
}
/// Record a pairing. No-op — and no write — when nothing changed, which is
/// the common case: this is called from the device poll, four times a
/// second.
pub fn remember(&mut self, address: &str, name: Option<&str>, kind: DeviceKind) {
// Nothing useful to auto-connect to, and a list full of every anonymous
// peripheral in the building helps nobody.
if kind == DeviceKind::Unknown || address.trim().is_empty() {
return;
}
let id = key(address);
let entry = KnownDevice {
address: address.to_string(),
name: name.map(str::to_owned).filter(|n| !n.trim().is_empty()),
kind,
};
let unchanged = self.devices.get(&id) == Some(&entry);
let was_forgotten = self.forgotten.remove(&id);
if unchanged && !was_forgotten {
return;
}
tracing::info!(address, name, ?kind, "remembering device");
self.devices.insert(id, entry);
self.save();
}
/// The rider said no. Both halves matter: drop the pairing *and* record the
/// refusal, so the next launch does not helpfully connect it again.
pub fn forget(&mut self, address: &str) {
let id = key(address);
let removed = self.devices.remove(&id).is_some();
let added = self.forgotten.insert(id);
if removed || added {
tracing::info!(address, "forgetting device");
self.save();
}
}
/// An explicit connect outranks an earlier refusal.
pub fn unforget(&mut self, address: &str) {
if self.forgotten.remove(&key(address)) {
self.save();
}
}
/// Write the file, atomically: a half-written `devices.json` would be
/// discarded whole on the next launch, and losing the pairings because the
/// power went out mid-`write` is exactly the failure this module exists to
/// prevent.
fn save(&self) {
let Some(path) = &self.path else {
return;
};
let stored = Stored {
version: VERSION,
devices: self.devices.values().cloned().collect(),
forgotten: self.forgotten.iter().cloned().collect(),
};
if let Err(e) = write_atomic(path, &stored) {
// Advisory, not fatal: the rider loses auto-connect on the next
// launch, never this ride.
tracing::warn!(path = %path.display(), error = %e, "could not save remembered devices");
}
}
}
fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
let text = serde_json::to_string_pretty(stored)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)
}
/// Where the remembered devices live: beside the recorded rides, in the app's
/// own data directory.
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("no app data directory: {e}"))?;
std::fs::create_dir_all(&dir)
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
Ok(dir.join(FILE))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"bikecontrol-known-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
dir.join(FILE)
}
#[test]
fn a_pairing_survives_a_reload() {
let path = temp();
let _ = std::fs::remove_file(&path);
let mut known = KnownDevices::load(path.clone());
known.remember(
"F4:C4:59:03:A1:8E",
Some("Zwift Click"),
DeviceKind::ClickMinus,
);
let again = KnownDevices::load(path);
assert!(again.contains("F4:C4:59:03:A1:8E"));
// The same peripheral, as Android spells it.
assert!(again.contains("f4:c4:59:03:a1:8e"));
assert_eq!(
again
.first_of(DeviceKind::ClickMinus)
.unwrap()
.name
.as_deref(),
Some("Zwift Click")
);
}
#[test]
fn forgetting_outlives_the_process_too() {
// The whole point: "no" has to be remembered as firmly as "yes", or the
// next launch connects the neighbour's trainer again.
let path = temp().with_extension("forget.json");
let _ = std::fs::remove_file(&path);
let mut known = KnownDevices::load(path.clone());
known.remember("AA:BB:CC:DD:EE:FF", Some("D100"), DeviceKind::Trainer);
known.forget("aa:bb:cc:dd:ee:ff");
let again = KnownDevices::load(path);
assert!(!again.contains("AA:BB:CC:DD:EE:FF"));
assert!(again.is_forgotten("AA:BB:CC:DD:EE:FF"));
assert!(!again.any_of_kind(DeviceKind::Trainer));
}
#[test]
fn connecting_again_undoes_a_refusal() {
let mut known = KnownDevices::default();
known.forget("AA:BB:CC:DD:EE:FF");
assert!(known.is_forgotten("AA:BB:CC:DD:EE:FF"));
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Trainer);
assert!(!known.is_forgotten("AA:BB:CC:DD:EE:FF"));
assert!(known.contains("AA:BB:CC:DD:EE:FF"));
}
#[test]
fn an_unidentified_peripheral_is_not_worth_remembering() {
let mut known = KnownDevices::default();
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Unknown);
assert!(known.is_empty());
}
#[test]
fn a_corrupt_file_costs_the_pairings_and_nothing_else() {
let path = temp().with_extension("corrupt.json");
std::fs::write(&path, b"{ this is not json").unwrap();
let known = KnownDevices::load(path);
assert!(known.is_empty());
}
}
+10
View File
@@ -14,6 +14,7 @@ pub mod derive;
pub mod devices;
pub mod events;
pub mod heart_rate;
pub mod known;
pub mod profile_view;
pub mod recording;
pub mod samples;
@@ -120,6 +121,15 @@ pub fn run() {
])
.setup(|app| {
let handle = app.handle().clone();
// FR-1.5: the hardware the rider paired with last time, before the
// first scan pass so the very first thing the scanner sees can be
// reconnected rather than merely listed. A missing data directory
// costs auto-connect and nothing else, so it is a warning, not a
// failed launch.
match known::store_path(&handle) {
Ok(path) => handle.state::<AppState>().lock().devices.attach_store(path),
Err(e) => tracing::warn!(error = %e, "remembered devices unavailable"),
}
// NFR-7: scanning starts immediately, not on a user click.
handle.state::<AppState>().lock().devices.start_scan();
state::spawn_ride_loop(handle.clone());
+65 -36
View File
@@ -1,24 +1,31 @@
<script lang="ts">
/**
* The Zwift Click, as two pods (FR-1.4, FR-9.19.2).
* The Zwift Click (FR-1.4, FR-9.19.2).
*
* A Click v2 is **two peripherals**, and until now the app showed one line
* for both: connect, and you got whichever pod answered first, with no way to
* tell which one that was or that the other was missing entirely. Each pod
* now has a card of its own — its own state, battery, address and proof that
* its buttons arrive.
* A Click v2 is two peripherals, but it is **one controller**: connected on
* its own, the `` pod delivers all ten buttons — its own paddle and D-pad,
* *and* the `+` paddle and face buttons relayed from its twin (§2.3.1,
* confirmed on this hardware 2026-08-21). So one link is the whole thing, and
* this panel says so rather than presenting two halves that both look
* required. Pairing both is not merely redundant: it is the configuration in
* which the `` pod stops reporting its own paddle.
*
* The `+` pod keeps a card, because it is the fallback that matters when the
* `` pod is flat or left in the garage — a rider with one working pod should
* still get a working controller.
*
* They are named for the shift paddle each carries, not for the side of the
* bar. Nothing a pod advertises says which end of the handlebar it is
* clamped to, so left and right would be a guess; the paddle is printed on
* the pod, and pressing it settles the question on screen (`confirmed`).
*
* The second job of this panel is to say what to *do* when a pod is missing.
* A Click sleeps within seconds and only advertises while awake (A-4), which
* no rider can guess from the words "not connected" — and which is also why
* connecting is not a button they have to win a race with: the running scan
* picks a pod up the moment it wakes and connects it (FR-1.5). The buttons
* here are for overriding that, not for driving it.
* The second job of this panel is to say what to *do* when nothing is
* connected. A Click sleeps within seconds and only advertises while awake
* (A-4), which no rider can guess from the words "not connected" — and which
* is also why connecting is not a button they have to win a race with: the
* running scan picks a pod up the moment it wakes and connects it (FR-1.5),
* and once paired it goes back to the pod it knows. The buttons here are for
* overriding that, not for driving it.
*/
import { app } from '../lib/app.svelte';
import { api, type Pod, type PodState, type PodStatus } from '../lib/bridge';
@@ -29,7 +36,11 @@
* pod stays missing — and the fix belongs next to the symptom. */
const scanning = $derived(app.devices.scanning);
const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
const bothConnected = $derived(pods.length === 2 && pods.every((p) => p.state === 'connected'));
/** The pod that speaks for the pair. Connected, this is the whole controller. */
const minusLive = $derived(controller?.minus.state === 'connected');
/** Running on the fallback: the `+` pod alone, with no `` paddle to shift
* down with beyond its `Y` button. Worth saying out loud. */
const plusOnly = $derived(!minusLive && controller?.plus.state === 'connected');
const busy = $derived(pods.some((p) => p.state === 'searching'));
/** A pod reporting the other's paddle: the pair may be filed the wrong way
* round, and the rider is the only one who can say. */
@@ -53,8 +64,8 @@
/** What each pod is for, so a rider who has lost one knows what they lost. */
const PURPOSE: Record<Pod, string> = {
minus: 'Shift down · D-pad',
plus: 'Shift up · A B Y Z',
minus: 'All ten buttons · relays the + pod',
plus: 'Fallback · shift up, A B Y Z',
};
function connect(pod: Pod) {
@@ -69,13 +80,13 @@
<section class="click">
<header>
<h2>Zwift Click</h2>
<span class="summary" class:tone-ok={bothConnected} class:tone-warn={!bothConnected}>
{#if bothConnected}
Both pods connected
{:else if anyConnected}
One pod of two
<span class="summary" class:tone-ok={anyConnected} class:tone-warn={!anyConnected}>
{#if minusLive}
Connected · all ten buttons
{:else if plusOnly}
+ pod only
{:else}
No pods connected
Not connected
{/if}
</span>
<div class="actions">
@@ -83,14 +94,16 @@
<!-- Nothing can be picked up automatically while the scan is off, so
the way to fix that sits here rather than only in the header. -->
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
{:else if !bothConnected}
{:else if !minusLive}
<!-- The pod, not both: it is the one that carries the whole
controller. The + pod has its own button on its own card. -->
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
{busy ? 'Searching…' : 'Connect now'}
{busy ? 'Searching…' : 'Look for the pod'}
</button>
{/if}
{#if anyConnected}
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
Disconnect both
Disconnect
</button>
{/if}
</div>
@@ -99,11 +112,16 @@
<p class="lede">
{#if !scanning}
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button
on each pod.
{:else if bothConnected}
Both pods are connected and will reconnect on their own if one drops.
on a pod.
{:else if minusLive}
The <strong> pod is connected</strong>, and it relays its twin: all ten buttons arrive over
this one link. There is nothing to pair the + pod for. It reconnects on its own if it drops,
and on the next launch.
{:else if plusOnly}
Running on the <strong>+ pod alone</strong> — its paddle, face buttons and shift-down on
<span class="kbd">Y</span>. Press a button on the pod to get the D-pad back.
{:else}
<strong>Press any button on a missing pod.</strong> It only advertises while awake, and the
<strong>Press any button on the pod.</strong> It only advertises while awake, and the
running scan connects it as soon as it does — no need to press anything here.
{/if}
</p>
@@ -142,7 +160,13 @@
</div>
</dl>
{#if pod.confirmed}
{#if pod.pod === 'plus' && minusLive && pod.state !== 'connected'}
<!-- Not a fault, and the panel must not let it read as one: this pod
is idle because the pod is already sending its buttons. -->
<p class="note tone-ok">
Not needed — the pod is relaying this pod's paddle and face buttons.
</p>
{:else if pod.confirmed}
<p class="note tone-ok">Confirmed — this pod sent its own {pod.symbol} paddle.</p>
{:else if pod.state === 'connected'}
<p class="note">
@@ -168,7 +192,7 @@
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop searching</button>
{:else}
<button class="btn ghost" onclick={() => connect(pod.pod)}>
Look for it now
{pod.pod === 'plus' && minusLive ? 'Connect anyway' : 'Look for it now'}
</button>
{/if}
</div>
@@ -195,15 +219,14 @@
</div>
{/if}
{#if !bothConnected}
{#if !minusLive}
<!--
FR-1.8 / FR-3.10. "Not connected" on its own reads as a broken app. Both
real causes — a sleeping pod and a lapsed unlock — are things only the
rider can fix, so they are spelled out here rather than left to be
guessed at.
FR-1.8. "Not connected" on its own reads as a broken app, and the real
cause — a pod that is simply asleep — is something only the rider can fix,
so it is spelled out here rather than left to be guessed at.
-->
<details class="help" open={!anyConnected}>
<summary>A pod will not connect — what to try</summary>
<summary>The pod will not connect — what to try</summary>
<ol>
<li>
<strong>Press any button on the pod.</strong> This is almost always the whole answer.
@@ -224,6 +247,12 @@
altogether, and after about thirty failed attempts the app stops chasing it and says
so on the card.
</li>
<li>
<strong>Use the + pod instead.</strong> If the pod is flat or not with you, the app
falls back to the + pod on its own after about a minute — or press
<em>Look for it now</em> on its card. You lose the D-pad; <span class="kbd">Y</span>
still shifts down.
</li>
</ol>
<p class="fallback">
Meanwhile the keyboard mirrors every Click action — <span class="kbd">+</span>
+27 -1
View File
@@ -92,7 +92,17 @@
{@const bars = rssiBars(device.rssi)}
<article class="row" class:live={isConnected(device)}>
<div class="identity">
<span class="name">{device.name}</span>
<span class="name">
{device.name}
<!-- FR-1.5. Paired before, so it comes back on its own the next
time it advertises — this launch or any other. Worth a badge:
it is the difference between a device the rider has to fetch
and one that simply turns up, and Forget below is how they
take it back. -->
{#if device.remembered}
<span class="badge" title="Paired before — reconnects on its own">Remembered</span>
{/if}
</span>
<span class="meta">{KIND_LABEL[device.kind]} · {device.address}</span>
{#if device.error}
<span class="error">{device.error}</span>
@@ -324,6 +334,10 @@
}
.name {
display: flex;
align-items: baseline;
gap: 0.45rem;
min-width: 0;
font-size: 1.12rem;
font-weight: 600;
letter-spacing: -0.01em;
@@ -332,6 +346,18 @@
text-overflow: ellipsis;
}
.badge {
flex: none;
padding: 0.1rem 0.4rem;
border: 1px solid var(--hairline);
border-radius: 0.3rem;
color: var(--ink-dim);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.meta {
font-size: 0.8rem;
color: var(--ink-dim);