src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/output.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/output.rs')
-rw-r--r--src/output.rs444
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(", ")
+ );
+ }
+}