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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user