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:
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The Zwift Click, as two pods (FR-1.4, FR-9.1–9.2).
|
||||
* The Zwift Click (FR-1.4, FR-9.1–9.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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user