diff options
| author | Luke Hoersten <[email protected]> | 2026-07-28 16:58:20 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-07-28 16:58:20 -0500 |
| commit | 07d4713f554b2ae2ccf4871f6be0590c129342b0 (patch) | |
| tree | e9675e5e8a7cfce544927339c8f93ee7f412a71b /src/output.rs | |
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.
Diffstat (limited to 'src/output.rs')
| -rw-r--r-- | src/output.rs | 444 |
1 files changed, 444 insertions, 0 deletions
diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..4de5745 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,444 @@ +//! The typed output layer. +//! +//! Every command handler returns an [`Output`]; printing happens exactly +//! once, in `main`, from that value. The CLI invariants are enforced here by +//! construction rather than by convention: +//! +//! - the set of top-level JSON shapes is closed (this enum), so a command +//! cannot invent an envelope or emit a naked array; +//! - JSON formatting is uniform because [`print_json`] is the only printer; +//! - the exit code is derived from the output value by [`Output::exit_code`], +//! so it cannot disagree with what was reported; +//! - human and JSON renderings are fed by the same data, so they cannot +//! drift apart in content. + +use std::path::PathBuf; +use std::process::ExitCode; + +use jiff::Timestamp; +use serde::Serialize; + +use crate::controller::{CommissionOutcome, DecommissionOutcome, InspectOutcome, SyncOutcome}; +use crate::time::CompactDuration; +use crate::tz::format_utc_offset; + +/// A 64-bit Matter identifier (node ID, fabric ID). Serializes as a decimal +/// string, never as a JSON number, which silently loses precision beyond +/// 2^53 in many consumers. The rule lives in the type, so an ID field +/// cannot be emitted wrongly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Id64(pub u64); + +impl Serialize for Id64 { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl std::fmt::Display for Id64 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl From<u64> for Id64 { + fn from(value: u64) -> Self { + Self(value) + } +} + +/// Everything a command can say. Untagged: each variant serializes as its +/// own object with descriptive top-level keys. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum Output { + Status(Box<StatusReport>), + Inspection { + nodes: Vec<InspectOutcome>, + }, + Sync(SyncReport), + Commissioned { + nodes: Vec<CommissionOutcome>, + }, + #[serde(rename_all = "camelCase")] + Decommission { + nodes: Vec<DecommissionOutcome>, + remaining_nodes: Vec<String>, + }, + Error { + error: String, + }, +} + +/// Everything that was parsed from the configuration file, echoed back so +/// an operator can see exactly what the tool is running with. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigReport { + pub source: PathBuf, + pub storage_path: PathBuf, + pub timezone: String, + pub log_level: String, + pub fabric_label: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatusReport { + pub config: ConfigReport, + pub storage_initialized: bool, + pub controller_identity: IdentityReport, + pub host_ntp_synchronized: bool, + pub current_utc_offset_seconds: i32, + pub current_utc_offset: String, + pub next_dst_transition: Option<DstTransition>, + pub matter_time_now_microseconds: String, + /// One entry per commissioned device: registry identity merged with + /// the latest sync state. + pub nodes: Vec<NodeListing>, +} + +#[derive(Debug, Serialize)] +#[serde( + tag = "state", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum IdentityReport { + NotCreated, + Created { + fabric_id: String, + controller_node_id: String, + }, + Unreadable, + Inconsistent { + reason: String, + }, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DstTransition { + pub at: String, + pub offset_before_seconds: i32, + pub offset_after_seconds: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeListing { + pub node_id: Id64, + 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>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncReport { + pub host_ntp_synchronized: bool, + /// Set when the operator supplied --time: the written instant. + pub manual_time: Option<String>, + pub nodes: Vec<SyncOutcome>, +} + +impl Output { + /// The exit code is a function of the reported outcome; a handler cannot + /// return one that disagrees with its output. 0 = success (including + /// empty-but-valid results), 1 = any failure. Skipped devices are not + /// failures. + pub fn exit_code(&self) -> ExitCode { + let ok = match self { + Output::Status(_) | Output::Commissioned { .. } => true, + Output::Inspection { nodes } => nodes.iter().all(|n| n.error.is_none()), + Output::Sync(report) => { + // With --time the operator is the time source; the NTP + // verdict is informational only. + (report.host_ntp_synchronized || report.manual_time.is_some()) + && report.nodes.iter().all(|n| n.success || n.skipped) + } + Output::Decommission { nodes, .. } => nodes.iter().all(|n| n.success), + Output::Error { .. } => false, + }; + if ok { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + } + + /// The one JSON printer: pretty, one object, always parseable. + pub fn print_json(&self) { + match serde_json::to_string_pretty(self) { + Ok(body) => println!("{body}"), + Err(error) => println!("{{\"error\": \"serialize output: {error}\"}}"), + } + } + + pub fn render_human(&self) { + match self { + Output::Status(report) => render_status(report), + Output::Inspection { nodes } => render_inspection(nodes), + Output::Sync(report) => render_sync(report), + Output::Commissioned { nodes } => nodes.iter().for_each(render_commissioned), + Output::Decommission { + nodes, + remaining_nodes, + } => render_decommission(nodes, remaining_nodes), + // Failures are already on stderr via the log; stdout stays quiet. + Output::Error { .. } => {} + } + } +} + +fn display_instant(at: Option<Timestamp>) -> String { + at.map_or_else(|| "never".into(), |at| at.to_string()) +} + +fn render_status(report: &StatusReport) { + println!( + "Configuration: loaded from {}", + report.config.source.display() + ); + println!( + " storagePath: {}", + report.config.storage_path.display() + ); + println!(" timezone: {}", report.config.timezone); + println!(" logLevel: {}", report.config.log_level); + println!(" fabricLabel: {}", report.config.fabric_label); + println!( + "Controller storage: {}", + if report.storage_initialized { + format!("present at {}", report.config.storage_path.display()) + } else { + "NOT INITIALIZED".into() + } + ); + match &report.controller_identity { + IdentityReport::NotCreated => { + println!("Controller identity: not created yet (\"commission\" will create it)") + } + IdentityReport::Created { + fabric_id, + controller_node_id, + } => println!( + "Controller identity: created (fabric id {fabric_id}, controller node id {controller_node_id})" + ), + IdentityReport::Unreadable => { + println!("Controller identity: present but unreadable (run as the service user?)") + } + IdentityReport::Inconsistent { reason } => { + println!("Controller identity: INCONSISTENT: {reason}") + } + } + println!( + "Commissioned nodes: {}", + if report.nodes.is_empty() { + "none recorded".into() + } else { + report + .nodes + .iter() + .map(|n| n.node_id.to_string()) + .collect::<Vec<_>>() + .join(", ") + } + ); + println!( + "Host NTP synced: {}", + if report.host_ntp_synchronized { + "yes" + } else { + "no (or not determinable on this host)" + } + ); + println!("Current UTC offset: {}", report.current_utc_offset); + match &report.next_dst_transition { + Some(transition) => println!( + "Next DST transition: {} ({} -> {})", + transition.at, + format_utc_offset(transition.offset_before_seconds), + format_utc_offset(transition.offset_after_seconds) + ), + None => println!("Next DST transition: none (fixed-offset zone)"), + } + println!( + "Matter time now: {} us since 2000-01-01T00:00:00Z", + report.matter_time_now_microseconds + ); + for node in &report.nodes { + let name = [node.vendor_name.as_deref(), node.product_name.as_deref()] + .into_iter() + .flatten() + .collect::<Vec<_>>() + .join(" "); + println!( + "Node {}: {}", + node.node_id, + if name.is_empty() { + "(no cached device info)" + } else { + &name + } + ); + println!( + " Last connection: {}", + display_instant(node.last_successful_connection) + ); + println!( + " Last successful sync: {}", + display_instant(node.last_successful_sync) + ); + println!( + " Last attempted sync: {}", + display_instant(node.last_attempted_sync) + ); + println!( + " Most recent error: {}", + node.last_error.as_deref().unwrap_or("none") + ); + } +} + +fn render_inspection(nodes: &[InspectOutcome]) { + if nodes.is_empty() { + println!("No devices are commissioned yet; run \"commission\" to add one."); + return; + } + for outcome in nodes { + println!("Node {}", outcome.node_id); + if let Some(error) = &outcome.error { + println!(" Error: {error}"); + println!(); + continue; + } + println!( + " Vendor: {}", + outcome.vendor_name.as_deref().unwrap_or("(unknown)") + ); + println!( + " Product: {}", + outcome.product_name.as_deref().unwrap_or("(unknown)") + ); + if let Some(time_sync) = &outcome.time_sync { + println!(" Time Synchronization cluster (endpoint 0):"); + println!( + " features: {:#x}{}", + time_sync.feature_map, + if time_sync.time_zone_feature { + " (timeZone)" + } else { + "" + } + ); + println!( + " utcTime: {}", + time_sync.utc_time.as_deref().unwrap_or("unset") + ); + println!(" granularity: {}", time_sync.granularity); + println!( + " dstOffsetListMaxSize: {}", + time_sync.dst_offset_list_max_size + ); + } + println!( + " Our fabric entry: label {:?}, device fabric index {}", + outcome.our_fabric_label.as_deref().unwrap_or("(none)"), + outcome + .our_fabric_index + .map(|i| i.to_string()) + .unwrap_or_else(|| "?".into()) + ); + println!(); + } +} + +fn render_sync(report: &SyncReport) { + if !report.host_ntp_synchronized && report.manual_time.is_none() { + // The refusal is already on stderr as a warning. + return; + } + if let Some(manual) = &report.manual_time { + println!("Manual time set: {manual} (host NTP state not enforced)"); + } + for outcome in &report.nodes { + println!("Node {}:", outcome.node_id); + if let Some(before) = &outcome.clock_before { + println!(" Assessment: {before}"); + } + if let Some(written) = &outcome.utc_time_written { + println!(" Time written: {written}"); + } + if let Some(zone) = &outcome.time_zone_written { + println!(" Time zone: {zone}"); + } + if outcome.dst_entries_written > 0 { + println!(" DST offsets: {} entries", outcome.dst_entries_written); + } + if let Some(delta) = outcome.delta_after_micros { + println!( + " Verification: device clock within {} of host after sync", + CompactDuration(delta.unsigned_abs()) + ); + } + match (&outcome.error, outcome.skipped) { + (None, _) => println!(" Result: OK"), + (Some(reason), true) => println!(" Result: SKIPPED ({reason})"), + (Some(error), false) => println!(" Result: FAILED ({error})"), + } + } +} + +fn render_commissioned(outcome: &CommissionOutcome) { + println!("Commissioning summary"); + println!(" Node ID: {}", outcome.node_id); + println!(" Fabric ID: {}", outcome.fabric_id); + println!( + " Device: {}", + [ + outcome.vendor_name.as_deref(), + outcome.product_name.as_deref() + ] + .into_iter() + .flatten() + .collect::<Vec<_>>() + .join(" ") + ); + println!(); + println!("The device remains paired with its primary ecosystem; this controller"); + println!("was added as an additional Matter administrator. Run \"sync\" to"); + println!("synchronize its clock now."); +} + +fn render_decommission(nodes: &[DecommissionOutcome], remaining: &[String]) { + for outcome in nodes { + if outcome.success { + println!( + "Device {} removed this controller's fabric; its primary ecosystem is untouched.", + outcome.node_id + ); + } else { + println!( + "Device {} could NOT be decommissioned: {}", + outcome.node_id, + outcome.error.as_deref().unwrap_or("unknown error") + ); + } + } + if remaining.is_empty() { + println!( + "No devices remain commissioned. The local controller identity remains in the \ + storage directory; deleting it is now safe." + ); + } else { + println!( + "{} device(s) remain commissioned: {}", + remaining.len(), + remaining.join(", ") + ); + } +} |
