diff options
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | src/controller/mod.rs | 17 | ||||
| -rw-r--r-- | src/controller/ops.rs | 218 | ||||
| -rw-r--r-- | src/main.rs | 64 | ||||
| -rw-r--r-- | src/state.rs | 142 | ||||
| -rw-r--r-- | src/time.rs | 41 |
6 files changed, 226 insertions, 263 deletions
@@ -51,8 +51,9 @@ The same principles as the TypeScript implementation this replaces, plus the Rus connects retry three times with backoff, since an mDNS resolve answer can be lost per-attempt. - **Flat files, no database.** All state is plain files under `storagePath`: rs-matter's KV blobs (`matter/k_XXXX`, written atomically via temp+rename), `identity.json` (the root CA private key; - mode 0600), `nodes.json` (device registry), and `service-state.json` (per-node sync results). The - config and `service-state.json` are file-compatible with the TypeScript implementation. + mode 0600, written once and never touched by a sync), and `devices.json` (one record per + commissioned device: cached names plus latest sync results). The config is compatible with the + TypeScript implementation. - **Time is jiff end to end.** Timestamps are `jiff::Timestamp`; Matter-epoch microseconds exist only at the wire boundary. DST transitions come directly from the IANA tzdb transition table, never from offset probing. Time zones are IANA names, never fixed offsets. @@ -170,7 +171,7 @@ Then `sudo systemctl enable --now mattertimesync.timer` (2 minutes after boot, h ## Migrating from the TypeScript implementation -The config file and `service-state.json` carry over unchanged, but the **Matter fabric identity does +The config file carries over unchanged, but the **Matter fabric identity does not**: matter.js and rs-matter persist fabrics in different formats, and the device is paired to a key, not a storage path. Migration is one re-pairing: diff --git a/src/controller/mod.rs b/src/controller/mod.rs index c85efdd..739615c 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -100,7 +100,8 @@ pub enum IdentityStatus { /// Inspects the identity pair on disk. A fabric blob is any k_* file in the /// matter/ KV directory. pub fn identity_status(storage: &Path) -> IdentityStatus { - let identity_exists = identity_path(storage).exists(); + let identity_file = identity_path(storage); + let identity_exists = identity_file.exists(); let fabric_exists = std::fs::read_dir(matter_kv_path(storage)) .map(|entries| { entries.flatten().any(|e| { @@ -119,7 +120,7 @@ pub fn identity_status(storage: &Path) -> IdentityStatus { (false, true) => { IdentityStatus::Inconsistent("fabric storage exists but identity.json is missing") } - (true, true) => match std::fs::read_to_string(identity_path(storage)) + (true, true) => match std::fs::read_to_string(&identity_file) .ok() .and_then(|raw| serde_json::from_str::<Identity>(&raw).ok()) { @@ -349,8 +350,8 @@ fn ensure_fabric<C: Crypto>( let identity_file = identity_path(&config.storage_path); let existing = matter.with_state(|state| state.fabrics.iter().map(|f| f.fab_idx()).next()); - let registry: crate::state::NodeRegistry = - state::load_or_default(&state::registry_path(&config.storage_path)); + let registry: crate::state::Devices = + state::load_or_default(&state::devices_path(&config.storage_path)); if let Some(fab_idx) = existing { match std::fs::read_to_string(&identity_file) { @@ -602,13 +603,7 @@ fn prepare_storage_dir(path: &Path) -> anyhow::Result<()> { /// so is safe to claim and tighten. #[cfg(unix)] fn is_our_storage_dir(path: &Path) -> bool { - const MARKERS: [&str; 5] = [ - "identity.json", - "matter", - ".lock", - "nodes.json", - "service-state.json", - ]; + const MARKERS: [&str; 4] = ["identity.json", "matter", ".lock", "devices.json"]; if MARKERS.iter().any(|m| path.join(m).exists()) { return true; } diff --git a/src/controller/ops.rs b/src/controller/ops.rs index 7693650..7db1431 100644 --- a/src/controller/ops.rs +++ b/src/controller/ops.rs @@ -17,7 +17,11 @@ use rs_matter::transport::exchange::Exchange; use jiff::Timestamp; use crate::pairing::Onboarding; -use crate::state::{NodeInfo, NodeRegistry}; + +/// The device-registry file path for this run's storage. +fn devices_path<C: Crypto>(ctx: &Ctx<'_, C>) -> std::path::PathBuf { + state::devices_path(&ctx.config.storage_path) +} const BROWSE_TIMEOUT_MS: u32 = 30_000; /// Per-phase bound for commissioning (PASE handshake + invokes, CASE + complete). @@ -38,7 +42,7 @@ async fn with_timeout<T>( embassy_time::Duration::from_secs(secs) )); match embassy_futures::select::select(&mut fut, &mut timer).await { - embassy_futures::select::Either::First(result) => result.ctx("{what}"), + embassy_futures::select::Either::First(result) => result.ctx(what), embassy_futures::select::Either::Second(()) => bail!("{what} timed out after {secs}s"), } } @@ -124,30 +128,18 @@ impl Op for CommissionOp { ) .await?; - // Cache the device identity for the local registry; best-effort. + // Record the device: cached names plus the initial connection. let (vendor_name, product_name) = read_device_names(ctx, device_node_id).await; - - let registry_file = state::registry_path(&ctx.config.storage_path); - let mut registry: NodeRegistry = state::load_or_default(®istry_file); - registry.nodes.insert( - device_node_id.to_string(), - NodeInfo { - vendor_name: vendor_name.clone(), - product_name: product_name.clone(), - }, - ); - state::store(®istry_file, ®istry)?; + state::update_device(&devices_path(ctx), device_node_id, |device| { + device.vendor_name = vendor_name.clone(); + device.product_name = product_name.clone(); + device.last_successful_connection = Some(Timestamp::now()); + })?; let mut identity = ctx.identity.clone(); identity.next_device_node_id += 1; state::store(&identity_path(&ctx.config.storage_path), &identity)?; - state::update_node_state( - &state::service_state_path(&ctx.config.storage_path), - device_node_id, - |node| node.last_successful_connection = Some(Timestamp::now()), - )?; - Ok(CommissionOutcome { node_id: device_node_id.into(), fabric_id: ctx.identity.fabric_id.into(), @@ -314,23 +306,23 @@ impl Op for SyncOp { type Out = Vec<SyncOutcome>; async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<Vec<SyncOutcome>> { - let state_path = state::service_state_path(&ctx.config.storage_path); + let state_path = devices_path(ctx); let mut outcomes = Vec::with_capacity(self.targets.len()); for node_id in self.targets { - state::update_node_state(&state_path, node_id, |n| { - n.last_attempted_sync = Some(Timestamp::now()); + state::update_device(&state_path, node_id, |d| { + d.last_attempted_sync = Some(Timestamp::now()); })?; let outcome = match sync_one(ctx, node_id, self.manual_time).await { Ok(outcome) => outcome, Err(error) => SyncOutcome::failed(node_id, format!("{error:#}")), }; - state::update_node_state(&state_path, node_id, |n| { + state::update_device(&state_path, node_id, |d| { if outcome.success { - n.last_successful_sync = Some(Timestamp::now()); - n.last_successful_connection = Some(Timestamp::now()); - n.last_error = None; + d.last_successful_sync = Some(Timestamp::now()); + d.last_successful_connection = Some(Timestamp::now()); + d.last_error = None; } else { - n.last_error = outcome.error.clone(); + d.last_error = outcome.error.clone(); } })?; if let Some(error) = &outcome.error { @@ -398,67 +390,11 @@ async fn sync_one<C: Crypto>( .ctx("SetUTCTime rejected")?; log::info!("Node {node_id}: SetUTCTime {utc_write}"); - let mut time_zone_written = None; - let mut dst_entries_written = 0; - if has_time_zone { - let zone_entries = build_time_zone_list(&tz, &ctx.config.timezone, now_ts); - let response = connect(ctx, node_id) - .await? - .time_synchronization() - .set_time_zone(ROOT_ENDPOINT_ID, |b| { - let mut list = b.time_zone()?; - for entry in &zone_entries { - list = list - .push()? - .offset(entry.offset_seconds)? - .valid_at(entry.valid_at.0)? - .name(Some(&entry.name))? - .end()?; - } - list.end()?.end() - }) - .await - .ctx("SetTimeZone rejected")?; - let dst_required = response - .response() - .map(|r| r.dst_offset_required().unwrap_or(true)) - .unwrap_or(true); - response.complete().await.ctx("SetTimeZone completion")?; - time_zone_written = Some(ctx.config.timezone.clone()); - log::info!("Node {node_id}: SetTimeZone {}", ctx.config.timezone); - - if dst_required { - let max_entries = connect(ctx, node_id) - .await? - .time_synchronization() - .dst_offset_list_max_size_read(ROOT_ENDPOINT_ID) - .await - .ctx("read dstOffsetListMaxSize")?; - let dst_entries = build_dst_offset_list(&tz, usize::from(max_entries), now_ts); - connect(ctx, node_id) - .await? - .time_synchronization() - .set_dst_offset(ROOT_ENDPOINT_ID, |b| { - let mut list = b.dst_offset()?; - for entry in &dst_entries { - list = list - .push()? - .offset(entry.offset_seconds)? - .valid_starting(entry.valid_starting.0)? - .valid_until(match entry.valid_until { - Some(until) => rs_matter::tlv::Nullable::some(until.0), - None => rs_matter::tlv::Nullable::none(), - })? - .end()?; - } - list.end()?.end() - }) - .await - .ctx("SetDSTOffset rejected")?; - dst_entries_written = dst_entries.len(); - log::info!("Node {node_id}: SetDSTOffset with {dst_entries_written} entries"); - } - } + let (time_zone_written, dst_entries_written) = if has_time_zone { + write_zone_and_dst(ctx, node_id, &tz, now_ts).await? + } else { + (None, 0) + }; // Verify by reading the clock back. let after = connect(ctx, node_id) @@ -495,6 +431,78 @@ async fn sync_one<C: Crypto>( }) } +/// Writes SetTimeZone and (when the device still needs it) SetDSTOffset, +/// returning the zone name written and the number of DST entries. Split out +/// of `sync_one` so the two writes read as one cohesive step. +async fn write_zone_and_dst<C: Crypto>( + ctx: &Ctx<'_, C>, + node_id: u64, + tz: &jiff::tz::TimeZone, + now_ts: Timestamp, +) -> anyhow::Result<(Option<String>, usize)> { + let zone_entries = build_time_zone_list(tz, &ctx.config.timezone, now_ts); + let response = connect(ctx, node_id) + .await? + .time_synchronization() + .set_time_zone(ROOT_ENDPOINT_ID, |b| { + let mut list = b.time_zone()?; + for entry in &zone_entries { + list = list + .push()? + .offset(entry.offset_seconds)? + .valid_at(entry.valid_at.0)? + .name(Some(&entry.name))? + .end()?; + } + list.end()?.end() + }) + .await + .ctx("SetTimeZone rejected")?; + let dst_required = response + .response() + .map(|r| r.dst_offset_required().unwrap_or(true)) + .unwrap_or(true); + response.complete().await.ctx("SetTimeZone completion")?; + log::info!("Node {node_id}: SetTimeZone {}", ctx.config.timezone); + + if !dst_required { + return Ok((Some(ctx.config.timezone.clone()), 0)); + } + + let max_entries = connect(ctx, node_id) + .await? + .time_synchronization() + .dst_offset_list_max_size_read(ROOT_ENDPOINT_ID) + .await + .ctx("read dstOffsetListMaxSize")?; + let dst_entries = build_dst_offset_list(tz, usize::from(max_entries), now_ts); + connect(ctx, node_id) + .await? + .time_synchronization() + .set_dst_offset(ROOT_ENDPOINT_ID, |b| { + let mut list = b.dst_offset()?; + for entry in &dst_entries { + list = list + .push()? + .offset(entry.offset_seconds)? + .valid_starting(entry.valid_starting.0)? + .valid_until(match entry.valid_until { + Some(until) => rs_matter::tlv::Nullable::some(until.0), + None => rs_matter::tlv::Nullable::none(), + })? + .end()?; + } + list.end()?.end() + }) + .await + .ctx("SetDSTOffset rejected")?; + log::info!( + "Node {node_id}: SetDSTOffset with {} entries", + dst_entries.len() + ); + Ok((Some(ctx.config.timezone.clone()), dst_entries.len())) +} + /// Pushes the configured fabric label to the device when it differs from the /// stored one. Best-effort: a cosmetic label must not fail a clock sync. /// Labels are unique per device, so a conflict with another admin's label is @@ -577,14 +585,7 @@ impl Op for DecommissionOp { let result = decommission_one(ctx, node_id).await; match result { Ok(()) => { - let registry_file = state::registry_path(&ctx.config.storage_path); - let mut registry: NodeRegistry = state::load_or_default(®istry_file); - registry.nodes.remove(&node_id.to_string()); - state::store(®istry_file, ®istry)?; - state::remove_node_state( - &state::service_state_path(&ctx.config.storage_path), - node_id, - )?; + state::remove_device(&devices_path(ctx), node_id)?; outcomes.push(DecommissionOutcome { node_id: node_id.into(), success: true, @@ -633,7 +634,7 @@ pub struct InspectOp { pub targets: Vec<u64>, } -#[derive(Debug, serde::Serialize)] +#[derive(Debug, Default, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct InspectOutcome { pub node_id: crate::output::Id64, @@ -645,6 +646,17 @@ pub struct InspectOutcome { pub our_fabric_index: Option<u8>, } +impl InspectOutcome { + /// A node whose live inspection could not complete. + fn failed(node_id: u64, error: String) -> Self { + Self { + node_id: node_id.into(), + error: Some(error), + ..Default::default() + } + } +} + #[derive(Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct TimeSyncCaps { @@ -665,15 +677,7 @@ impl Op for InspectOp { Ok(outcome) => outcomes.push(outcome), Err(error) => { log::error!("Node {node_id}: inspect failed: {error:#}"); - outcomes.push(InspectOutcome { - node_id: node_id.into(), - error: Some(format!("{error:#}")), - vendor_name: None, - product_name: None, - time_sync: None, - our_fabric_label: None, - our_fabric_index: None, - }); + outcomes.push(InspectOutcome::failed(node_id, format!("{error:#}"))); } } } diff --git a/src/main.rs b/src/main.rs index 16fe2df..f372f8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,7 +49,7 @@ use jiff::Timestamp; use crate::config::{Config, DEFAULT_CONFIG_PATH}; use crate::output::{ConfigReport, DstTransition, NodeListing, Output, StatusReport, SyncReport}; -use crate::state::{NodeRegistry, ServiceState}; +use crate::state::Devices; use crate::time::MatterMicros; #[derive(Parser)] @@ -182,11 +182,10 @@ fn main() -> ExitCode { struct Filter(Option<u64>); impl Filter { - fn retain<V>(&self, map: &mut std::collections::BTreeMap<String, V>) { - if let Some(node) = self.0 { - let key = node.to_string(); - map.retain(|k, _| *k == key); - } + /// Whether a device (keyed by decimal node ID) passes the filter: all + /// devices when no `--node` was given, otherwise just the named one. + fn keeps(&self, key: &str) -> bool { + self.0.is_none_or(|node| key.parse() == Ok(node)) } } @@ -208,9 +207,8 @@ impl Target { /// Targets for read-only interrogation: an empty registry is a valid, /// empty answer. An explicitly requested unknown node is still an error. fn resolve_or_empty(&self, config: &Config) -> anyhow::Result<Vec<u64>> { - let registry: NodeRegistry = - state::load_or_default(&state::registry_path(&config.storage_path)); - let known: Vec<u64> = registry.node_ids().collect(); + let devices: Devices = state::load_or_default(&state::devices_path(&config.storage_path)); + let known: Vec<u64> = devices.node_ids().collect(); match self.0 { None => Ok(known), Some(node) if known.contains(&node) => Ok(vec![node]), @@ -219,41 +217,42 @@ impl Target { if known.is_empty() { "none".into() } else { - known - .iter() - .map(u64::to_string) - .collect::<Vec<_>>() - .join(", ") + join_ids(&known) } ), } } } +/// Comma-joins a slice of displayable values (node IDs) for a message. +fn join_ids<T: std::fmt::Display>(items: &[T]) -> String { + items + .iter() + .map(T::to_string) + .collect::<Vec<_>>() + .join(", ") +} + fn run_status(config: &Config, filter: Filter) -> anyhow::Result<Output> { let tz = config.time_zone(); let now = Timestamp::now(); - let state: ServiceState = - state::load_or_default(&state::service_state_path(&config.storage_path)); - let mut registry: NodeRegistry = - state::load_or_default(&state::registry_path(&config.storage_path)); - filter.retain(&mut registry.nodes); - let nodes: Vec<NodeListing> = registry + let devices: Devices = state::load_or_default(&state::devices_path(&config.storage_path)); + let nodes: Vec<NodeListing> = devices .nodes .into_iter() - .filter_map(|(key, info)| { - // Registry keys are decimal node IDs written by us; anything else - // is file corruption and is skipped, matching the lenient loader. + .filter(|(key, _)| filter.keeps(key)) + .filter_map(|(key, device)| { + // Keys are decimal node IDs written by us; anything else is file + // corruption and is skipped, matching the lenient loader. let node_id: u64 = key.parse().ok()?; - let node = state.nodes.get(&key).cloned().unwrap_or_default(); Some(NodeListing { node_id: node_id.into(), - vendor_name: info.vendor_name, - product_name: info.product_name, - last_successful_connection: node.last_successful_connection, - last_successful_sync: node.last_successful_sync, - last_attempted_sync: node.last_attempted_sync, - last_error: node.last_error, + vendor_name: device.vendor_name, + product_name: device.product_name, + last_successful_connection: device.last_successful_connection, + last_successful_sync: device.last_successful_sync, + last_attempted_sync: device.last_attempted_sync, + last_error: device.last_error, }) }) .collect(); @@ -351,10 +350,9 @@ fn run_commission(config: &Config, code: &str) -> anyhow::Result<Output> { fn run_decommission(config: &Config, target: Target) -> anyhow::Result<Output> { let targets = target.resolve(config)?; let nodes = controller::run(config, controller::DecommissionOp { targets })?; - let registry: NodeRegistry = - state::load_or_default(&state::registry_path(&config.storage_path)); + let devices: Devices = state::load_or_default(&state::devices_path(&config.storage_path)); Ok(Output::Decommission { nodes, - remaining_nodes: registry.nodes.into_keys().collect(), + remaining_nodes: devices.nodes.into_keys().collect(), }) } diff --git a/src/state.rs b/src/state.rs index 8f30e2c..c1ca251 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,13 +1,11 @@ -//! Local service state kept next to the Matter fabric storage. +//! Local device state kept next to the Matter fabric storage. //! -//! Two small JSON files, both written atomically (temp file + rename) so a -//! power loss mid-write never corrupts them: -//! -//! - `service-state.json`: per-node sync results, file-compatible with the -//! TypeScript implementation. -//! - `nodes.json`: the node registry (id plus cached vendor/product names), -//! written at commissioning time so `nodes` and `status` never need to -//! start the Matter stack. +//! One JSON file, `devices.json`, holds a record per commissioned device: +//! its cached identity (captured at commissioning, so `status` never has to +//! start the Matter stack) merged with its latest sync results. Written +//! atomically (temp file + rename) so a power loss mid-write never corrupts +//! it. The secret (the root CA key in `identity.json`) is a separate file so +//! these frequent writes never touch it. use std::collections::BTreeMap; use std::io; @@ -17,50 +15,36 @@ use jiff::Timestamp; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +/// Everything known locally about one commissioned device. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] -pub struct NodeState { +pub struct DeviceRecord { + pub vendor_name: Option<String>, + pub product_name: Option<String>, pub last_successful_connection: Option<Timestamp>, pub last_successful_sync: Option<Timestamp>, pub last_attempted_sync: Option<Timestamp>, pub last_error: Option<String>, } +/// The device registry: one record per node, keyed by node ID as a decimal +/// string. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] -pub struct ServiceState { - /// Keyed by node ID as a decimal string, like the TypeScript state file. - pub nodes: BTreeMap<String, NodeState>, -} - -/// Cached identity of a commissioned node, captured at commissioning. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", default)] -pub struct NodeInfo { - pub vendor_name: Option<String>, - pub product_name: Option<String>, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] -pub struct NodeRegistry { - pub nodes: BTreeMap<String, NodeInfo>, +pub struct Devices { + pub nodes: BTreeMap<String, DeviceRecord>, } -impl NodeRegistry { +impl Devices { pub fn node_ids(&self) -> impl Iterator<Item = u64> + '_ { - // Registry keys are written by us; a non-decimal key would be file - // corruption, which the lenient loader already reduces to "empty". + // Keys are written by us; a non-decimal key would be file corruption, + // which the lenient loader already reduces to "empty". self.nodes.keys().filter_map(|key| key.parse().ok()) } } -pub fn service_state_path(storage: &Path) -> PathBuf { - storage.join("service-state.json") -} - -pub fn registry_path(storage: &Path) -> PathBuf { - storage.join("nodes.json") +pub fn devices_path(storage: &Path) -> PathBuf { + storage.join("devices.json") } /// Reads a state file leniently: missing or corrupt files yield the default. @@ -107,22 +91,22 @@ pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { std::fs::write(path, [bytes, b"\n"].concat()) } -/// Merges a patch into one node's entry and persists the result. -pub fn update_node_state( +/// Merges a patch into one device's record and persists the result. +pub fn update_device( path: &Path, node_id: u64, - patch: impl FnOnce(&mut NodeState), + patch: impl FnOnce(&mut DeviceRecord), ) -> io::Result<()> { - let mut state: ServiceState = load_or_default(path); - patch(state.nodes.entry(node_id.to_string()).or_default()); - store(path, &state) + let mut devices: Devices = load_or_default(path); + patch(devices.nodes.entry(node_id.to_string()).or_default()); + store(path, &devices) } -/// Drops one node's entry (after decommissioning) and persists the result. -pub fn remove_node_state(path: &Path, node_id: u64) -> io::Result<()> { - let mut state: ServiceState = load_or_default(path); - state.nodes.remove(&node_id.to_string()); - store(path, &state) +/// Drops one device's record (after decommissioning) and persists the result. +pub fn remove_device(path: &Path, node_id: u64) -> io::Result<()> { + let mut devices: Devices = load_or_default(path); + devices.nodes.remove(&node_id.to_string()); + store(path, &devices) } #[cfg(test)] @@ -136,64 +120,50 @@ mod tests { } #[test] - fn state_round_trips_and_merges() { - let path = tempdir().join("service-state.json"); - update_node_state(&path, 1, |node| { - node.last_error = Some("boom".into()); + fn record_round_trips_and_merges() { + let path = tempdir().join("devices.json"); + update_device(&path, 1, |device| { + device.vendor_name = Some("IKEA of Sweden".into()); + device.last_error = Some("boom".into()); }) .unwrap(); - update_node_state(&path, 1, |node| { - node.last_error = None; - node.last_successful_sync = Some("2026-07-27T02:20:24.240Z".parse().unwrap()); + // A later patch merges into the same record, not replacing it. + update_device(&path, 1, |device| { + device.last_error = None; + device.last_successful_sync = Some("2026-07-27T02:20:24.240Z".parse().unwrap()); }) .unwrap(); - let state: ServiceState = load_or_default(&path); - let node = &state.nodes["1"]; - assert_eq!(node.last_error, None); - assert!(node.last_successful_sync.is_some()); - - remove_node_state(&path, 1).unwrap(); - let state: ServiceState = load_or_default(&path); - assert!(state.nodes.is_empty()); - } + let devices: Devices = load_or_default(&path); + let device = &devices.nodes["1"]; + assert_eq!(device.vendor_name.as_deref(), Some("IKEA of Sweden")); + assert_eq!(device.last_error, None); + assert!(device.last_successful_sync.is_some()); - #[test] - fn reads_typescript_state_files() { - // Exact shape written by the TypeScript implementation. - let raw = r#"{ - "nodes": { - "1": { - "lastSuccessfulConnection": "2026-07-27T02:20:23.357Z", - "lastSuccessfulSync": "2026-07-27T02:20:24.240Z", - "lastAttemptedSync": "2026-07-27T02:20:22.437Z", - "lastError": null - } - } - }"#; - let state: ServiceState = serde_json::from_str(raw).unwrap(); - assert_eq!(state.nodes["1"].last_error, None); - assert!(state.nodes["1"].last_successful_sync.is_some()); + remove_device(&path, 1).unwrap(); + let devices: Devices = load_or_default(&path); + assert!(devices.nodes.is_empty()); } #[test] fn corrupt_files_reduce_to_defaults() { let path = tempdir().join("corrupt.json"); std::fs::write(&path, "{ not json").unwrap(); - let state: ServiceState = load_or_default(&path); - assert!(state.nodes.is_empty()); + let devices: Devices = load_or_default(&path); + assert!(devices.nodes.is_empty()); } #[test] - fn registry_lists_node_ids() { - let mut registry = NodeRegistry::default(); - registry.nodes.insert( + fn lists_node_ids() { + let mut devices = Devices::default(); + devices.nodes.insert( "1".into(), - NodeInfo { + DeviceRecord { vendor_name: Some("IKEA of Sweden".into()), product_name: Some("ALPSTUGA air quality monitor".into()), + ..Default::default() }, ); - assert_eq!(registry.node_ids().collect::<Vec<_>>(), vec![1]); + assert_eq!(devices.node_ids().collect::<Vec<_>>(), vec![1]); } } diff --git a/src/time.rs b/src/time.rs index 6c38cc6..6616194 100644 --- a/src/time.rs +++ b/src/time.rs @@ -188,19 +188,16 @@ impl fmt::Display for CompactDuration { let hours = (total_seconds % 86_400) / 3_600; let minutes = (total_seconds % 3_600) / 60; let seconds = total_seconds % 60; - let mut parts: Vec<String> = Vec::new(); - if days > 0 { - parts.push(format!("{days}d")); - } - if hours > 0 { - parts.push(format!("{hours}h")); - } - if minutes > 0 { - parts.push(format!("{minutes}m")); - } - if seconds > 0 && days == 0 { - parts.push(format!("{seconds}s")); - } + let parts: Vec<String> = [ + (days > 0).then(|| format!("{days}d")), + (hours > 0).then(|| format!("{hours}h")), + (minutes > 0).then(|| format!("{minutes}m")), + // Seconds are dropped once days appear (see the doc comment). + (seconds > 0 && days == 0).then(|| format!("{seconds}s")), + ] + .into_iter() + .flatten() + .collect(); f.write_str(&parts.join(" ")) } } @@ -353,18 +350,16 @@ pub fn parse_wall_clock(input: &str) -> Result<jiff::civil::Time, String> { .map_err(|_| format!("cannot parse {p:?} in {input:?} as a number")) }) .collect::<Result<_, _>>()?; - let (mut hour, minute, second) = (numbers[0], numbers[1], *numbers.get(2).unwrap_or(&0)); - - match meridiem { - Some(pm) => { - if !(1..=12).contains(&hour) { - return Err(format!("hour in {input:?} must be 1-12 with am/pm")); - } - hour = if pm { hour % 12 + 12 } else { hour % 12 }; + let (hour, minute, second) = (numbers[0], numbers[1], *numbers.get(2).unwrap_or(&0)); + let hour = match meridiem { + Some(_) if !(1..=12).contains(&hour) => { + return Err(format!("hour in {input:?} must be 1-12 with am/pm")); } + Some(true) => hour % 12 + 12, + Some(false) => hour % 12, None if hour > 23 => return Err(format!("hour in {input:?} must be 0-23")), - None => {} - } + None => hour, + }; jiff::civil::Time::new(hour as i8, minute as i8, second as i8, 0) .map_err(|e| format!("invalid time {input:?}: {e}")) } |
