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/main.rs | 374 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 src/main.rs (limited to 'src/main.rs') diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e36ad21 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,374 @@ +//! mattertimesync: a one-shot CLI Matter controller that sets the clocks of +//! Matter devices via the standard Time Synchronization cluster. +//! +//! # CLI invariants +//! +//! Every command upholds these; new commands and options must too. Numbers +//! 1, 2 and 5 are enforced structurally by the [`output::Output`] type: +//! handlers return a value of a closed enum instead of printing, the exit +//! code is derived from that value, and both renderings consume it. +//! +//! 1. **Channels.** stdout carries command output only; all logs go to +//! stderr. With `--json`, stdout is exactly one pretty-printed JSON +//! object with a descriptive top-level key ({"nodes": ...}, +//! {"commissioned": ...}), on failure included ({"error": ...}). +//! 2. **Exit codes.** 0 = success, including empty-but-valid results; +//! 1 = runtime failure, including any per-node failure; 2 = usage or +//! configuration error. Devices skipped as incompatible do not fail a +//! run. +//! 3. **--node semantics.** The local read-only command (status) takes a +//! [`Filter`]: an absent node yields an empty report, never an error. +//! Device-connecting commands (inspect, sync, decommission) take a +//! [`Target`]: the node must be commissioned, otherwise error. +//! commission takes neither because the node ID does not exist until it +//! assigns one. +//! 4. **Default targeting.** Device-connecting commands operate on every +//! commissioned device when --node is omitted. On an empty registry, +//! read-only inspect reports emptiness successfully; fabric-writing +//! sync and decommission error, because having nothing to write to is +//! an operator problem worth surfacing. +//! 5. **Per-node isolation.** Multi-node runs never abort the batch on one +//! node's failure; every node gets its own outcome entry. +//! 6. **Universal options.** -c/--config and -j/--json exist everywhere; +//! -n/--node everywhere except commission. + +mod config; +mod controller; +mod host; +mod output; +mod pairing; +mod state; +mod time; +mod tz; + +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; +use jiff::Timestamp; + +use crate::config::{Config, DEFAULT_CONFIG_PATH}; +use crate::output::{ + ConfigReport, DstTransition, IdentityReport, NodeListing, Output, StatusReport, SyncReport, +}; +use crate::state::{NodeRegistry, ServiceState}; +use crate::time::MatterMicros; + +#[derive(Parser)] +#[command( + name = "mattertimesync", + version, + about, + disable_help_subcommand = true +)] +struct Cli { + /// Configuration file + #[arg(short = 'c', long, global = true, default_value = DEFAULT_CONFIG_PATH)] + config: PathBuf, + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Show controller and per-device state [local, read-only] + Status { + /// Machine-readable output (64-bit values as decimal strings) + #[arg(short = 'j', long)] + json: bool, + /// Show only this device's state + #[arg(short = 'n', long)] + node: Option, + }, + /// Connect to each commissioned device (or one with --node) and dump + /// identity, time-sync capabilities, and our fabric entry + /// [fabric interrogation, read-only] + Inspect { + /// Inspect only this device + #[arg(short = 'n', long)] + node: Option, + /// Machine-readable output (64-bit values as decimal strings) + #[arg(short = 'j', long)] + json: bool, + }, + /// Join a device as an additional Matter admin; the pairing code is + /// used once, never logged or stored [fabric-writing] + Commission { + /// Pairing code from the primary ecosystem's pairing mode + pairing_code: String, + /// Machine-readable output (64-bit values as decimal strings) + #[arg(short = 'j', long)] + json: bool, + }, + /// Set each device's clock: UTC time, time zone, DST offsets + /// [fabric-writing] + Sync { + /// Target one device instead of all commissioned devices + #[arg(short = 'n', long)] + node: Option, + /// Set this wall-clock time (e.g. 16:35 or 4:35pm, today in the + /// configured time zone) instead of the current time + #[arg(short = 't', long, value_name = "TIME")] + time: Option, + /// Machine-readable output (64-bit values as decimal strings) + #[arg(short = 'j', long)] + json: bool, + }, + /// Drop this controller's fabric from each device, or one with --node; + /// primary ecosystems are untouched [fabric-writing] + Decommission { + /// Target one device instead of all commissioned devices + #[arg(short = 'n', long)] + node: Option, + /// Machine-readable output (64-bit values as decimal strings) + #[arg(short = 'j', long)] + json: bool, + }, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + // No command: show the full help, not a terse error. + let Some(command) = cli.command else { + use clap::CommandFactory; + let _ = Cli::command().print_help(); + return ExitCode::from(2); + }; + + let config = match Config::load(&cli.config) { + Ok(config) => config, + Err(error) => { + eprintln!("Configuration error: {error}"); + return ExitCode::from(2); + } + }; + + env_logger::Builder::new() + .filter_level(config.log_level.into()) + .format_timestamp_millis() + .init(); + + for warning in config.path_warnings() { + log::warn!("{warning}"); + } + + let (json, result) = match command { + Command::Status { json, node } => (json, run_status(&config, Filter(node))), + Command::Inspect { json, node } => (json, run_inspect(&config, Target(node))), + Command::Sync { json, node, time } => { + (json, run_sync(&config, Target(node), time.as_deref())) + } + Command::Commission { json, pairing_code } => { + (json, run_commission(&config, &pairing_code)) + } + Command::Decommission { json, node } => (json, run_decommission(&config, Target(node))), + }; + + let output = result.unwrap_or_else(|error| { + log::error!("{error:#}"); + Output::Error { + error: format!("{error:#}"), + } + }); + if json { + output.print_json(); + } else { + output.render_human(); + } + output.exit_code() +} + +/// `--node` on a local, read-only command: filters a report. An absent node +/// yields an empty report, never an error. +struct Filter(Option); + +impl Filter { + fn retain(&self, map: &mut std::collections::BTreeMap) { + if let Some(node) = self.0 { + let key = node.to_string(); + map.retain(|k, _| *k == key); + } + } +} + +/// `--node` on a device-connecting command: selects targets. A requested +/// node must be commissioned; without one, every commissioned device. +struct Target(Option); + +impl Target { + /// Targets for fabric-writing commands: an empty registry is an error + /// (having nothing to write to is an operator problem worth surfacing). + fn resolve(&self, config: &Config) -> anyhow::Result> { + let targets = self.resolve_or_empty(config)?; + if targets.is_empty() { + anyhow::bail!("no devices are commissioned yet; run \"commission\" to add one"); + } + Ok(targets) + } + + /// 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> { + let registry: NodeRegistry = + state::load_or_default(&state::registry_path(&config.storage_path)); + let known: Vec = registry.node_ids().collect(); + match self.0 { + None => Ok(known), + Some(node) if known.contains(&node) => Ok(vec![node]), + Some(node) => anyhow::bail!( + "node {node} is not commissioned on this controller (known nodes: {})", + if known.is_empty() { + "none".into() + } else { + known + .iter() + .map(u64::to_string) + .collect::>() + .join(", ") + } + ), + } + } +} + +fn run_status(config: &Config, filter: Filter) -> anyhow::Result { + 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 = registry + .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. + 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, + }) + }) + .collect(); + + let identity = match controller::identity_status(&config.storage_path) { + controller::IdentityStatus::NotCreated => IdentityReport::NotCreated, + controller::IdentityStatus::Created { + fabric_id, + controller_node_id, + } => IdentityReport::Created { + fabric_id: fabric_id.to_string(), + controller_node_id: controller_node_id.to_string(), + }, + controller::IdentityStatus::Unreadable => IdentityReport::Unreadable, + controller::IdentityStatus::Inconsistent(reason) => IdentityReport::Inconsistent { + reason: reason.to_string(), + }, + }; + let offset_seconds = tz.to_offset(now).seconds(); + Ok(Output::Status(Box::new(StatusReport { + config: ConfigReport { + source: config.source.clone(), + storage_path: config.storage_path.clone(), + timezone: config.timezone.clone(), + log_level: config.log_level.to_string(), + fabric_label: config.fabric_label.clone(), + }, + storage_initialized: config.storage_path.is_dir(), + controller_identity: identity, + host_ntp_synchronized: host::clock_is_ntp_synchronized(), + current_utc_offset_seconds: offset_seconds, + current_utc_offset: tz::format_utc_offset(offset_seconds), + next_dst_transition: tz::next_offset_transition(&tz, now).map(|(at, before, after)| { + DstTransition { + at: at.to_string(), + offset_before_seconds: before, + offset_after_seconds: after, + } + }), + matter_time_now_microseconds: MatterMicros::now().0.to_string(), + nodes, + }))) +} + +fn run_inspect(config: &Config, target: Target) -> anyhow::Result { + let targets = target.resolve_or_empty(config)?; + let nodes = if targets.is_empty() { + Vec::new() + } else { + controller::run(config, controller::InspectOp { targets })? + }; + Ok(Output::Inspection { nodes }) +} + +fn run_sync(config: &Config, target: Target, time: Option<&str>) -> anyhow::Result { + // --time makes the operator the time source: compute today's date in + // the configured zone at the requested wall-clock time. + let manual_time = match time { + None => None, + Some(raw) => { + let wall = crate::time::parse_wall_clock(raw).map_err(|e| anyhow::anyhow!(e))?; + let tz = config.time_zone(); + let today = Timestamp::now().to_zoned(tz.clone()).date(); + let instant = today + .at(wall.hour(), wall.minute(), wall.second(), 0) + .to_zoned(tz) + .map_err(|e| anyhow::anyhow!("cannot place {raw:?} in {}: {e}", config.timezone))? + .timestamp(); + Some(MatterMicros::from_timestamp(instant)) + } + }; + + // Fail safe: never push time from a clock that is not NTP-disciplined. + // Not enforced with --time: the operator deliberately chose the value. + let ntp = host::clock_is_ntp_synchronized(); + if !ntp && manual_time.is_none() { + log::warn!("Host clock is not NTP-synchronized; refusing to set device time."); + return Ok(Output::Sync(SyncReport { + host_ntp_synchronized: false, + manual_time: None, + nodes: Vec::new(), + })); + } + let targets = target.resolve(config)?; + let nodes = controller::run( + config, + controller::SyncOp { + targets, + manual_time, + }, + )?; + Ok(Output::Sync(SyncReport { + host_ntp_synchronized: ntp, + manual_time: manual_time.map(|m| m.to_string()), + nodes, + })) +} + +fn run_commission(config: &Config, code: &str) -> anyhow::Result { + let onboarding = pairing::parse_manual_code(code)?; + let commissioned = controller::run(config, controller::CommissionOp { onboarding })?; + Ok(Output::Commissioned { + nodes: vec![commissioned], + }) +} + +fn run_decommission(config: &Config, target: Target) -> anyhow::Result { + 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)); + Ok(Output::Decommission { + nodes, + remaining_nodes: registry.nodes.into_keys().collect(), + }) +} -- cgit v1.2.3