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 —
|
> 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.
|
> 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
|
### 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
|
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.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.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.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.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.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 |
|
| 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()
|
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
|
/// `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
|
/// 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() {
|
if address.is_some() {
|
||||||
return Err("An address names one pod, so say which pod it is".into());
|
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();
|
let known = state.lock().devices.click_pod_addresses();
|
||||||
for id in PodId::BOTH {
|
controller.connect(PodId::Minus, known.get(&PodId::Minus).cloned());
|
||||||
controller.connect(id, known.get(&id).cloned());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
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
|
/// 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).
|
/// disconnect, and this budget is spent on the same window close (NFR-9).
|
||||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
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.
|
/// 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
|
/// 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,
|
/// Connect one pod. `address` is used when the scanner has already seen it,
|
||||||
/// which is both faster and unambiguous.
|
/// which is both faster and unambiguous.
|
||||||
Connect { pod: PodId, address: Option<String> },
|
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 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
|
/// 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 });
|
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).
|
/// Tell the supervisor a pod is advertising right now (FR-1.5).
|
||||||
///
|
///
|
||||||
/// Called from the device scan on every pass. Cheap and idempotent: the
|
/// Called from the device scan on every pass. Cheap and idempotent: the
|
||||||
@@ -587,6 +616,10 @@ async fn run(
|
|||||||
let mut swapped = false;
|
let mut swapped = false;
|
||||||
// One press is one press, whichever pods report it (see `Buttons`).
|
// One press is one press, whichever pods report it (see `Buttons`).
|
||||||
let mut buttons = Buttons::default();
|
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 (attempt_tx, mut attempt_rx) = mpsc::channel::<Attempt>(4);
|
||||||
let mut housekeeping = tokio::time::interval(Duration::from_secs(5));
|
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);
|
let selector = selector_for(pod, address, &status_tx.borrow(), swapped);
|
||||||
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
|
tracing::info!(pod = pod.as_str(), selector = %selector.describe(), "controller: connecting");
|
||||||
start_attempt(slot, pod, selector, &attempt_tx);
|
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));
|
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 } => {
|
Cmd::Seen { pod, address } => {
|
||||||
// One link is the whole controller.
|
// One link is the whole controller — see
|
||||||
//
|
// `take_plus_pod`, which owns this rule. The short of
|
||||||
// The `−` pod relays its twin: connected on its own it
|
// it: the `−` pod relays its twin, so all ten buttons
|
||||||
// delivers its own paddle and D-pad *and* the `+`
|
// arrive over it alone (§2.3.1, confirmed in the field
|
||||||
// paddle and face buttons — all ten buttons, measured
|
// 2026-08-21), and a `+` link is a substitute for a `−`
|
||||||
// over 445 frames on one characteristic (§2.3.1). So
|
// pod that is missing rather than the other half of a
|
||||||
// the `+` pod is not connected while the `−` pod is
|
// pair. Connecting both is what the merge in `Buttons`
|
||||||
// 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`
|
|
||||||
// exists to paper over, and it is also the
|
// exists to paper over, and it is also the
|
||||||
// configuration in which the `−` pod stops reporting
|
// configuration in which the `−` pod stops reporting
|
||||||
// its own paddle — the failure that cost an evening.
|
// its own paddle — the failure that cost an evening.
|
||||||
// Not opening the second link removes both.
|
if pod == PodId::Plus
|
||||||
if pod == PodId::Plus && !slot_mut(&mut minus, &mut plus, PodId::Minus).idle()
|
&& !take_plus_pod(
|
||||||
|
minus.idle(),
|
||||||
|
status_tx.borrow().minus.address.is_some(),
|
||||||
|
tokio::time::Instant::now() >= plus_gate,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"controller: + pod seen but the − pod is already speaking for it"
|
"controller: + pod seen; the − pod speaks for the pair"
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -653,6 +700,9 @@ async fn run(
|
|||||||
// already found are the seconds it goes back to sleep in.
|
// already found are the seconds it goes back to sleep in.
|
||||||
tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
|
tracing::info!(pod = pod.as_str(), %address, "controller: pod seen; connecting");
|
||||||
start_attempt(slot, pod, PodSelector::Address(address.clone()), &attempt_tx);
|
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| {
|
status_tx.send_modify(|s| {
|
||||||
let p = s.get_mut(pod);
|
let p = s.get_mut(pod);
|
||||||
p.address = Some(address);
|
p.address = Some(address);
|
||||||
@@ -738,6 +788,13 @@ async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ = housekeeping.tick() => {
|
_ = 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 —
|
// Converge on one link. The `+` pod may have connected first —
|
||||||
// it is the one the rider happened to wake — and once the `−`
|
// 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
|
// 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 {
|
fn slot_mut<'a>(minus: &'a mut Slot, plus: &'a mut Slot, pod: PodId) -> &'a mut Slot {
|
||||||
match pod {
|
match pod {
|
||||||
PodId::Minus => minus,
|
PodId::Minus => minus,
|
||||||
@@ -1574,6 +1650,46 @@ mod tests {
|
|||||||
assert!(!s.plus.contradicted && !s.minus.contradicted);
|
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]
|
#[test]
|
||||||
fn a_missing_pod_is_reported_as_something_to_do_not_as_not_found() {
|
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()));
|
let advice = connect_advice(PodId::Minus, &FtmsError::NotFound("the − Click pod".into()));
|
||||||
|
|||||||
+310
-26
@@ -19,7 +19,8 @@
|
|||||||
//! point is refused.
|
//! point is refused.
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
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::scan::{self, DiscoveredDevice, ScanKind, ZWIFT_SERVICE};
|
||||||
use bikecontrol_ble::uuids;
|
use bikecontrol_ble::uuids;
|
||||||
@@ -31,6 +32,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::controller::{ControllerHandle, PodState};
|
use crate::controller::{ControllerHandle, PodState};
|
||||||
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
|
use crate::heart_rate::{HeartRateHandle, HeartRateStatus};
|
||||||
|
use crate::known::KnownDevices;
|
||||||
use crate::trainer::{TrainerHandle, TrainerStatus};
|
use crate::trainer::{TrainerHandle, TrainerStatus};
|
||||||
|
|
||||||
/// One pass of the scanner. Long enough for a trainer to advertise, short
|
/// 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
|
/// 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.
|
/// cadence: nothing the rider can do about a missing radio happens in 400 ms.
|
||||||
const ADAPTER_RETRY: Duration = Duration::from_secs(2);
|
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
|
/// 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.
|
/// 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>,
|
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.
|
/// What the scan task publishes.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct ScanSnapshot {
|
pub struct ScanSnapshot {
|
||||||
@@ -146,8 +173,23 @@ pub struct DeviceRegistry {
|
|||||||
hr: HeartRateHandle,
|
hr: HeartRateHandle,
|
||||||
scan_rx: watch::Receiver<ScanSnapshot>,
|
scan_rx: watch::Receiver<ScanSnapshot>,
|
||||||
scan_on: watch::Sender<bool>,
|
scan_on: watch::Sender<bool>,
|
||||||
forgotten: HashSet<String>,
|
/// Everything the rider has paired with, and everything they have refused.
|
||||||
remembered: HashSet<String>,
|
/// 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.
|
/// The list published last tick, for change detection.
|
||||||
published: Vec<DeviceInfo>,
|
published: Vec<DeviceInfo>,
|
||||||
last_trainer: TrainerStatus,
|
last_trainer: TrainerStatus,
|
||||||
@@ -173,8 +215,10 @@ impl DeviceRegistry {
|
|||||||
hr,
|
hr,
|
||||||
scan_rx,
|
scan_rx,
|
||||||
scan_on,
|
scan_on,
|
||||||
forgotten: HashSet::new(),
|
known: KnownDevices::default(),
|
||||||
remembered: HashSet::new(),
|
auto_off: HashSet::new(),
|
||||||
|
auto: HashMap::new(),
|
||||||
|
seen_now: HashSet::new(),
|
||||||
published: Vec::new(),
|
published: Vec::new(),
|
||||||
scanning: false,
|
scanning: false,
|
||||||
scan_suspended: 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
|
/// Rider-initiated. Cancels any suspension: an explicit request outranks
|
||||||
/// our own bookkeeping in both directions.
|
/// our own bookkeeping in both directions.
|
||||||
pub fn start_scan(&mut self) {
|
pub fn start_scan(&mut self) {
|
||||||
@@ -229,6 +296,10 @@ impl DeviceRegistry {
|
|||||||
let changed = next != self.published;
|
let changed = next != self.published;
|
||||||
self.published = next;
|
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 {
|
PollResult {
|
||||||
changed,
|
changed,
|
||||||
transitions,
|
transitions,
|
||||||
@@ -244,10 +315,12 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
let controller = self.controller.status();
|
let controller = self.controller.status();
|
||||||
|
|
||||||
|
self.seen_now.clear();
|
||||||
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
|
let mut out: Vec<DeviceInfo> = Vec::with_capacity(snapshot.devices.len() + 1);
|
||||||
for d in &snapshot.devices {
|
for d in &snapshot.devices {
|
||||||
let id = d.address.clone();
|
let id = d.address.clone();
|
||||||
if self.forgotten.contains(&id) {
|
self.seen_now.insert(id.clone());
|
||||||
|
if self.known.is_forgotten(&id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let kind = classify(d);
|
let kind = classify(d);
|
||||||
@@ -257,9 +330,16 @@ impl DeviceRegistry {
|
|||||||
// is the pod the rider already told us about by pressing a button
|
// 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
|
// on it. So the scan connects it (FR-1.5), unless they disconnected
|
||||||
// it on purpose, in which case the supervisor ignores this.
|
// 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() {
|
if let Some(pod) = kind.pod_id() {
|
||||||
|
if is_ours(&self.known, kind, &id) {
|
||||||
self.controller.pod_seen(pod, &id);
|
self.controller.pod_seen(pod, &id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
out.push(DeviceInfo {
|
out.push(DeviceInfo {
|
||||||
kind,
|
kind,
|
||||||
name: d.label(),
|
name: d.label(),
|
||||||
@@ -272,7 +352,9 @@ impl DeviceRegistry {
|
|||||||
},
|
},
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: d.services.iter().map(|u| describe_service(*u)).collect(),
|
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,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -296,7 +378,7 @@ impl DeviceRegistry {
|
|||||||
state: ConnectionState::Idle,
|
state: ConnectionState::Idle,
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
services: vec![describe_service(uuids::FITNESS_MACHINE_SERVICE)],
|
||||||
remembered: true,
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -308,7 +390,6 @@ impl DeviceRegistry {
|
|||||||
device.kind = DeviceKind::Trainer;
|
device.kind = DeviceKind::Trainer;
|
||||||
device.state = trainer.state.clone();
|
device.state = trainer.state.clone();
|
||||||
device.control_acquired = trainer.control_acquired;
|
device.control_acquired = trainer.control_acquired;
|
||||||
device.remembered = true;
|
|
||||||
device.error = trainer.error.clone().or_else(|| {
|
device.error = trainer.error.clone().or_else(|| {
|
||||||
trainer
|
trainer
|
||||||
.stale
|
.stale
|
||||||
@@ -329,7 +410,7 @@ impl DeviceRegistry {
|
|||||||
let Some(address) = pod.address.clone() else {
|
let Some(address) = pod.address.clone() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if self.forgotten.contains(&address) {
|
if self.known.is_forgotten(&address) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let kind = match id {
|
let kind = match id {
|
||||||
@@ -348,7 +429,7 @@ impl DeviceRegistry {
|
|||||||
state: ConnectionState::Idle,
|
state: ConnectionState::Idle,
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: vec![describe_service(ZWIFT_SERVICE)],
|
services: vec![describe_service(ZWIFT_SERVICE)],
|
||||||
remembered: true,
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -360,7 +441,6 @@ impl DeviceRegistry {
|
|||||||
device.kind = kind;
|
device.kind = kind;
|
||||||
device.battery_pct = pod.battery_percent;
|
device.battery_pct = pod.battery_percent;
|
||||||
device.error = pod.error.clone();
|
device.error = pod.error.clone();
|
||||||
device.remembered = true;
|
|
||||||
device.state = match pod.state {
|
device.state = match pod.state {
|
||||||
PodState::Connected => ConnectionState::Connected,
|
PodState::Connected => ConnectionState::Connected,
|
||||||
PodState::Searching => ConnectionState::Connecting,
|
PodState::Searching => ConnectionState::Connecting,
|
||||||
@@ -380,7 +460,7 @@ impl DeviceRegistry {
|
|||||||
// link.
|
// link.
|
||||||
let hr = self.hr.status();
|
let hr = self.hr.status();
|
||||||
if let Some(address) = hr.address.clone() {
|
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) {
|
let idx = match out.iter().position(|d| d.id == address) {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
None => {
|
None => {
|
||||||
@@ -396,7 +476,7 @@ impl DeviceRegistry {
|
|||||||
state: ConnectionState::Idle,
|
state: ConnectionState::Idle,
|
||||||
control_acquired: false,
|
control_acquired: false,
|
||||||
services: vec![describe_service(HEART_RATE_SERVICE)],
|
services: vec![describe_service(HEART_RATE_SERVICE)],
|
||||||
remembered: true,
|
remembered: false,
|
||||||
battery_pct: None,
|
battery_pct: None,
|
||||||
heart_rate_bpm: None,
|
heart_rate_bpm: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -409,7 +489,6 @@ impl DeviceRegistry {
|
|||||||
device.battery_pct = hr.battery_percent;
|
device.battery_pct = hr.battery_percent;
|
||||||
device.heart_rate_bpm = hr.bpm;
|
device.heart_rate_bpm = hr.bpm;
|
||||||
device.error = hr.error.clone();
|
device.error = hr.error.clone();
|
||||||
device.remembered = true;
|
|
||||||
if let Some(name) = &hr.name {
|
if let Some(name) = &hr.name {
|
||||||
device.name = name.clone();
|
device.name = name.clone();
|
||||||
}
|
}
|
||||||
@@ -429,9 +508,95 @@ impl DeviceRegistry {
|
|||||||
.then(b.rssi.cmp(&a.rssi))
|
.then(b.rssi.cmp(&a.rssi))
|
||||||
.then(a.id.cmp(&b.id))
|
.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
|
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> {
|
pub fn list(&self) -> Vec<DeviceInfo> {
|
||||||
self.published.clone()
|
self.published.clone()
|
||||||
}
|
}
|
||||||
@@ -449,13 +614,12 @@ impl DeviceRegistry {
|
|||||||
// controller supervisor rather than handled here, so the device list
|
// controller supervisor rather than handled here, so the device list
|
||||||
// and the connection screen drive the same one link per pod (FR-1.4).
|
// and the connection screen drive the same one link per pod (FR-1.4).
|
||||||
if let Some(pod) = device.kind.pod_id() {
|
if let Some(pod) = device.kind.pod_id() {
|
||||||
self.remembered.insert(id.to_string());
|
self.wanted(&device.address);
|
||||||
self.forgotten.remove(id);
|
|
||||||
self.controller.connect(pod, Some(device.address.clone()));
|
self.controller.connect(pod, Some(device.address.clone()));
|
||||||
let mut info = device;
|
let mut info = device;
|
||||||
info.state = ConnectionState::Connecting;
|
info.state = ConnectionState::Connecting;
|
||||||
info.error = None;
|
info.error = None;
|
||||||
info.remembered = true;
|
info.remembered = self.known.contains(&info.address);
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,13 +627,12 @@ impl DeviceRegistry {
|
|||||||
// the heart rate supervisor so this list and whatever else drives the
|
// the heart rate supervisor so this list and whatever else drives the
|
||||||
// link stay one path.
|
// link stay one path.
|
||||||
if device.kind == DeviceKind::HeartRate {
|
if device.kind == DeviceKind::HeartRate {
|
||||||
self.remembered.insert(id.to_string());
|
self.wanted(&device.address);
|
||||||
self.forgotten.remove(id);
|
|
||||||
self.hr.connect(Some(device.address.clone()));
|
self.hr.connect(Some(device.address.clone()));
|
||||||
let mut info = device;
|
let mut info = device;
|
||||||
info.state = ConnectionState::Connecting;
|
info.state = ConnectionState::Connecting;
|
||||||
info.error = None;
|
info.error = None;
|
||||||
info.remembered = true;
|
info.remembered = self.known.contains(&info.address);
|
||||||
return Ok(info);
|
return Ok(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,8 +671,7 @@ impl DeviceRegistry {
|
|||||||
// soon as the trainer is no longer attached (FR-1.12).
|
// soon as the trainer is no longer attached (FR-1.12).
|
||||||
self.set_scanning(false);
|
self.set_scanning(false);
|
||||||
self.scan_suspended = true;
|
self.scan_suspended = true;
|
||||||
self.remembered.insert(id.to_string());
|
self.wanted(&device.address);
|
||||||
self.forgotten.remove(id);
|
|
||||||
self.trainer
|
self.trainer
|
||||||
.connect(scan::TrainerSelector::Address(device.address.clone()));
|
.connect(scan::TrainerSelector::Address(device.address.clone()));
|
||||||
|
|
||||||
@@ -517,7 +679,7 @@ impl DeviceRegistry {
|
|||||||
info.state = ConnectionState::Connecting;
|
info.state = ConnectionState::Connecting;
|
||||||
info.control_acquired = false;
|
info.control_acquired = false;
|
||||||
info.error = None;
|
info.error = None;
|
||||||
info.remembered = true;
|
info.remembered = self.known.contains(&info.address);
|
||||||
Ok(info)
|
Ok(info)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +687,11 @@ impl DeviceRegistry {
|
|||||||
let mut device = self
|
let mut device = self
|
||||||
.get(id)
|
.get(id)
|
||||||
.ok_or_else(|| format!("no such device: {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 {
|
if device.kind == DeviceKind::Trainer {
|
||||||
// SAF-2 runs inside the supervisor before the link drops.
|
// SAF-2 runs inside the supervisor before the link drops.
|
||||||
self.trainer.disconnect();
|
self.trainer.disconnect();
|
||||||
@@ -550,12 +717,29 @@ impl DeviceRegistry {
|
|||||||
if device.kind == DeviceKind::HeartRate {
|
if device.kind == DeviceKind::HeartRate {
|
||||||
self.hr.disconnect();
|
self.hr.disconnect();
|
||||||
}
|
}
|
||||||
self.remembered.remove(id);
|
self.known.forget(&device.address);
|
||||||
self.forgotten.insert(id.to_string());
|
self.auto_off.remove(id);
|
||||||
|
self.auto.remove(id);
|
||||||
self.published.retain(|d| d.id != id);
|
self.published.retain(|d| d.id != id);
|
||||||
Ok(())
|
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).
|
/// 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
|
/// 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?
|
/// 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
|
/// 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));
|
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]
|
#[test]
|
||||||
fn well_known_services_are_named_and_zwift_is_recognised() {
|
fn well_known_services_are_named_and_zwift_is_recognised() {
|
||||||
let ftms = describe_service(uuids::FITNESS_MACHINE_SERVICE);
|
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 devices;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod heart_rate;
|
pub mod heart_rate;
|
||||||
|
pub mod known;
|
||||||
pub mod profile_view;
|
pub mod profile_view;
|
||||||
pub mod recording;
|
pub mod recording;
|
||||||
pub mod samples;
|
pub mod samples;
|
||||||
@@ -120,6 +121,15 @@ pub fn run() {
|
|||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
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.
|
// NFR-7: scanning starts immediately, not on a user click.
|
||||||
handle.state::<AppState>().lock().devices.start_scan();
|
handle.state::<AppState>().lock().devices.start_scan();
|
||||||
state::spawn_ride_loop(handle.clone());
|
state::spawn_ride_loop(handle.clone());
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
<script lang="ts">
|
<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
|
* A Click v2 is two peripherals, but it is **one controller**: connected on
|
||||||
* for both: connect, and you got whichever pod answered first, with no way to
|
* its own, the `−` pod delivers all ten buttons — its own paddle and D-pad,
|
||||||
* tell which one that was or that the other was missing entirely. Each pod
|
* *and* the `+` paddle and face buttons relayed from its twin (§2.3.1,
|
||||||
* now has a card of its own — its own state, battery, address and proof that
|
* confirmed on this hardware 2026-08-21). So one link is the whole thing, and
|
||||||
* its buttons arrive.
|
* 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
|
* 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
|
* 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
|
* 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 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.
|
* The second job of this panel is to say what to *do* when nothing is
|
||||||
* A Click sleeps within seconds and only advertises while awake (A-4), which
|
* connected. A Click sleeps within seconds and only advertises while awake
|
||||||
* no rider can guess from the words "not connected" — and which is also why
|
* (A-4), which no rider can guess from the words "not connected" — and which
|
||||||
* connecting is not a button they have to win a race with: the running scan
|
* is also why connecting is not a button they have to win a race with: the
|
||||||
* picks a pod up the moment it wakes and connects it (FR-1.5). The buttons
|
* running scan picks a pod up the moment it wakes and connects it (FR-1.5),
|
||||||
* here are for overriding that, not for driving it.
|
* 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 { app } from '../lib/app.svelte';
|
||||||
import { api, type Pod, type PodState, type PodStatus } from '../lib/bridge';
|
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. */
|
* pod stays missing — and the fix belongs next to the symptom. */
|
||||||
const scanning = $derived(app.devices.scanning);
|
const scanning = $derived(app.devices.scanning);
|
||||||
const anyConnected = $derived(pods.some((p) => p.state === 'connected'));
|
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'));
|
const busy = $derived(pods.some((p) => p.state === 'searching'));
|
||||||
/** A pod reporting the other's paddle: the pair may be filed the wrong way
|
/** 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. */
|
* 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. */
|
/** What each pod is for, so a rider who has lost one knows what they lost. */
|
||||||
const PURPOSE: Record<Pod, string> = {
|
const PURPOSE: Record<Pod, string> = {
|
||||||
minus: 'Shift down · D-pad',
|
minus: 'All ten buttons · relays the + pod',
|
||||||
plus: 'Shift up · A B Y Z',
|
plus: 'Fallback · shift up, A B Y Z',
|
||||||
};
|
};
|
||||||
|
|
||||||
function connect(pod: Pod) {
|
function connect(pod: Pod) {
|
||||||
@@ -69,13 +80,13 @@
|
|||||||
<section class="click">
|
<section class="click">
|
||||||
<header>
|
<header>
|
||||||
<h2>Zwift Click</h2>
|
<h2>Zwift Click</h2>
|
||||||
<span class="summary" class:tone-ok={bothConnected} class:tone-warn={!bothConnected}>
|
<span class="summary" class:tone-ok={anyConnected} class:tone-warn={!anyConnected}>
|
||||||
{#if bothConnected}
|
{#if minusLive}
|
||||||
Both pods connected
|
Connected · all ten buttons
|
||||||
{:else if anyConnected}
|
{:else if plusOnly}
|
||||||
One pod of two
|
+ pod only
|
||||||
{:else}
|
{:else}
|
||||||
No pods connected
|
Not connected
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -83,14 +94,16 @@
|
|||||||
<!-- Nothing can be picked up automatically while the scan is off, so
|
<!-- 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. -->
|
the way to fix that sits here rather than only in the header. -->
|
||||||
<button class="btn" onclick={() => app.run(() => api.startScan())}>Start scan</button>
|
<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())}>
|
<button class="btn" disabled={busy} onclick={() => app.run(() => api.connectController())}>
|
||||||
{busy ? 'Searching…' : 'Connect now'}
|
{busy ? 'Searching…' : 'Look for the − pod'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if anyConnected}
|
{#if anyConnected}
|
||||||
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
|
<button class="btn ghost" onclick={() => app.run(() => api.disconnectController())}>
|
||||||
Disconnect both
|
Disconnect
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -99,11 +112,16 @@
|
|||||||
<p class="lede">
|
<p class="lede">
|
||||||
{#if !scanning}
|
{#if !scanning}
|
||||||
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button
|
<strong>The scan is off</strong>, so pods will not be picked up. Start it and press a button
|
||||||
on each pod.
|
on a pod.
|
||||||
{:else if bothConnected}
|
{:else if minusLive}
|
||||||
Both pods are connected and will reconnect on their own if one drops.
|
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}
|
{: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.
|
running scan connects it as soon as it does — no need to press anything here.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
@@ -142,7 +160,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</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>
|
<p class="note tone-ok">Confirmed — this pod sent its own {pod.symbol} paddle.</p>
|
||||||
{:else if pod.state === 'connected'}
|
{:else if pod.state === 'connected'}
|
||||||
<p class="note">
|
<p class="note">
|
||||||
@@ -168,7 +192,7 @@
|
|||||||
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop searching</button>
|
<button class="btn ghost" onclick={() => disconnect(pod.pod)}>Stop searching</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn ghost" onclick={() => connect(pod.pod)}>
|
<button class="btn ghost" onclick={() => connect(pod.pod)}>
|
||||||
Look for it now
|
{pod.pod === 'plus' && minusLive ? 'Connect anyway' : 'Look for it now'}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -195,15 +219,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !bothConnected}
|
{#if !minusLive}
|
||||||
<!--
|
<!--
|
||||||
FR-1.8 / FR-3.10. "Not connected" on its own reads as a broken app. Both
|
FR-1.8. "Not connected" on its own reads as a broken app, and the real
|
||||||
real causes — a sleeping pod and a lapsed unlock — are things only the
|
cause — a pod that is simply asleep — is something only the rider can fix,
|
||||||
rider can fix, so they are spelled out here rather than left to be
|
so it is spelled out here rather than left to be guessed at.
|
||||||
guessed at.
|
|
||||||
-->
|
-->
|
||||||
<details class="help" open={!anyConnected}>
|
<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>
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
<strong>Press any button on the pod.</strong> This is almost always the whole answer.
|
<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
|
altogether, and after about thirty failed attempts the app stops chasing it and says
|
||||||
so on the card.
|
so on the card.
|
||||||
</li>
|
</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>
|
</ol>
|
||||||
<p class="fallback">
|
<p class="fallback">
|
||||||
Meanwhile the keyboard mirrors every Click action — <span class="kbd">+</span>
|
Meanwhile the keyboard mirrors every Click action — <span class="kbd">+</span>
|
||||||
|
|||||||
@@ -92,7 +92,17 @@
|
|||||||
{@const bars = rssiBars(device.rssi)}
|
{@const bars = rssiBars(device.rssi)}
|
||||||
<article class="row" class:live={isConnected(device)}>
|
<article class="row" class:live={isConnected(device)}>
|
||||||
<div class="identity">
|
<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>
|
<span class="meta">{KIND_LABEL[device.kind]} · {device.address}</span>
|
||||||
{#if device.error}
|
{#if device.error}
|
||||||
<span class="error">{device.error}</span>
|
<span class="error">{device.error}</span>
|
||||||
@@ -324,6 +334,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 0;
|
||||||
font-size: 1.12rem;
|
font-size: 1.12rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
@@ -332,6 +346,18 @@
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.meta {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--ink-dim);
|
color: var(--ink-dim);
|
||||||
|
|||||||
Reference in New Issue
Block a user