Reconnect to remembered hardware instead of pairing every launch

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:36:34 +02:00
co-authored by Claude Opus 5
parent 5dff2500e2
commit 7497a5d602
8 changed files with 877 additions and 85 deletions
+311
View File
@@ -0,0 +1,311 @@
//! Devices the rider has paired before, remembered across launches (FR-1.5).
//!
//! Until now "remembered" was a `HashSet` inside [`crate::devices::DeviceRegistry`],
//! which meant it lasted exactly as long as the process. Every launch started
//! from nothing: find the trainer, press Connect, find the strap, press
//! Connect, and only then ride. This is that set written down.
//!
//! ```text
//! app_data_dir()/devices.json
//! { "version": 1,
//! "devices": [ { address, name, kind }, … ],
//! "forgotten": [ address, … ] }
//! ```
//!
//! Two lists rather than one, because *forgotten* is not merely "absent".
//! Absent means never seen; forgotten means the rider looked at this device and
//! said no, and auto-connect has to keep honouring that on the next launch too.
//!
//! The file is small and written only when something actually changes — a pair,
//! an unpair, a name learned — so this never lands in the ride loop's path. It
//! is also *advisory*: a corrupt or unreadable file costs the rider their
//! auto-connect, never their ride, so every failure here is logged and
//! swallowed rather than propagated.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Manager};
use crate::devices::DeviceKind;
/// Bumped only if the shape changes incompatibly. An older file with a version
/// we do not know is discarded rather than guessed at.
const VERSION: u32 = 1;
const FILE: &str = "devices.json";
/// One device the rider has paired with.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KnownDevice {
/// As the adapter reports it. Matching is case-insensitive — see [`key`] —
/// but what is written down is what we were told.
pub address: String,
pub name: Option<String>,
pub kind: DeviceKind,
}
/// The file on disk. Kept separate from the in-memory form so the indexes below
/// are never serialised.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct Stored {
version: u32,
devices: Vec<KnownDevice>,
forgotten: Vec<String>,
}
/// Address as we match on it. BlueZ hands back `F4:C4:59:…` and Android
/// `f4:c4:59:…` for the same peripheral, and a pairing that survives a launch
/// but not a platform is not much of a pairing.
fn key(address: &str) -> String {
address.trim().to_ascii_uppercase()
}
/// Everything the app remembers about the hardware in the room, plus where to
/// write it.
#[derive(Debug, Default)]
pub struct KnownDevices {
devices: BTreeMap<String, KnownDevice>,
forgotten: BTreeSet<String>,
/// `None` before [`KnownDevices::load`] — `AppState::new` runs before there
/// is an `AppHandle` to ask for a data directory, so the registry spends
/// the first moments of the process with an in-memory-only store.
path: Option<PathBuf>,
}
impl KnownDevices {
/// Read the file, or start empty if it is missing, unreadable or from a
/// version we do not understand.
pub fn load(path: PathBuf) -> Self {
let mut out = Self {
path: Some(path.clone()),
..Self::default()
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return out,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "could not read remembered devices");
return out;
}
};
let stored: Stored = match serde_json::from_str(&text) {
Ok(s) => s,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "remembered devices are unreadable; starting fresh");
return out;
}
};
if stored.version != VERSION {
tracing::warn!(
found = stored.version,
expected = VERSION,
"remembered devices are from another version; starting fresh"
);
return out;
}
for device in stored.devices {
out.devices.insert(key(&device.address), device);
}
out.forgotten = stored.forgotten.iter().map(|a| key(a)).collect();
out
}
pub fn len(&self) -> usize {
self.devices.len()
}
pub fn is_empty(&self) -> bool {
self.devices.is_empty()
}
/// Has this device been paired with before?
pub fn contains(&self, address: &str) -> bool {
self.devices.contains_key(&key(address))
}
/// Did the rider say no to this device?
pub fn is_forgotten(&self, address: &str) -> bool {
self.forgotten.contains(&key(address))
}
/// The remembered device of this kind, if there is one. Used to prefer
/// *our* Click over the identically-named one in the next room.
pub fn first_of(&self, kind: DeviceKind) -> Option<&KnownDevice> {
self.devices.values().find(|d| d.kind == kind)
}
pub fn any_of_kind(&self, kind: DeviceKind) -> bool {
self.first_of(kind).is_some()
}
/// Record a pairing. No-op — and no write — when nothing changed, which is
/// the common case: this is called from the device poll, four times a
/// second.
pub fn remember(&mut self, address: &str, name: Option<&str>, kind: DeviceKind) {
// Nothing useful to auto-connect to, and a list full of every anonymous
// peripheral in the building helps nobody.
if kind == DeviceKind::Unknown || address.trim().is_empty() {
return;
}
let id = key(address);
let entry = KnownDevice {
address: address.to_string(),
name: name.map(str::to_owned).filter(|n| !n.trim().is_empty()),
kind,
};
let unchanged = self.devices.get(&id) == Some(&entry);
let was_forgotten = self.forgotten.remove(&id);
if unchanged && !was_forgotten {
return;
}
tracing::info!(address, name, ?kind, "remembering device");
self.devices.insert(id, entry);
self.save();
}
/// The rider said no. Both halves matter: drop the pairing *and* record the
/// refusal, so the next launch does not helpfully connect it again.
pub fn forget(&mut self, address: &str) {
let id = key(address);
let removed = self.devices.remove(&id).is_some();
let added = self.forgotten.insert(id);
if removed || added {
tracing::info!(address, "forgetting device");
self.save();
}
}
/// An explicit connect outranks an earlier refusal.
pub fn unforget(&mut self, address: &str) {
if self.forgotten.remove(&key(address)) {
self.save();
}
}
/// Write the file, atomically: a half-written `devices.json` would be
/// discarded whole on the next launch, and losing the pairings because the
/// power went out mid-`write` is exactly the failure this module exists to
/// prevent.
fn save(&self) {
let Some(path) = &self.path else {
return;
};
let stored = Stored {
version: VERSION,
devices: self.devices.values().cloned().collect(),
forgotten: self.forgotten.iter().cloned().collect(),
};
if let Err(e) = write_atomic(path, &stored) {
// Advisory, not fatal: the rider loses auto-connect on the next
// launch, never this ride.
tracing::warn!(path = %path.display(), error = %e, "could not save remembered devices");
}
}
}
fn write_atomic(path: &Path, stored: &Stored) -> std::io::Result<()> {
let text = serde_json::to_string_pretty(stored)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)
}
/// Where the remembered devices live: beside the recorded rides, in the app's
/// own data directory.
pub fn store_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| format!("no app data directory: {e}"))?;
std::fs::create_dir_all(&dir)
.map_err(|e| format!("could not create {}: {e}", dir.display()))?;
Ok(dir.join(FILE))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"bikecontrol-known-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).unwrap();
dir.join(FILE)
}
#[test]
fn a_pairing_survives_a_reload() {
let path = temp();
let _ = std::fs::remove_file(&path);
let mut known = KnownDevices::load(path.clone());
known.remember(
"F4:C4:59:03:A1:8E",
Some("Zwift Click"),
DeviceKind::ClickMinus,
);
let again = KnownDevices::load(path);
assert!(again.contains("F4:C4:59:03:A1:8E"));
// The same peripheral, as Android spells it.
assert!(again.contains("f4:c4:59:03:a1:8e"));
assert_eq!(
again
.first_of(DeviceKind::ClickMinus)
.unwrap()
.name
.as_deref(),
Some("Zwift Click")
);
}
#[test]
fn forgetting_outlives_the_process_too() {
// The whole point: "no" has to be remembered as firmly as "yes", or the
// next launch connects the neighbour's trainer again.
let path = temp().with_extension("forget.json");
let _ = std::fs::remove_file(&path);
let mut known = KnownDevices::load(path.clone());
known.remember("AA:BB:CC:DD:EE:FF", Some("D100"), DeviceKind::Trainer);
known.forget("aa:bb:cc:dd:ee:ff");
let again = KnownDevices::load(path);
assert!(!again.contains("AA:BB:CC:DD:EE:FF"));
assert!(again.is_forgotten("AA:BB:CC:DD:EE:FF"));
assert!(!again.any_of_kind(DeviceKind::Trainer));
}
#[test]
fn connecting_again_undoes_a_refusal() {
let mut known = KnownDevices::default();
known.forget("AA:BB:CC:DD:EE:FF");
assert!(known.is_forgotten("AA:BB:CC:DD:EE:FF"));
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Trainer);
assert!(!known.is_forgotten("AA:BB:CC:DD:EE:FF"));
assert!(known.contains("AA:BB:CC:DD:EE:FF"));
}
#[test]
fn an_unidentified_peripheral_is_not_worth_remembering() {
let mut known = KnownDevices::default();
known.remember("AA:BB:CC:DD:EE:FF", None, DeviceKind::Unknown);
assert!(known.is_empty());
}
#[test]
fn a_corrupt_file_costs_the_pairings_and_nothing_else() {
let path = temp().with_extension("corrupt.json");
std::fs::write(&path, b"{ this is not json").unwrap();
let known = KnownDevices::load(path);
assert!(known.is_empty());
}
}