From 07d4713f554b2ae2ccf4871f6be0590c129342b0 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Tue, 28 Jul 2026 16:58:20 -0500 Subject: Implement mattertimesync: one-shot Matter time synchronization CLI A standalone CLI Matter controller that joins Matter devices as a secondary administrator (multi-admin) and sets their clocks via the standard Time Synchronization cluster. Built on rs-matter 0.2.0, the official CSA Rust Matter stack: its PASE/CASE initiators, Commissioner flow, builtin mDNS, and generated cluster clients. One-shot runs from a systemd timer; there is no daemon. Hardware-validated end to end (commission, inspect, sync, decommission) against an IKEA ALPSTUGA air quality monitor over Thread, commissioned alongside Apple Home. Design and operation are documented in the README. 34 unit tests, clippy clean. --- src/state.rs | 194 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 src/state.rs (limited to 'src/state.rs') diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..887ac21 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,194 @@ +//! Local service 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. + +use std::collections::BTreeMap; +use std::io; +use std::path::{Path, PathBuf}; + +use jiff::Timestamp; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct NodeState { + pub last_successful_connection: Option, + pub last_successful_sync: Option, + pub last_attempted_sync: Option, + pub last_error: Option, +} + +#[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, +} + +/// 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, + pub product_name: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct NodeRegistry { + pub nodes: BTreeMap, +} + +impl NodeRegistry { + pub fn node_ids(&self) -> impl Iterator + '_ { + // Registry 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") +} + +/// Reads a state file leniently: missing or corrupt files yield the default. +/// These files hold status metadata only; refusing to run over them would +/// turn a scratched cache into an outage. +pub fn load_or_default(path: &Path) -> T { + match std::fs::read_to_string(path) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or_default(), + Err(_) => T::default(), + } +} + +/// Atomic JSON write: temp file in the same directory, then rename. +pub fn store(path: &Path, value: &T) -> io::Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent)?; + let tmp = path.with_extension("json.tmp"); + let body = serde_json::to_string_pretty(value).expect("state serialization cannot fail"); + write_private(&tmp, body.as_bytes())?; + std::fs::rename(&tmp, path) +} + +#[cfg(unix)] +pub(crate) fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(bytes)?; + file.write_all(b"\n") +} + +#[cfg(not(unix))] +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( + path: &Path, + node_id: u64, + patch: impl FnOnce(&mut NodeState), +) -> io::Result<()> { + let mut state: ServiceState = load_or_default(path); + patch(state.nodes.entry(node_id.to_string()).or_default()); + store(path, &state) +} + +/// 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) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tempdir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("mts-state-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[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()); + }) + .unwrap(); + update_node_state(&path, 1, |node| { + node.last_error = None; + node.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()); + } + + #[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()); + } + + #[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()); + } + + #[test] + fn registry_lists_node_ids() { + let mut registry = NodeRegistry::default(); + registry.nodes.insert( + "1".into(), + NodeInfo { + vendor_name: Some("IKEA of Sweden".into()), + product_name: Some("ALPSTUGA air quality monitor".into()), + }, + ); + assert_eq!(registry.node_ids().collect::>(), vec![1]); + } +} -- cgit v1.2.3