//! 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, 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, forgotten: Vec, } /// 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, forgotten: BTreeSet, /// `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, } 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 { 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()); } }