src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/main.rs')
-rw-r--r--src/main.rs374
1 files changed, 374 insertions, 0 deletions
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<Command>,
+}
+
+#[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<u64>,
+ },
+ /// 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<u64>,
+ /// 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<u64>,
+ /// 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<String>,
+ /// 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<u64>,
+ /// 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<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);
+ }
+ }
+}
+
+/// `--node` on a device-connecting command: selects targets. A requested
+/// node must be commissioned; without one, every commissioned device.
+struct Target(Option<u64>);
+
+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<Vec<u64>> {
+ 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<Vec<u64>> {
+ let registry: NodeRegistry =
+ state::load_or_default(&state::registry_path(&config.storage_path));
+ let known: Vec<u64> = 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::<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
+ .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<Output> {
+ 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<Output> {
+ // --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<Output> {
+ 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<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));
+ Ok(Output::Decommission {
+ nodes,
+ remaining_nodes: registry.nodes.into_keys().collect(),
+ })
+}