src.nth.io/

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.rs264
-rw-r--r--src/controller.rs1332
-rw-r--r--src/host.rs88
-rw-r--r--src/main.rs374
-rw-r--r--src/output.rs444
-rw-r--r--src/pairing.rs129
-rw-r--r--src/state.rs194
-rw-r--r--src/time.rs367
-rw-r--r--src/tz.rs268
9 files changed, 3460 insertions, 0 deletions
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..3e53975
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,264 @@
+//! Validated JSON configuration, file-compatible with the TypeScript
+//! implementation (`/etc/mattertimesync/config.json`, camelCase keys).
+//!
+//! Unknown fields are rejected loudly via serde's `deny_unknown_fields`;
+//! device membership deliberately lives in controller storage, not here.
+
+use std::fmt;
+use std::path::PathBuf;
+
+use jiff::tz::TimeZone;
+use serde::Deserialize;
+
+pub const DEFAULT_CONFIG_PATH: &str = "/etc/mattertimesync/config.json";
+
+/// Matter FabricDescriptorStruct label limit.
+const FABRIC_LABEL_MAX_LENGTH: usize = 32;
+
+#[derive(Debug, thiserror::Error)]
+pub enum ConfigError {
+ #[error("cannot read configuration file {path}: {source}")]
+ Unreadable {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ #[error("configuration is not valid: {0}")]
+ Invalid(#[from] serde_json::Error),
+ #[error("\"timezone\" must be a valid IANA time-zone name (got {0:?})")]
+ BadTimezone(String),
+ #[error(
+ "\"fabricLabel\" must be a non-empty string of at most {FABRIC_LABEL_MAX_LENGTH} characters"
+ )]
+ BadFabricLabel,
+ #[error(
+ "\"storagePath\" must be an absolute path (got {0:?}); JSON configs get no shell expansion"
+ )]
+ RelativeStoragePath(PathBuf),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum LogLevel {
+ Debug,
+ #[default]
+ Info,
+ Warn,
+ Error,
+}
+
+impl From<LogLevel> for log::LevelFilter {
+ fn from(level: LogLevel) -> Self {
+ match level {
+ LogLevel::Debug => log::LevelFilter::Debug,
+ LogLevel::Info => log::LevelFilter::Info,
+ LogLevel::Warn => log::LevelFilter::Warn,
+ LogLevel::Error => log::LevelFilter::Error,
+ }
+ }
+}
+
+impl fmt::Display for LogLevel {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let name = match self {
+ LogLevel::Debug => "debug",
+ LogLevel::Info => "info",
+ LogLevel::Warn => "warn",
+ LogLevel::Error => "error",
+ };
+ f.write_str(name)
+ }
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub struct Config {
+ /// Where this configuration was loaded from; not a config field.
+ #[serde(skip)]
+ pub source: PathBuf,
+ /// Directory holding persistent Matter fabric state and service state.
+ /// Contains the controller's private keys; mode 0700.
+ pub storage_path: PathBuf,
+ /// IANA time-zone name, e.g. "America/Chicago". Never a fixed UTC offset.
+ #[serde(default = "default_timezone")]
+ pub timezone: String,
+ #[serde(default)]
+ pub log_level: LogLevel,
+ /// Fabric label other ecosystems display for this controller (e.g. the
+ /// Apple Home Connected Services subtitle). Must be unique per device.
+ #[serde(default = "default_fabric_label")]
+ pub fabric_label: String,
+}
+
+fn default_timezone() -> String {
+ "America/Chicago".into()
+}
+
+fn default_fabric_label() -> String {
+ "Matter Time Sync".into()
+}
+
+impl Config {
+ pub fn load(path: &std::path::Path) -> Result<Self, ConfigError> {
+ let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::Unreadable {
+ path: path.to_owned(),
+ source,
+ })?;
+ let mut config = Self::parse(&raw)?;
+ config.source = path.to_owned();
+ Ok(config)
+ }
+
+ pub fn parse(raw: &str) -> Result<Self, ConfigError> {
+ let config: Config = serde_json::from_str(raw)?;
+ config.validate()?;
+ Ok(config)
+ }
+
+ fn validate(&self) -> Result<(), ConfigError> {
+ // No shell expansion happens on a JSON file, so a "~/..." or relative
+ // path would silently land wherever the process happens to run.
+ if !self.storage_path.is_absolute() {
+ return Err(ConfigError::RelativeStoragePath(self.storage_path.clone()));
+ }
+ // Resolving through jiff's tzdb is the validation; a bare offset or
+ // invented name fails here rather than at 3am on a DST transition.
+ TimeZone::get(&self.timezone)
+ .map_err(|_| ConfigError::BadTimezone(self.timezone.clone()))?;
+ if self.fabric_label.trim().is_empty() || self.fabric_label.len() > FABRIC_LABEL_MAX_LENGTH
+ {
+ return Err(ConfigError::BadFabricLabel);
+ }
+ Ok(())
+ }
+
+ /// Warnings for path components that look like they expected shell
+ /// expansion: a component starting with `~` or containing `$`. Such
+ /// directories can legitimately exist, so these cannot be errors; but
+ /// far more often they mean the config was written expecting a shell to
+ /// expand it, and the data would land in a literal `~foo` directory.
+ pub fn path_warnings(&self) -> Vec<String> {
+ self.storage_path
+ .components()
+ .filter_map(|component| {
+ let text = component.as_os_str().to_string_lossy();
+ let looks_like = if text.starts_with('~') {
+ "a shell tilde"
+ } else if text.contains('$') {
+ "an unexpanded shell variable"
+ } else {
+ return None;
+ };
+ Some(format!(
+ "storagePath component {text:?} looks like {looks_like}; JSON configs get no \
+ shell expansion, so it will be used as a literal directory name"
+ ))
+ })
+ .collect()
+ }
+
+ /// The configured zone, resolved against the system tzdb.
+ pub fn time_zone(&self) -> TimeZone {
+ // Validated at load time; a tzdb that shrinks between then and now is
+ // not a scenario worth threading a Result through every caller for.
+ TimeZone::get(&self.timezone).expect("timezone was validated at config load")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const MINIMAL: &str = r#"{ "storagePath": "/var/lib/mattertimesync" }"#;
+
+ #[test]
+ fn minimal_config_applies_defaults() {
+ let config = Config::parse(MINIMAL).unwrap();
+ assert_eq!(
+ config.storage_path,
+ PathBuf::from("/var/lib/mattertimesync")
+ );
+ assert_eq!(config.timezone, "America/Chicago");
+ assert_eq!(config.log_level, LogLevel::Info);
+ assert_eq!(config.fabric_label, "Matter Time Sync");
+ }
+
+ #[test]
+ fn full_config_round_trips() {
+ let config = Config::parse(
+ r#"{
+ "storagePath": "/tmp/x",
+ "timezone": "Europe/Berlin",
+ "logLevel": "debug",
+ "fabricLabel": "Lakeside Time Sync"
+ }"#,
+ )
+ .unwrap();
+ assert_eq!(config.timezone, "Europe/Berlin");
+ assert_eq!(config.log_level, LogLevel::Debug);
+ assert_eq!(config.fabric_label, "Lakeside Time Sync");
+ }
+
+ #[test]
+ fn unknown_fields_are_rejected_loudly() {
+ // Fields from abandoned designs are rejected, not ignored.
+ for raw in [
+ r#"{ "storagePath": "/x", "nodeId": "1" }"#,
+ r#"{ "storagePath": "/x", "syncIntervalHours": 24 }"#,
+ r#"{ "storagePath": "/x", "unexpected": 1 }"#,
+ ] {
+ let error = Config::parse(raw).unwrap_err();
+ assert!(error.to_string().contains("unknown field"), "{error}");
+ }
+ }
+
+ #[test]
+ fn suspicious_path_components_warn_but_load() {
+ let config = Config::parse(r#"{ "storagePath": "/data/~backup" }"#).unwrap();
+ assert_eq!(config.path_warnings().len(), 1);
+ assert!(config.path_warnings()[0].contains("shell tilde"));
+
+ let config = Config::parse(r#"{ "storagePath": "/var/lib/$USER/mts" }"#).unwrap();
+ assert!(config.path_warnings()[0].contains("unexpanded shell variable"));
+
+ let config = Config::parse(r#"{ "storagePath": "/var/lib/mattertimesync" }"#).unwrap();
+ assert!(config.path_warnings().is_empty());
+ }
+
+ #[test]
+ fn non_absolute_storage_paths_are_rejected() {
+ for path in ["~/mts-storage", "data", "./data"] {
+ let raw = format!(r#"{{ "storagePath": {path:?} }}"#);
+ let error = Config::parse(&raw).unwrap_err();
+ assert!(
+ error.to_string().contains("absolute"),
+ "accepted {path:?}: {error}"
+ );
+ }
+ }
+
+ #[test]
+ fn storage_path_is_required() {
+ assert!(Config::parse(r#"{}"#).is_err());
+ }
+
+ #[test]
+ fn invalid_timezones_are_rejected() {
+ for tz in ["Central Time", "", "America/Springfield", "UTC-6"] {
+ let raw = format!(r#"{{ "storagePath": "/x", "timezone": {tz:?} }}"#);
+ assert!(Config::parse(&raw).is_err(), "accepted {tz:?}");
+ }
+ }
+
+ #[test]
+ fn invalid_log_levels_are_rejected() {
+ assert!(Config::parse(r#"{ "storagePath": "/x", "logLevel": "verbose" }"#).is_err());
+ }
+
+ #[test]
+ fn invalid_fabric_labels_are_rejected() {
+ for label in ["", " ", &"x".repeat(33)] {
+ let raw = format!(r#"{{ "storagePath": "/x", "fabricLabel": {label:?} }}"#);
+ assert!(Config::parse(&raw).is_err(), "accepted {label:?}");
+ }
+ }
+}
diff --git a/src/controller.rs b/src/controller.rs
new file mode 100644
index 0000000..675f58d
--- /dev/null
+++ b/src/controller.rs
@@ -0,0 +1,1332 @@
+//! The rs-matter controller: persistent fabric, one-shot stack harness, and
+//! the on-wire operations (commission, sync, decommission).
+//!
+//! Being a secondary Matter controller is fabric membership, not a running
+//! process: every command builds the stack, races the transport and mDNS
+//! pumps against the actual operation, and exits. The fabric identity
+//! persists in `<storagePath>/matter/` (rs-matter's KV blobs) plus
+//! `<storagePath>/identity.json` (the RCAC private key that signs device
+//! NOCs, which rs-matter deliberately leaves to the caller).
+
+use std::net::UdpSocket;
+use std::num::NonZeroU8;
+use std::path::Path;
+use std::pin::pin;
+
+use anyhow::{Context as _, anyhow, bail};
+use embassy_futures::select::{Either3, select3};
+use rs_matter::Matter;
+use rs_matter::cert::MAX_CERT_TLV_AND_ASN1_LEN;
+use rs_matter::cert::r#gen::VALID_FOREVER;
+use rs_matter::crypto::{
+ CanonAeadKey, CanonPkcSecretKey, Crypto, RngCore as _, SecretKey, SigningSecretKey,
+ default_crypto,
+};
+use rs_matter::dm::devices::test::{DAC_PRIVKEY, TEST_DEV_ATT, TEST_DEV_COMM, TEST_DEV_DET};
+use rs_matter::error::Error as MatterError;
+use rs_matter::fabric::FabricPersist;
+use rs_matter::onboard::cac::RcacGenerator;
+use rs_matter::onboard::noc::NocGenerator;
+use rs_matter::transport::network::mdns::CommissionableFilter;
+use rs_matter::transport::network::mdns::builtin::{BuiltinMdns, Host};
+use rs_matter::transport::network::mdns::{
+ MDNS_IPV6_BROADCAST_ADDR, MDNS_SOCKET_DEFAULT_BIND_ADDR,
+};
+use rs_matter::utils::init::InitMaybeUninit;
+use serde::{Deserialize, Serialize};
+use static_cell::StaticCell;
+
+use crate::config::Config;
+use crate::state;
+
+/// The identity material rs-matter leaves to the caller, persisted as JSON
+/// with the RCAC private key hex-encoded. Mode 0600, next to the KV blobs.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Identity {
+ pub fabric_id: u64,
+ pub controller_node_id: u64,
+ pub next_device_node_id: u64,
+ pub rcac_privkey_hex: String,
+}
+
+/// What `status` reports about the controller identity, derived purely from
+/// the on-disk artifacts (no Matter stack startup).
+#[derive(Debug)]
+pub enum IdentityStatus {
+ /// Neither identity.json nor a fabric blob: first commission creates it.
+ NotCreated,
+ Created {
+ fabric_id: u64,
+ controller_node_id: u64,
+ },
+ /// identity.json exists but cannot be read or parsed (typically
+ /// permissions: it is mode 0600, owned by the service user).
+ Unreadable,
+ /// One half of the identity pair is missing; the fabric-writing commands
+ /// will refuse or auto-recover per their guardrails.
+ Inconsistent(&'static str),
+}
+
+/// Inspects the identity pair on disk. A fabric blob is any k_* file in the
+/// matter/ KV directory.
+pub fn identity_status(storage: &Path) -> IdentityStatus {
+ let identity_exists = identity_path(storage).exists();
+ let fabric_exists = std::fs::read_dir(matter_kv_path(storage))
+ .map(|entries| {
+ entries
+ .flatten()
+ .any(|e| e.file_name().to_string_lossy().starts_with("k_"))
+ })
+ .unwrap_or(false);
+ match (identity_exists, fabric_exists) {
+ (false, false) => IdentityStatus::NotCreated,
+ (true, false) => {
+ IdentityStatus::Inconsistent("identity.json exists but the fabric storage is missing")
+ }
+ (false, true) => {
+ IdentityStatus::Inconsistent("fabric storage exists but identity.json is missing")
+ }
+ (true, true) => match std::fs::read_to_string(identity_path(storage))
+ .ok()
+ .and_then(|raw| serde_json::from_str::<Identity>(&raw).ok())
+ {
+ Some(identity) => IdentityStatus::Created {
+ fabric_id: identity.fabric_id,
+ controller_node_id: identity.controller_node_id,
+ },
+ None => IdentityStatus::Unreadable,
+ },
+ }
+}
+
+fn identity_path(storage: &Path) -> std::path::PathBuf {
+ storage.join("identity.json")
+}
+
+fn matter_kv_path(storage: &Path) -> std::path::PathBuf {
+ storage.join("matter")
+}
+
+impl Identity {
+ fn generate(crypto: &impl Crypto) -> Result<Self, MatterError> {
+ let mut rng = crypto.rand()?;
+ let mut bytes = [0u8; 8];
+ rng.fill_bytes(&mut bytes);
+ // Non-zero 64-bit fabric id; operational node ids for devices are
+ // small and sequential like matter.js's.
+ let fabric_id = u64::from_be_bytes(bytes) | 1;
+ let mut node_bytes = [0u8; 8];
+ rng.fill_bytes(&mut node_bytes);
+ Ok(Self {
+ fabric_id,
+ controller_node_id: u64::from_be_bytes(node_bytes) | 1,
+ next_device_node_id: 1,
+ rcac_privkey_hex: String::new(),
+ })
+ }
+
+ fn rcac_privkey(&self) -> anyhow::Result<CanonPkcSecretKey> {
+ let bytes = hex_decode(&self.rcac_privkey_hex).context("identity.json rcac key")?;
+ let mut key = CanonPkcSecretKey::new();
+ if bytes.len() != key.access_mut().len() {
+ bail!(
+ "identity.json rcac key has unexpected length {}",
+ bytes.len()
+ );
+ }
+ key.access_mut().copy_from_slice(&bytes);
+ Ok(key)
+ }
+}
+
+fn hex_encode(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+fn hex_decode(hex: &str) -> anyhow::Result<Vec<u8>> {
+ if !hex.len().is_multiple_of(2) {
+ bail!("odd-length hex");
+ }
+ (0..hex.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).context("bad hex digit"))
+ .collect()
+}
+
+static MATTER: StaticCell<Matter> = StaticCell::new();
+
+/// Flat-file KV store with atomic writes, stand-in for rs-matter's
+/// `DirKvBlobStore` (whose `store()` is a plain overwrite). The fabric
+/// record lives here and must never be half-written; a temp file plus
+/// rename makes every update all-or-nothing. Same `k_XXXX` file naming.
+struct AtomicKvBlobStore(std::path::PathBuf);
+
+impl AtomicKvBlobStore {
+ fn key_path(&self, key: u16) -> std::path::PathBuf {
+ self.0.join(format!("k_{key:04x}"))
+ }
+}
+
+impl rs_matter::persist::KvBlobStore for AtomicKvBlobStore {
+ fn load<'a>(&mut self, key: u16, buf: &'a mut [u8]) -> Result<Option<&'a [u8]>, MatterError> {
+ match std::fs::read(self.key_path(key)) {
+ Ok(data) => {
+ let slot = buf
+ .get_mut(..data.len())
+ .ok_or(rs_matter::error::ErrorCode::NoSpace)?;
+ slot.copy_from_slice(&data);
+ Ok(Some(slot))
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
+ Err(_) => Err(rs_matter::error::ErrorCode::StdIoError.into()),
+ }
+ }
+
+ fn store(&mut self, key: u16, data: &[u8], _buf: &mut [u8]) -> Result<(), MatterError> {
+ let path = self.key_path(key);
+ let write = || -> std::io::Result<()> {
+ std::fs::create_dir_all(self.0.as_path())?;
+ let tmp = path.with_extension("tmp");
+ state::write_private(&tmp, data)?;
+ std::fs::rename(&tmp, &path)
+ };
+ write().map_err(|_| rs_matter::error::ErrorCode::StdIoError.into())
+ }
+
+ fn remove(&mut self, key: u16, _buf: &mut [u8]) -> Result<(), MatterError> {
+ match std::fs::remove_file(self.key_path(key)) {
+ Ok(()) => Ok(()),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(_) => Err(rs_matter::error::ErrorCode::StdIoError.into()),
+ }
+ }
+}
+
+/// Everything an operation needs, borrowed from the harness scope.
+pub struct Ctx<'a, C: Crypto> {
+ pub matter: &'a Matter<'a>,
+ pub crypto: &'a C,
+ pub fab_idx: NonZeroU8,
+ pub identity: Identity,
+ pub config: &'a Config,
+}
+
+/// A controller operation, generic over the crypto backend the harness
+/// picks. A trait (rather than an async closure) because Rust forbids a
+/// nested `impl Trait` in closure parameters.
+pub trait Op {
+ type Out;
+ async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<Self::Out>;
+}
+
+/// Builds the Matter stack, ensures the persistent controller fabric exists,
+/// then races the transport and mDNS pumps against `op`. The pumps never
+/// finish on their own; `op` completing ends the run.
+pub fn run<O: Op>(config: &Config, op: O) -> anyhow::Result<O::Out> {
+ std::fs::create_dir_all(&config.storage_path).context("create storage directory")?;
+ restrict_dir_mode(&config.storage_path)?;
+ // One controller process per storage directory. Held for the process
+ // lifetime (flock releases on exit, so no stale-lock handling); this
+ // makes the first-run identity bootstrap race-free when a manual run
+ // overlaps the timer.
+ let _lock = lock_storage(&config.storage_path)?;
+
+ futures_lite::future::block_on(async {
+ let crypto = default_crypto(rand::thread_rng(), DAC_PRIVKEY);
+ let matter: &'static Matter =
+ MATTER
+ .uninit()
+ .init_with(Matter::init(&TEST_DEV_DET, TEST_DEV_COMM, &TEST_DEV_ATT, 0));
+
+ let kv_store = AtomicKvBlobStore(matter_kv_path(&config.storage_path));
+ let kv = matter.kv(kv_store);
+ matter
+ .load_persist(&kv)
+ .await
+ .map_err(|e| anyhow!("load persisted Matter state: {e:?}"))?;
+
+ let (fab_idx, identity) = ensure_fabric(config, matter, &crypto, &kv)?;
+ let ctx = Ctx {
+ matter,
+ crypto: &crypto,
+ fab_idx,
+ identity,
+ config,
+ };
+
+ // Matter transport on an ephemeral port.
+ let socket =
+ async_io::Async::<UdpSocket>::bind(([0u16; 8], 0)).context("bind Matter UDP socket")?;
+ let transport = matter.run(&crypto, &socket, &socket, &socket);
+
+ // mDNS: the builtin responder everywhere. On the deployment
+ // platform (Linux) it owns port 5353 outright and behaves
+ // deterministically. On a macOS dev machine it shares 5353 with
+ // mDNSResponder and resolve answers occasionally race to the wrong
+ // socket; the connect retries absorb most of that. (The system
+ // dnssd backend was tried and is worse: its 10-item browse channel
+ // deterministically drops records on Matter-dense networks.)
+ let mdns_socket = bind_mdns_socket().context("bind mDNS socket")?;
+ let (mdns_host_ipv4, mdns_ipv6, mdns_interface) = pick_interface()?;
+ let hostname = format!(
+ "{:012X}",
+ ctx.identity.controller_node_id & 0xFFFF_FFFF_FFFF
+ );
+ let host = Host {
+ hostname: &hostname,
+ ip: mdns_host_ipv4,
+ ipv6: mdns_ipv6,
+ };
+ let mut mdns_runner = BuiltinMdns::new();
+ // ipv4_interface: None disables the runner's IPv4 send path
+ // entirely; see bind_mdns_socket for why mDNS is IPv6-only here.
+ let mdns = mdns_runner.run(
+ &mdns_socket,
+ &mdns_socket,
+ &host,
+ None,
+ Some(mdns_interface),
+ matter,
+ &crypto,
+ );
+
+ let mut transport = pin!(transport);
+ let mut mdns = pin!(mdns);
+ let mut op_fut = pin!(op.run(&ctx));
+
+ match select3(&mut transport, &mut mdns, &mut op_fut).await {
+ Either3::First(r) => bail!("Matter transport exited prematurely: {r:?}"),
+ Either3::Second(r) => bail!("mDNS runner exited prematurely: {r:?}"),
+ Either3::Third(result) => result,
+ }
+ })
+}
+
+/// Loads or bootstraps the controller fabric. On first run this mints the
+/// RCAC (whose private key we keep, RCAC-direct mode), our own operational
+/// NOC, and the fabric IPK, then installs and persists the fabric. On later
+/// runs the fabric comes back via rs-matter's KV persistence and only the
+/// label is reconciled with the configuration.
+fn ensure_fabric<C: Crypto>(
+ config: &Config,
+ matter: &Matter<'static>,
+ crypto: &C,
+ kv: &impl rs_matter::persist::KvBlobStoreAccess,
+) -> anyhow::Result<(NonZeroU8, Identity)> {
+ let identity_file = identity_path(&config.storage_path);
+ let existing = matter.with_state(|state| state.fabrics.iter().map(|f| f.fab_idx()).next());
+
+ let registry: crate::state::NodeRegistry =
+ state::load_or_default(&state::registry_path(&config.storage_path));
+
+ if let Some(fab_idx) = existing {
+ match std::fs::read_to_string(&identity_file) {
+ Ok(raw) => {
+ let identity: Identity =
+ serde_json::from_str(&raw).context("parse identity.json")?;
+ reconcile_label(matter, crypto, kv, fab_idx, &config.fabric_label)?;
+ return Ok((fab_idx, identity));
+ }
+ // A fabric blob without identity.json is an interrupted first
+ // run: identity.json is written last, and commissioning requires
+ // a completed bootstrap. With an empty registry nothing can
+ // reference this fabric, so discard it and bootstrap cleanly.
+ // With a non-empty registry something is deeply wrong; never
+ // guess.
+ Err(_) if registry.nodes.is_empty() => {
+ log::warn!(
+ "Discarding fabric left by an interrupted first run (no identity.json, \
+ no commissioned devices); creating a fresh controller identity"
+ );
+ matter
+ .with_state(|state| state.fabrics.remove(fab_idx))
+ .map_err(|e| anyhow!("remove interrupted fabric: {e:?}"))?;
+ remove_persisted_fabric(kv, fab_idx)?;
+ }
+ Err(e) => {
+ bail!(
+ "cannot read {} but the node registry lists {} commissioned device(s); \
+ refusing to touch the fabric. Restore identity.json from backup. ({e})",
+ identity_file.display(),
+ registry.nodes.len()
+ );
+ }
+ }
+ }
+
+ // Refuse to mint a fresh identity over the remains of an old one. An
+ // identity.json without a loadable fabric means the KV storage was lost
+ // or damaged AFTER commissioning began; overwriting the RCAC key would
+ // permanently orphan every device commissioned to it. Deleting the whole
+ // storage directory (after decommissioning, or accepting the orphans) is
+ // a deliberate human act, never something a run does implicitly.
+ if identity_file.exists() {
+ bail!(
+ "{} exists but no fabric was loaded from {}; refusing to create a new controller \
+ identity over an old one. Restore the matter/ KV files from backup, or delete the \
+ whole storage directory to deliberately start over.",
+ identity_file.display(),
+ matter_kv_path(&config.storage_path).display()
+ );
+ }
+ if !registry.nodes.is_empty() {
+ bail!(
+ "the node registry lists {} commissioned device(s) but no fabric was loaded; \
+ refusing to create a new controller identity. Restore the storage directory from \
+ backup.",
+ registry.nodes.len()
+ );
+ }
+
+ log::info!("First run: creating the controller fabric (persistent identity)");
+ let mut identity = Identity::generate(crypto).map_err(|e| anyhow!("{e:?}"))?;
+
+ let mut rcac_buf = [0u8; MAX_CERT_TLV_AND_ASN1_LEN];
+ let mut rcac_gen = RcacGenerator::new(&mut rcac_buf);
+ let (rcac_priv, rcac) = rcac_gen
+ .generate(crypto, identity.fabric_id, VALID_FOREVER)
+ .map_err(|e| anyhow!("generate RCAC: {e:?}"))?;
+ identity.rcac_privkey_hex = hex_encode(rcac_priv.reference().access());
+
+ // Our own operational identity: keypair, CSR, NOC signed by the RCAC.
+ let controller_key = crypto
+ .generate_secret_key()
+ .map_err(|e| anyhow!("generate controller key: {e:?}"))?;
+ let mut csr_buf = [0u8; 256];
+ let csr = controller_key
+ .csr(&mut csr_buf)
+ .map_err(|e| anyhow!("controller CSR: {e:?}"))?;
+ let mut controller_key_canon = CanonPkcSecretKey::new();
+ controller_key
+ .write_canon(&mut controller_key_canon)
+ .map_err(|e| anyhow!("{e:?}"))?;
+
+ let mut noc_buf = [0u8; MAX_CERT_TLV_AND_ASN1_LEN];
+ let mut noc_generator = NocGenerator::create(rcac_priv.reference(), rcac, &[], &mut noc_buf)
+ .map_err(|e| anyhow!("NOC generator: {e:?}"))?;
+ let controller_noc = noc_generator
+ .generate(crypto, csr, identity.controller_node_id, &[], VALID_FOREVER)
+ .map_err(|e| anyhow!("controller NOC: {e:?}"))?;
+
+ let mut ipk = CanonAeadKey::new();
+ crypto
+ .rand()
+ .map_err(|e| anyhow!("{e:?}"))?
+ .fill_bytes(ipk.access_mut());
+
+ let fab_idx = matter
+ .with_state(|state| {
+ let fab_idx = state
+ .fabrics
+ .add(
+ crypto,
+ controller_key_canon.reference(),
+ rcac,
+ controller_noc,
+ &[],
+ Some(ipk.reference()),
+ TEST_VENDOR_ID,
+ identity.controller_node_id,
+ )?
+ .fab_idx();
+ let _ = state.fabrics.update_label(fab_idx, &config.fabric_label);
+ Ok::<_, MatterError>(fab_idx)
+ })
+ .map_err(|e| anyhow!("install controller fabric: {e:?}"))?;
+
+ persist_fabric(matter, kv, fab_idx)?;
+
+ // Identity file after the fabric: a crash in between leaves a fabric
+ // without identity.json, which the next run reports loudly rather than
+ // silently minting a second identity.
+ state::store(&identity_file, &identity).context("write identity.json")?;
+ log::info!(
+ "Controller fabric created and persisted (fabric id {}, controller node id {})",
+ identity.fabric_id,
+ identity.controller_node_id
+ );
+ Ok((fab_idx, identity))
+}
+
+/// CSA test vendor id: the correct value for an uncertified controller.
+const TEST_VENDOR_ID: u16 = 0xFFF1;
+
+fn reconcile_label<C: Crypto>(
+ matter: &Matter<'static>,
+ _crypto: &C,
+ kv: &impl rs_matter::persist::KvBlobStoreAccess,
+ fab_idx: NonZeroU8,
+ label: &str,
+) -> anyhow::Result<()> {
+ let changed = matter.with_state(|state| {
+ let current = state
+ .fabrics
+ .get(fab_idx)
+ .map(|f| f.label().to_string())
+ .unwrap_or_default();
+ if current == label {
+ return Ok::<_, MatterError>(false);
+ }
+ state.fabrics.update_label(fab_idx, label)?;
+ Ok(true)
+ });
+ match changed {
+ Ok(true) => persist_fabric(matter, kv, fab_idx),
+ Ok(false) => Ok(()),
+ Err(e) => Err(anyhow!("update local fabric label: {e:?}")),
+ }
+}
+
+fn remove_persisted_fabric(
+ kv: &impl rs_matter::persist::KvBlobStoreAccess,
+ fab_idx: NonZeroU8,
+) -> anyhow::Result<()> {
+ let mut persist = FabricPersist::new(kv);
+ persist
+ .remove(fab_idx)
+ .and_then(|()| persist.run())
+ .map_err(|e: MatterError| anyhow!("remove persisted fabric blob: {e:?}"))
+}
+
+fn persist_fabric(
+ matter: &Matter<'static>,
+ kv: &impl rs_matter::persist::KvBlobStoreAccess,
+ fab_idx: NonZeroU8,
+) -> anyhow::Result<()> {
+ matter
+ .with_state(|state| {
+ let fabric = state
+ .fabrics
+ .get(fab_idx)
+ .ok_or(rs_matter::error::ErrorCode::NotFound)?;
+ let mut persist = FabricPersist::new(kv);
+ persist.store(fabric)?;
+ persist.run()
+ })
+ .map_err(|e: MatterError| anyhow!("persist fabric: {e:?}"))
+}
+
+#[cfg(unix)]
+fn lock_storage(storage: &Path) -> anyhow::Result<std::fs::File> {
+ use std::os::fd::AsRawFd;
+ let path = storage.join(".lock");
+ let file = std::fs::File::create(&path).context("create storage lock file")?;
+ let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
+ if rc != 0 {
+ bail!(
+ "another mattertimesync instance is already running against {}",
+ storage.display()
+ );
+ }
+ Ok(file)
+}
+
+#[cfg(not(unix))]
+fn lock_storage(_storage: &Path) -> anyhow::Result<std::fs::File> {
+ anyhow::bail!("only unix hosts are supported")
+}
+
+#[cfg(unix)]
+fn restrict_dir_mode(path: &Path) -> anyhow::Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
+ .context("chmod storage directory")
+}
+
+#[cfg(not(unix))]
+fn restrict_dir_mode(_path: &Path) -> anyhow::Result<()> {
+ Ok(())
+}
+
+fn bind_mdns_socket() -> anyhow::Result<async_io::Async<UdpSocket>> {
+ use socket2::{Domain, Protocol, Socket, Type};
+ let socket = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?;
+ socket.set_reuse_address(true)?;
+ // macOS requires SO_REUSEPORT (not just SO_REUSEADDR) to share 5353
+ // with the system's own mDNSResponder; Linux is lenient either way.
+ #[cfg(unix)]
+ socket.set_reuse_port(true)?;
+ socket.set_only_v6(false)?;
+ socket.bind(&MDNS_SOCKET_DEFAULT_BIND_ADDR.into())?;
+ let socket = async_io::Async::<UdpSocket>::new_nonblocking(socket.into())?;
+
+ // IPv6 only, everywhere. Matter mandates IPv6 (Thread addresses are
+ // IPv6-only and every Matter device advertises over IPv6 mDNS), so the
+ // IPv4 side would add nothing but platform variance: macOS refuses IPv4
+ // operations on an IPv6 socket that Linux tolerates. Without the group
+ // join there is no IPv4 mDNS at all, quietly and identically on every
+ // host. A failed IPv6 join, by contrast, means discovery cannot work.
+ let (_ipv4, _ipv6, interface) = pick_interface()?;
+ socket
+ .get_ref()
+ .join_multicast_v6(&MDNS_IPV6_BROADCAST_ADDR, interface)
+ .context("join mDNS IPv6 multicast group")?;
+ Ok(socket)
+}
+
+/// Picks the LAN interface for mDNS: the first non-loopback interface with
+/// both an IPv6 address (preferring link-local) and an IPv4 address.
+fn pick_interface() -> anyhow::Result<(std::net::Ipv4Addr, std::net::Ipv6Addr, u32)> {
+ let all = if_addrs::get_if_addrs().context("enumerate network interfaces")?;
+ let candidate = |v6_filter: fn(std::net::Ipv6Addr) -> bool| {
+ all.iter()
+ .filter(|ia| !ia.is_loopback())
+ .filter_map(|ia| match ia.addr {
+ if_addrs::IfAddr::V6(ref v6) if v6_filter(v6.ip) => {
+ Some((ia.name.clone(), v6.ip, ia.index.unwrap_or(0)))
+ }
+ _ => None,
+ })
+ .find_map(|(name, ipv6, index)| {
+ all.iter()
+ .filter(|ia| ia.name == name)
+ .find_map(|ia| match ia.addr {
+ if_addrs::IfAddr::V4(ref v4) => Some((v4.ip, ipv6, index)),
+ _ => None,
+ })
+ })
+ };
+ candidate(|ip| ip.is_unicast_link_local())
+ .or_else(|| candidate(|_| true))
+ .ok_or_else(|| anyhow!("no network interface with IPv4 + IPv6 found for mDNS"))
+}
+
+/// Commissionable-browse filter for a manual pairing code.
+pub fn commissionable_filter(short_discriminator: u8) -> CommissionableFilter {
+ CommissionableFilter {
+ short_discriminator: Some(short_discriminator),
+ ..Default::default()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Operations
+// ---------------------------------------------------------------------------
+
+use rs_matter::dm::clusters::decl::basic_information::BasicInformationClient;
+use rs_matter::dm::clusters::decl::operational_credentials::OperationalCredentialsClient;
+use rs_matter::dm::clusters::decl::time_synchronization::{
+ GranularityEnum, TimeSourceEnum, TimeSynchronizationClient,
+};
+use rs_matter::dm::endpoints::ROOT_ENDPOINT_ID;
+use rs_matter::onboard::{CommissionOptions, Commissioner};
+use rs_matter::transport::exchange::Exchange;
+
+use jiff::Timestamp;
+
+use crate::pairing::Onboarding;
+use crate::state::{NodeInfo, NodeRegistry};
+
+const BROWSE_TIMEOUT_MS: u32 = 30_000;
+/// Per-phase bound for commissioning (PASE handshake + invokes, CASE + complete).
+const COMMISSION_TIMEOUT_SECS: u64 = 60;
+/// Bound for opening a CASE exchange to a commissioned node (mDNS resolve + handshake).
+const CONNECT_TIMEOUT_SECS: u64 = 30;
+
+/// Bounds a Matter operation that could otherwise hang (unreachable peer,
+/// swallowed packets). The timeout is part of the caller's log line so an
+/// operator watching a quiet log knows how long "waiting" can last.
+async fn with_timeout<T>(
+ what: &str,
+ secs: u64,
+ fut: impl Future<Output = Result<T, MatterError>>,
+) -> anyhow::Result<T> {
+ let mut fut = core::pin::pin!(fut);
+ let mut timer = core::pin::pin!(embassy_time::Timer::after(
+ embassy_time::Duration::from_secs(secs)
+ ));
+ match embassy_futures::select::select(&mut fut, &mut timer).await {
+ embassy_futures::select::Either::First(result) => {
+ result.map_err(|e| anyhow!("{what}: {e:?}"))
+ }
+ embassy_futures::select::Either::Second(()) => bail!("{what} timed out after {secs}s"),
+ }
+}
+
+pub struct CommissionOp {
+ pub onboarding: Onboarding,
+}
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CommissionOutcome {
+ pub node_id: crate::output::Id64,
+ pub fabric_id: crate::output::Id64,
+ pub vendor_name: Option<String>,
+ pub product_name: Option<String>,
+}
+
+impl Op for CommissionOp {
+ type Out = CommissionOutcome;
+
+ async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<CommissionOutcome> {
+ let matter = ctx.matter;
+ log::info!(
+ "Discovering commissionable device (_matterc._udp, short discriminator {}, timeout {}s)",
+ self.onboarding.short_discriminator,
+ BROWSE_TIMEOUT_MS / 1000
+ );
+ let (peer_addr, _instance) = matter
+ .transport()
+ .browse_commissionable(
+ &commissionable_filter(self.onboarding.short_discriminator),
+ &[],
+ BROWSE_TIMEOUT_MS,
+ )
+ .await
+ .map_err(|e| {
+ anyhow!("no commissionable device found (is the pairing window open?): {e:?}")
+ })?;
+ log::info!("Found commissionable device at {:?}", peer_addr);
+
+ let device_node_id = ctx.identity.next_device_node_id;
+ let rcac_privkey = ctx.identity.rcac_privkey()?;
+ let mut noc_buf = [0u8; MAX_CERT_TLV_AND_ASN1_LEN];
+ let mut noc_generator =
+ NocGenerator::new(matter, rcac_privkey.reference(), ctx.fab_idx, &mut noc_buf)
+ .map_err(|e| anyhow!("NOC generator from persisted identity: {e:?}"))?;
+
+ let mut commissioner_buf = [0u8; rs_matter::cert::MAX_CERT_TLV_LEN];
+ let mut commissioner = Commissioner::new(
+ matter,
+ ctx.crypto,
+ ctx.fab_idx,
+ &mut noc_generator,
+ &mut commissioner_buf,
+ );
+ let opts = CommissionOptions {
+ // Consumer devices carry vendor DACs we cannot verify without the
+ // DCL; matter.js accepted these the same way.
+ allow_test_attestation: true,
+ ..CommissionOptions::new()
+ };
+
+ log::info!(
+ "Commissioning as node {device_node_id} (PASE phase, timeout {COMMISSION_TIMEOUT_SECS}s)"
+ );
+ let phase1 = with_timeout(
+ "commissioning over PASE",
+ COMMISSION_TIMEOUT_SECS,
+ commissioner.commission(
+ peer_addr,
+ self.onboarding.passcode,
+ &opts,
+ device_node_id,
+ VALID_FOREVER,
+ ),
+ )
+ .await?;
+ log::info!("CASE phase: completing commissioning (timeout {COMMISSION_TIMEOUT_SECS}s)");
+ with_timeout(
+ "CommissioningComplete over CASE",
+ COMMISSION_TIMEOUT_SECS,
+ commissioner.complete_via_case(peer_addr, &phase1),
+ )
+ .await?;
+
+ // Cache the device identity for the local registry; best-effort.
+ let vendor_name = read_vendor_name(ctx, device_node_id).await.ok();
+ let product_name = read_product_name(ctx, device_node_id).await.ok();
+
+ let registry_file = state::registry_path(&ctx.config.storage_path);
+ let mut registry: NodeRegistry = state::load_or_default(&registry_file);
+ registry.nodes.insert(
+ device_node_id.to_string(),
+ NodeInfo {
+ vendor_name: vendor_name.clone(),
+ product_name: product_name.clone(),
+ },
+ );
+ state::store(&registry_file, &registry)?;
+
+ let mut identity = ctx.identity.clone();
+ identity.next_device_node_id += 1;
+ state::store(&identity_path(&ctx.config.storage_path), &identity)?;
+
+ state::update_node_state(
+ &state::service_state_path(&ctx.config.storage_path),
+ device_node_id,
+ |node| node.last_successful_connection = Some(Timestamp::now()),
+ )?;
+
+ Ok(CommissionOutcome {
+ node_id: device_node_id.into(),
+ fabric_id: ctx.identity.fabric_id.into(),
+ vendor_name,
+ product_name,
+ })
+ }
+}
+
+async fn read_vendor_name<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<String> {
+ let exchange = connect(ctx, node_id).await?;
+ let mut out = String::new();
+ exchange
+ .basic_information()
+ .vendor_name_read_with(ROOT_ENDPOINT_ID, |value| {
+ out = value?.to_string();
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read vendorName: {e:?}"))?
+ .map_err(|e| anyhow!("parse vendorName: {e:?}"))?;
+ Ok(out)
+}
+
+async fn read_product_name<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<String> {
+ let exchange = connect(ctx, node_id).await?;
+ let mut out = String::new();
+ exchange
+ .basic_information()
+ .product_name_read_with(ROOT_ENDPOINT_ID, |value| {
+ out = value?.to_string();
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read productName: {e:?}"))?
+ .map_err(|e| anyhow!("parse productName: {e:?}"))?;
+ Ok(out)
+}
+
+/// How many times to attempt an mDNS resolve + CASE connect. Resolve
+/// answers can be lost per-attempt (multicast loss, macOS socket sharing),
+/// so single-shot failure is not conclusive.
+const CONNECT_ATTEMPTS: u32 = 3;
+/// Pause between connect attempts. A failed CASE handshake can linger
+/// half-open on the device, which then answers an immediate re-knock with
+/// Busy; a couple of seconds lets it reap the stale session.
+const CONNECT_RETRY_DELAY_SECS: u64 = 2;
+
+/// Opens an exchange over CASE to a commissioned node (cached session when
+/// available, mDNS operational resolve otherwise). One exchange = one IM
+/// transaction, so every read/invoke starts here.
+async fn connect<'a, C: Crypto>(ctx: &Ctx<'a, C>, node_id: u64) -> anyhow::Result<Exchange<'a>> {
+ let mut last_error = None;
+ for attempt in 1..=CONNECT_ATTEMPTS {
+ match with_timeout(
+ &format!("reaching node {node_id} (offline or unresolvable?)"),
+ CONNECT_TIMEOUT_SECS,
+ Exchange::initiate(ctx.matter, ctx.crypto, ctx.fab_idx, node_id),
+ )
+ .await
+ {
+ Ok(exchange) => return Ok(exchange),
+ Err(error) => {
+ if attempt < CONNECT_ATTEMPTS {
+ log::warn!(
+ "Node {node_id}: connect attempt {attempt}/{CONNECT_ATTEMPTS} failed \
+ ({error:#}); retrying in {CONNECT_RETRY_DELAY_SECS}s"
+ );
+ embassy_time::Timer::after(embassy_time::Duration::from_secs(
+ CONNECT_RETRY_DELAY_SECS,
+ ))
+ .await;
+ }
+ last_error = Some(error);
+ }
+ }
+ }
+ Err(last_error.expect("at least one attempt ran"))
+}
+
+// --- sync -----------------------------------------------------------------
+
+use crate::time::{ClockAssessment, MatterMicros};
+use crate::tz::{build_dst_offset_list, build_time_zone_list};
+
+const VERIFY_TOLERANCE_MICROS: i64 = 5_000_000;
+const FEATURE_TIME_ZONE: u32 = 1 << 0;
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SyncOutcome {
+ pub node_id: crate::output::Id64,
+ pub success: bool,
+ /// True when the device has no Time Synchronization cluster: reported
+ /// with a warning, not counted as a failure, so a permanently
+ /// incompatible device cannot fail every timer run.
+ pub skipped: bool,
+ pub error: Option<String>,
+ /// Human assessment of the device clock before the write.
+ pub clock_before: Option<String>,
+ pub epoch_shifted: bool,
+ pub utc_time_written: Option<String>,
+ pub time_zone_written: Option<String>,
+ pub dst_entries_written: usize,
+ pub delta_after_micros: Option<i64>,
+ pub verified: bool,
+}
+
+pub struct SyncOp {
+ pub targets: Vec<u64>,
+ /// When set, write this instant instead of the current time: the
+ /// operator is deliberately setting an arbitrary wall clock.
+ pub manual_time: Option<MatterMicros>,
+}
+
+impl Op for SyncOp {
+ type Out = Vec<SyncOutcome>;
+
+ async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<Vec<SyncOutcome>> {
+ let state_path = state::service_state_path(&ctx.config.storage_path);
+ let mut outcomes = Vec::with_capacity(self.targets.len());
+ for node_id in self.targets {
+ state::update_node_state(&state_path, node_id, |n| {
+ n.last_attempted_sync = Some(Timestamp::now());
+ })?;
+ let outcome = match sync_one(ctx, node_id, self.manual_time).await {
+ Ok(outcome) => outcome,
+ Err(error) => SyncOutcome {
+ node_id: node_id.into(),
+ success: false,
+ skipped: false,
+ error: Some(format!("{error:#}")),
+ clock_before: None,
+ epoch_shifted: false,
+ utc_time_written: None,
+ time_zone_written: None,
+ dst_entries_written: 0,
+ delta_after_micros: None,
+ verified: false,
+ },
+ };
+ state::update_node_state(&state_path, node_id, |n| {
+ if outcome.success {
+ n.last_successful_sync = Some(Timestamp::now());
+ n.last_successful_connection = Some(Timestamp::now());
+ n.last_error = None;
+ } else {
+ n.last_error = outcome.error.clone();
+ }
+ })?;
+ if let Some(error) = &outcome.error {
+ log::error!("Node {node_id}: sync failed: {error}");
+ }
+ outcomes.push(outcome);
+ }
+ Ok(outcomes)
+ }
+}
+
+async fn sync_one<C: Crypto>(
+ ctx: &Ctx<'_, C>,
+ node_id: u64,
+ manual_time: Option<MatterMicros>,
+) -> anyhow::Result<SyncOutcome> {
+ let tz = ctx.config.time_zone();
+ let now_ts = Timestamp::now();
+
+ // Capability discovery first: a device without the cluster is skipped
+ // with a warning, and the TimeZone feature gates SetTimeZone/SetDSTOffset.
+ let feature_map = match connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .feature_map_read(ROOT_ENDPOINT_ID)
+ .await
+ {
+ Ok(map) => map,
+ Err(e) if e.code() == rs_matter::error::ErrorCode::ClusterNotFound => {
+ log::warn!("Node {node_id}: no Time Synchronization cluster; skipping");
+ return Ok(SyncOutcome {
+ node_id: node_id.into(),
+ success: false,
+ skipped: true,
+ error: Some("no Time Synchronization cluster".into()),
+ clock_before: None,
+ epoch_shifted: false,
+ utc_time_written: None,
+ time_zone_written: None,
+ dst_entries_written: 0,
+ delta_after_micros: None,
+ verified: false,
+ });
+ }
+ Err(e) => bail!("read featureMap: {e:?}"),
+ };
+ let has_time_zone = feature_map & FEATURE_TIME_ZONE != 0;
+
+ // Before: read the device clock live for the correction report.
+ let before = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .utc_time_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read utcTime: {e:?}"))?;
+ let device_before = before.into_option().map(MatterMicros);
+ let assessment = ClockAssessment::compare(device_before, MatterMicros::now());
+ log::info!("Node {node_id}: {assessment}");
+
+ // SetUTCTime, last-moment fresh. The host clock is NTP-disciplined and
+ // the timestamp microsecond-precise at send time; a device that already
+ // holds good time may reject a weaker claim (TimeNotAccepted).
+ let utc_write = manual_time.unwrap_or_else(MatterMicros::now);
+ connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .set_utc_time(ROOT_ENDPOINT_ID, |b| {
+ b.utc_time(utc_write.0)?
+ .granularity(GranularityEnum::MicrosecondsGranularity)?
+ .time_source(Some(TimeSourceEnum::NonMatterSNTP))?
+ .end()
+ })
+ .await
+ .map_err(|e| anyhow!("SetUTCTime rejected: {e:?}"))?;
+ log::info!("Node {node_id}: SetUTCTime {utc_write}");
+
+ let mut time_zone_written = None;
+ let mut dst_entries_written = 0;
+ if has_time_zone {
+ let zone_entries = build_time_zone_list(&tz, &ctx.config.timezone, now_ts);
+ let response = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .set_time_zone(ROOT_ENDPOINT_ID, |b| {
+ let mut list = b.time_zone()?;
+ for entry in &zone_entries {
+ list = list
+ .push()?
+ .offset(entry.offset_seconds)?
+ .valid_at(entry.valid_at.0)?
+ .name(Some(&entry.name))?
+ .end()?;
+ }
+ list.end()?.end()
+ })
+ .await
+ .map_err(|e| anyhow!("SetTimeZone rejected: {e:?}"))?;
+ let dst_required = response
+ .response()
+ .map(|r| r.dst_offset_required().unwrap_or(true))
+ .unwrap_or(true);
+ response
+ .complete()
+ .await
+ .map_err(|e| anyhow!("SetTimeZone completion: {e:?}"))?;
+ time_zone_written = Some(ctx.config.timezone.clone());
+ log::info!("Node {node_id}: SetTimeZone {}", ctx.config.timezone);
+
+ if dst_required {
+ let max_entries = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .dst_offset_list_max_size_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read dstOffsetListMaxSize: {e:?}"))?;
+ let dst_entries = build_dst_offset_list(&tz, usize::from(max_entries), now_ts);
+ connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .set_dst_offset(ROOT_ENDPOINT_ID, |b| {
+ let mut list = b.dst_offset()?;
+ for entry in &dst_entries {
+ list = list
+ .push()?
+ .offset(entry.offset_seconds)?
+ .valid_starting(entry.valid_starting.0)?
+ .valid_until(match entry.valid_until {
+ Some(until) => rs_matter::tlv::Nullable::some(until.0),
+ None => rs_matter::tlv::Nullable::none(),
+ })?
+ .end()?;
+ }
+ list.end()?.end()
+ })
+ .await
+ .map_err(|e| anyhow!("SetDSTOffset rejected: {e:?}"))?;
+ dst_entries_written = dst_entries.len();
+ log::info!("Node {node_id}: SetDSTOffset with {dst_entries_written} entries");
+ }
+ }
+
+ // Verify by reading the clock back.
+ let after = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .utc_time_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read-back utcTime: {e:?}"))?;
+ // Verify against what was WRITTEN (not against "now"): the question is
+ // whether the device accepted our value, which also makes verification
+ // correct when a manual time was set deliberately far from now.
+ let after_assessment =
+ ClockAssessment::compare(after.into_option().map(MatterMicros), utc_write);
+ let delta_after = after_assessment.effective_delta_micros();
+ let verified = delta_after.is_some_and(|d| d.abs() <= VERIFY_TOLERANCE_MICROS);
+ if !verified {
+ bail!("read-back verification failed: {after_assessment}");
+ }
+
+ ensure_fabric_label(ctx, node_id).await;
+
+ Ok(SyncOutcome {
+ node_id: node_id.into(),
+ success: true,
+ skipped: false,
+ error: None,
+ clock_before: Some(assessment.to_string()),
+ epoch_shifted: assessment.is_epoch_shifted(),
+ utc_time_written: Some(utc_write.to_string()),
+ time_zone_written,
+ dst_entries_written,
+ delta_after_micros: delta_after,
+ verified,
+ })
+}
+
+/// Pushes the configured fabric label to the device when it differs from the
+/// stored one. Best-effort: a cosmetic label must not fail a clock sync.
+/// Labels are unique per device, so a conflict with another admin's label is
+/// logged with guidance rather than retried.
+async fn ensure_fabric_label<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) {
+ if let Err(error) = try_ensure_fabric_label(ctx, node_id).await {
+ log::warn!("Node {node_id}: could not update fabric label: {error:#}");
+ }
+}
+
+async fn try_ensure_fabric_label<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<()> {
+ let wanted = ctx.config.fabric_label.clone();
+ let mut current: Option<String> = None;
+ connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .fabrics_read_with(ROOT_ENDPOINT_ID, |reader| {
+ // Fabric-filtered read: the device returns only our own entry.
+ for item in reader? {
+ current = Some(item?.label()?.to_string());
+ }
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read fabrics: {e:?}"))?
+ .map_err(|e| anyhow!("parse fabrics: {e:?}"))?;
+
+ if current.as_deref() == Some(wanted.as_str()) {
+ return Ok(());
+ }
+ let response = connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .update_fabric_label(ROOT_ENDPOINT_ID, |b| b.label(&wanted)?.end())
+ .await
+ .map_err(|e| anyhow!("UpdateFabricLabel: {e:?}"))?;
+ let status = response.response().map(|r| r.status_code());
+ response
+ .complete()
+ .await
+ .map_err(|e| anyhow!("UpdateFabricLabel completion: {e:?}"))?;
+ log::info!("Node {node_id}: fabric label updated to {wanted:?} (status {status:?})");
+ Ok(())
+}
+
+// --- decommission ----------------------------------------------------------
+
+pub struct DecommissionOp {
+ pub targets: Vec<u64>,
+}
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DecommissionOutcome {
+ pub node_id: crate::output::Id64,
+ pub success: bool,
+ pub error: Option<String>,
+}
+
+impl Op for DecommissionOp {
+ type Out = Vec<DecommissionOutcome>;
+
+ async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<Vec<DecommissionOutcome>> {
+ let mut outcomes = Vec::with_capacity(self.targets.len());
+ for node_id in self.targets {
+ let result = decommission_one(ctx, node_id).await;
+ match result {
+ Ok(()) => {
+ let registry_file = state::registry_path(&ctx.config.storage_path);
+ let mut registry: NodeRegistry = state::load_or_default(&registry_file);
+ registry.nodes.remove(&node_id.to_string());
+ state::store(&registry_file, &registry)?;
+ state::remove_node_state(
+ &state::service_state_path(&ctx.config.storage_path),
+ node_id,
+ )?;
+ outcomes.push(DecommissionOutcome {
+ node_id: node_id.into(),
+ success: true,
+ error: None,
+ });
+ }
+ Err(error) => {
+ log::error!("Node {node_id}: decommission failed: {error:#}");
+ outcomes.push(DecommissionOutcome {
+ node_id: node_id.into(),
+ success: false,
+ error: Some(format!("{error:#}")),
+ });
+ }
+ }
+ }
+ Ok(outcomes)
+ }
+}
+
+/// The device drops this controller's fabric via RemoveFabric on our own
+/// entry (found through a fabric-filtered read, so no other admin's entry
+/// can even be addressed), while staying paired to its primary ecosystem.
+async fn decommission_one<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<()> {
+ let mut our_index: Option<u8> = None;
+ connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .fabrics_read_with(ROOT_ENDPOINT_ID, |reader| {
+ for item in reader? {
+ our_index = item?.fabric_index()?;
+ }
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read fabrics: {e:?}"))?
+ .map_err(|e| anyhow!("parse fabrics: {e:?}"))?;
+ let our_index = our_index.ok_or_else(|| anyhow!("device has no entry for our fabric"))?;
+
+ log::info!(
+ "Decommissioning: removing our fabric (device index {our_index}) from node {node_id}"
+ );
+ let response = connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .remove_fabric(ROOT_ENDPOINT_ID, |b| b.fabric_index(our_index)?.end())
+ .await
+ .map_err(|e| anyhow!("RemoveFabric: {e:?}"))?;
+ let status = response.response().map(|r| r.status_code());
+ response
+ .complete()
+ .await
+ .map_err(|e| anyhow!("RemoveFabric completion: {e:?}"))?;
+ log::info!("Node {node_id}: fabric removed (status {status:?})");
+ Ok(())
+}
+
+// --- inspect ---------------------------------------------------------------
+
+pub struct InspectOp {
+ pub targets: Vec<u64>,
+}
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct InspectOutcome {
+ pub node_id: crate::output::Id64,
+ pub error: Option<String>,
+ pub vendor_name: Option<String>,
+ pub product_name: Option<String>,
+ pub time_sync: Option<TimeSyncCaps>,
+ pub our_fabric_label: Option<String>,
+ pub our_fabric_index: Option<u8>,
+}
+
+#[derive(Debug, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TimeSyncCaps {
+ pub feature_map: u32,
+ pub time_zone_feature: bool,
+ pub utc_time: Option<String>,
+ pub granularity: u8,
+ pub dst_offset_list_max_size: u8,
+}
+
+impl Op for InspectOp {
+ type Out = Vec<InspectOutcome>;
+
+ async fn run<C: Crypto>(self, ctx: &Ctx<'_, C>) -> anyhow::Result<Vec<InspectOutcome>> {
+ let mut outcomes = Vec::with_capacity(self.targets.len());
+ for node_id in self.targets {
+ match inspect_one(ctx, node_id).await {
+ Ok(outcome) => outcomes.push(outcome),
+ Err(error) => {
+ log::error!("Node {node_id}: inspect failed: {error:#}");
+ outcomes.push(InspectOutcome {
+ node_id: node_id.into(),
+ error: Some(format!("{error:#}")),
+ vendor_name: None,
+ product_name: None,
+ time_sync: None,
+ our_fabric_label: None,
+ our_fabric_index: None,
+ });
+ }
+ }
+ }
+ Ok(outcomes)
+ }
+}
+
+async fn inspect_one<C: Crypto>(ctx: &Ctx<'_, C>, node_id: u64) -> anyhow::Result<InspectOutcome> {
+ let vendor_name = read_vendor_name(ctx, node_id).await.ok();
+ let product_name = read_product_name(ctx, node_id).await.ok();
+
+ let feature_map = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .feature_map_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read featureMap (device may lack Time Synchronization): {e:?}"))?;
+ let utc_time = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .utc_time_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read utcTime: {e:?}"))?
+ .into_option()
+ .map(|v| MatterMicros(v).to_string());
+ let granularity = connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .granularity_read(ROOT_ENDPOINT_ID)
+ .await
+ .map_err(|e| anyhow!("read granularity: {e:?}"))? as u8;
+ let has_tz = feature_map & FEATURE_TIME_ZONE != 0;
+ let dst_max = if has_tz {
+ connect(ctx, node_id)
+ .await?
+ .time_synchronization()
+ .dst_offset_list_max_size_read(ROOT_ENDPOINT_ID)
+ .await
+ .unwrap_or(1)
+ } else {
+ 0
+ };
+
+ let mut label = None;
+ let mut index = None;
+ connect(ctx, node_id)
+ .await?
+ .operational_credentials()
+ .fabrics_read_with(ROOT_ENDPOINT_ID, |reader| {
+ for item in reader? {
+ let item = item?;
+ label = Some(item.label()?.to_string());
+ index = item.fabric_index()?;
+ }
+ Ok::<_, MatterError>(())
+ })
+ .await
+ .map_err(|e| anyhow!("read fabrics: {e:?}"))?
+ .map_err(|e| anyhow!("parse fabrics: {e:?}"))?;
+
+ Ok(InspectOutcome {
+ node_id: node_id.into(),
+ error: None,
+ vendor_name,
+ product_name,
+ time_sync: Some(TimeSyncCaps {
+ feature_map,
+ time_zone_feature: has_tz,
+ utc_time,
+ granularity,
+ dst_offset_list_max_size: dst_max,
+ }),
+ our_fabric_label: label,
+ our_fabric_index: index,
+ })
+}
diff --git a/src/host.rs b/src/host.rs
new file mode 100644
index 0000000..feb5a34
--- /dev/null
+++ b/src/host.rs
@@ -0,0 +1,88 @@
+//! Host clock trust: the device must never be set from a clock we do not
+//! trust.
+//!
+//! The primary check measures the actual clock error with a single SNTP
+//! query (RFC 4330) and accepts the host when the offset is under a second.
+//! That is a direct measurement, platform-independent, and stronger than
+//! asking the OS whether it believes it is synchronized. When no NTP server
+//! is reachable, systemd-timesyncd's verdict (`timedatectl`, Linux) is the
+//! fallback; hosts with neither fail safe.
+
+use std::net::UdpSocket;
+use std::process::Command;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+const NTP_SERVERS: [&str; 2] = ["time.apple.com:123", "pool.ntp.org:123"];
+/// Accept the host clock when it is within this many seconds of NTP time.
+/// Well inside the 5s sync read-back tolerance, far above network jitter.
+const MAX_OFFSET_SECONDS: f64 = 1.0;
+/// Seconds between the NTP era (1900) and the Unix epoch (1970).
+const NTP_UNIX_OFFSET: f64 = 2_208_988_800.0;
+/// The NTP fraction field is 32-bit fixed-point in units of 1/2^32 seconds;
+/// dividing by 2^32 converts it to seconds.
+const NTP_FRACTION_SCALE: f64 = (1u64 << 32) as f64;
+
+pub fn clock_is_ntp_synchronized() -> bool {
+ for server in NTP_SERVERS {
+ if let Some(offset) = sntp_offset(server) {
+ let ok = offset.abs() <= MAX_OFFSET_SECONDS;
+ if ok {
+ log::debug!("Host clock is {offset:+.3}s from {server}; trusted");
+ } else {
+ log::warn!("Host clock is {offset:+.3}s from {server}; not trusted");
+ }
+ return ok;
+ }
+ }
+ log::debug!("No NTP server reachable; falling back to timedatectl");
+ timedatectl_says_synchronized()
+}
+
+/// One SNTP client exchange: returns the approximate offset of the local
+/// clock relative to the server (positive = local clock ahead). Uses the
+/// request/response midpoint, so the error is bounded by half the round
+/// trip, which is milliseconds against a threshold of a second.
+fn sntp_offset(server: &str) -> Option<f64> {
+ let socket = UdpSocket::bind(("0.0.0.0", 0)).ok()?;
+ socket.set_read_timeout(Some(Duration::from_secs(2))).ok()?;
+ socket.connect(server).ok()?;
+
+ let mut request = [0u8; 48];
+ request[0] = 0b00_100_011; // LI 0, version 4, mode 3 (client)
+ let sent_at = unix_now();
+ socket.send(&request).ok()?;
+
+ let mut response = [0u8; 48];
+ let len = socket.recv(&mut response).ok()?;
+ let received_at = unix_now();
+ if len < 48 || response[0] & 0x07 != 4 {
+ // Not a server-mode reply.
+ return None;
+ }
+
+ // Transmit timestamp: seconds since 1900 plus a 32-bit binary fraction.
+ let seconds = u32::from_be_bytes(response[40..44].try_into().ok()?) as f64;
+ let fraction =
+ u32::from_be_bytes(response[44..48].try_into().ok()?) as f64 / NTP_FRACTION_SCALE;
+ let server_time = seconds + fraction - NTP_UNIX_OFFSET;
+ if server_time <= 0.0 {
+ return None;
+ }
+ Some((sent_at + received_at) / 2.0 - server_time)
+}
+
+fn unix_now() -> f64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs_f64())
+ .unwrap_or(0.0)
+}
+
+/// systemd-timesyncd's opinion; false on hosts without timedatectl.
+fn timedatectl_says_synchronized() -> bool {
+ Command::new("timedatectl")
+ .args(["show", "-p", "NTPSynchronized", "--value"])
+ .output()
+ .map(|output| output.status.success() && output.stdout.trim_ascii() == b"yes")
+ .unwrap_or(false)
+}
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(),
+ })
+}
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(", ")
+ );
+ }
+}
diff --git a/src/pairing.rs b/src/pairing.rs
new file mode 100644
index 0000000..715d7a8
--- /dev/null
+++ b/src/pairing.rs
@@ -0,0 +1,129 @@
+//! Manual pairing code decoding (Matter Core spec 5.1.4.1).
+//!
+//! An 11-digit (no VID/PID) or 21-digit (with VID/PID) decimal code packs a
+//! 4-bit short discriminator and the 27-bit setup passcode, guarded by a
+//! trailing Verhoeff check digit. QR payloads ("MT:...") are not accepted;
+//! the primary ecosystem always shows the numeric code.
+
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum PairingCodeError {
+ #[error(
+ "QR payloads are not supported; enter the numeric pairing code shown by the primary ecosystem"
+ )]
+ QrPayload,
+ #[error("pairing code must be 11 or 21 digits (got {0})")]
+ BadLength(usize),
+ #[error("pairing code check digit is wrong; re-check the digits")]
+ BadCheckDigit,
+ #[error("pairing code decodes to an invalid setup passcode")]
+ BadPasscode,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Onboarding {
+ pub passcode: u32,
+ /// Upper 4 bits of the 12-bit discriminator, as carried in manual codes.
+ pub short_discriminator: u8,
+}
+
+/// Setup passcodes the spec forbids (trivial/sequential values).
+const INVALID_PASSCODES: [u32; 12] = [
+ 0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888, 99999999,
+ 12345678, 87654321,
+];
+
+pub fn parse_manual_code(raw: &str) -> Result<Onboarding, PairingCodeError> {
+ let trimmed = raw.trim();
+ if trimmed.starts_with("MT:") {
+ return Err(PairingCodeError::QrPayload);
+ }
+ let digits: Vec<u8> = trimmed
+ .chars()
+ .filter(|c| !c.is_whitespace() && *c != '-')
+ .map(|c| {
+ c.to_digit(10)
+ .map(|d| d as u8)
+ .ok_or(PairingCodeError::BadLength(0))
+ })
+ .collect::<Result<_, _>>()
+ .map_err(|_| PairingCodeError::BadLength(trimmed.len()))?;
+
+ if digits.len() != 11 && digits.len() != 21 {
+ return Err(PairingCodeError::BadLength(digits.len()));
+ }
+
+ let payload: String = digits[..digits.len() - 1]
+ .iter()
+ .map(|d| (b'0' + d) as char)
+ .collect();
+ if verhoeff::calculate(payload.as_str()) != digits[digits.len() - 1] {
+ return Err(PairingCodeError::BadCheckDigit);
+ }
+
+ let d1 = u32::from(digits[0]);
+ let chunk2 = digits[1..6]
+ .iter()
+ .fold(0u32, |acc, &d| acc * 10 + u32::from(d));
+ let chunk3 = digits[6..10]
+ .iter()
+ .fold(0u32, |acc, &d| acc * 10 + u32::from(d));
+
+ // digit 1 = (VID_PID_PRESENT << 2) | (discriminator >> 10)
+ // digits 2-6 = ((discriminator & 0x300) << 6) | (passcode & 0x3FFF)
+ // digits 7-10 = passcode >> 14
+ let short_discriminator = (((d1 & 0x3) << 2) | ((chunk2 >> 14) & 0x3)) as u8;
+ let passcode = (chunk3 << 14) | (chunk2 & 0x3FFF);
+
+ if passcode >= 1 << 27 || INVALID_PASSCODES.contains(&passcode) {
+ return Err(PairingCodeError::BadPasscode);
+ }
+ Ok(Onboarding {
+ passcode,
+ short_discriminator,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn decodes_the_canonical_test_code() {
+ // The Matter SDK test onboarding payload: passcode 20202021,
+ // discriminator 3840 (0xF00) => short discriminator 15, manual code
+ // 34970112332.
+ let onboarding = parse_manual_code("34970112332").unwrap();
+ assert_eq!(
+ onboarding,
+ Onboarding {
+ passcode: 20202021,
+ short_discriminator: 15,
+ }
+ );
+ }
+
+ #[test]
+ fn accepts_hyphenated_input() {
+ assert!(parse_manual_code("3497-011-2332").is_ok());
+ }
+
+ #[test]
+ fn rejects_qr_payloads_with_guidance() {
+ assert_eq!(
+ parse_manual_code("MT:Y.K90SO527JA0648G00"),
+ Err(PairingCodeError::QrPayload)
+ );
+ }
+
+ #[test]
+ fn rejects_wrong_lengths_and_check_digits() {
+ assert!(matches!(
+ parse_manual_code("1234"),
+ Err(PairingCodeError::BadLength(_))
+ ));
+ assert_eq!(
+ parse_manual_code("34970112331"),
+ Err(PairingCodeError::BadCheckDigit)
+ );
+ }
+}
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<Timestamp>,
+ pub last_successful_sync: Option<Timestamp>,
+ pub last_attempted_sync: Option<Timestamp>,
+ pub last_error: Option<String>,
+}
+
+#[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<String, NodeState>,
+}
+
+/// 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<String>,
+ pub product_name: Option<String>,
+}
+
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct NodeRegistry {
+ pub nodes: BTreeMap<String, NodeInfo>,
+}
+
+impl NodeRegistry {
+ pub fn node_ids(&self) -> impl Iterator<Item = u64> + '_ {
+ // 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<T: DeserializeOwned + 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<T: Serialize>(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<_>>(), vec![1]);
+ }
+}
diff --git a/src/time.rs b/src/time.rs
new file mode 100644
index 0000000..bedc1fa
--- /dev/null
+++ b/src/time.rs
@@ -0,0 +1,367 @@
+//! Matter epoch conversion and device clock assessment.
+//!
+//! Matter UTC time is microseconds since 2000-01-01T00:00:00Z (the "Matter
+//! epoch"), not the Unix epoch. `jiff::Timestamp` is the lingua franca
+//! everywhere else in this program; [`MatterMicros`] exists only at the wire
+//! boundary. Unlike matter.js, rs-matter passes spec values through
+//! unconverted, so no Unix-epoch API shift exists here.
+
+use std::fmt;
+
+use jiff::Timestamp;
+
+pub const MATTER_EPOCH_UNIX_SECONDS: i64 = 946_684_800;
+const MATTER_EPOCH_UNIX_MICROS: i64 = MATTER_EPOCH_UNIX_SECONDS * 1_000_000;
+
+/// Microseconds since the Matter epoch, as carried in Time Synchronization
+/// cluster attributes and commands.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct MatterMicros(pub u64);
+
+impl MatterMicros {
+ pub fn now() -> Self {
+ Self::from_timestamp(Timestamp::now())
+ }
+
+ /// Panics on pre-2000 timestamps, which cannot be represented on the
+ /// wire and cannot arise from a running host clock we already required
+ /// to be NTP-synchronized.
+ pub fn from_timestamp(at: Timestamp) -> Self {
+ let micros = at.as_microsecond() - MATTER_EPOCH_UNIX_MICROS;
+ Self(u64::try_from(micros).expect("timestamp precedes the Matter epoch"))
+ }
+
+ pub fn to_timestamp(self) -> Timestamp {
+ Timestamp::from_microsecond(self.0 as i64 + MATTER_EPOCH_UNIX_MICROS)
+ .expect("Matter timestamp out of jiff range")
+ }
+
+ /// Signed delta `self - other` in microseconds.
+ pub fn delta_micros(self, other: Self) -> i64 {
+ self.0 as i64 - other.0 as i64
+ }
+}
+
+impl fmt::Display for MatterMicros {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.to_timestamp().fmt(f)
+ }
+}
+
+/// A device is treated as epoch-confused when its clock delta lands within
+/// this window of the exact Unix/Matter epoch distance: firmware that encodes
+/// Unix-epoch values on the wire reads as ~30 years ahead after decoding. A
+/// week comfortably covers any real drift while remaining astronomically far
+/// from every honest delta.
+const EPOCH_SHIFT_DETECTION_WINDOW_MICROS: i64 = 7 * 86_400 * 1_000_000;
+
+/// How the device's reported clock relates to the host clock.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ClockAssessment {
+ /// The device lost its clock (null utcTime, e.g. after a power outage).
+ Unset,
+ /// The device reported a time `delta_micros` away from the host's.
+ Offset {
+ /// Raw reported delta (device minus host), exact.
+ delta_micros: i64,
+ /// True when the delta is the Unix/Matter epoch distance: the device
+ /// firmware encodes the wrong epoch on the wire (off-spec).
+ epoch_shifted: bool,
+ },
+}
+
+impl ClockAssessment {
+ pub fn compare(device: Option<MatterMicros>, host: MatterMicros) -> Self {
+ let Some(device) = device else {
+ return Self::Unset;
+ };
+ let delta_micros = device.delta_micros(host);
+ let shift_error = delta_micros - MATTER_EPOCH_UNIX_MICROS;
+ Self::Offset {
+ delta_micros,
+ epoch_shifted: shift_error.abs() <= EPOCH_SHIFT_DETECTION_WINDOW_MICROS,
+ }
+ }
+
+ /// The device's real clock error: the raw delta with any detected epoch
+ /// shift folded out. `None` when the clock was unset.
+ pub fn effective_delta_micros(&self) -> Option<i64> {
+ match *self {
+ Self::Unset => None,
+ Self::Offset {
+ delta_micros,
+ epoch_shifted,
+ } => Some(if epoch_shifted {
+ delta_micros - MATTER_EPOCH_UNIX_MICROS
+ } else {
+ delta_micros
+ }),
+ }
+ }
+
+ pub fn is_epoch_shifted(&self) -> bool {
+ matches!(
+ self,
+ Self::Offset {
+ epoch_shifted: true,
+ ..
+ }
+ )
+ }
+}
+
+impl fmt::Display for ClockAssessment {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Unset => f.write_str("device clock was unset"),
+ Self::Offset { epoch_shifted, .. } => {
+ let effective = self
+ .effective_delta_micros()
+ .expect("Offset always has a delta");
+ if *epoch_shifted {
+ write!(
+ f,
+ "device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was {}",
+ DescribeDelta(effective)
+ )
+ } else {
+ write!(f, "device clock was {}", DescribeDelta(effective))
+ }
+ }
+ }
+ }
+}
+
+/// "within 1s of host time (412ms behind)" / "1m 23s ahead" for a signed delta.
+struct DescribeDelta(i64);
+
+impl fmt::Display for DescribeDelta {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let magnitude = self.0.unsigned_abs();
+ let direction = if self.0 < 0 { "behind" } else { "ahead" };
+ if magnitude < 1_000_000 {
+ write!(
+ f,
+ "within 1s of host time ({} {direction})",
+ CompactDuration(magnitude)
+ )
+ } else {
+ write!(f, "{} {direction}", CompactDuration(magnitude))
+ }
+ }
+}
+
+/// Compact human duration: 412ms, 3.2s, 1m 23s, 1d 1h 1m.
+pub struct CompactDuration(pub u64);
+
+impl fmt::Display for CompactDuration {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let micros = self.0;
+ if micros < 1_000 {
+ return write!(f, "{micros}us");
+ }
+ if micros < 1_000_000 {
+ return write!(f, "{}ms", micros / 1_000);
+ }
+ let total_seconds = micros / 1_000_000;
+ if total_seconds < 60 {
+ let tenths = (micros % 1_000_000) / 100_000;
+ return if tenths == 0 {
+ write!(f, "{total_seconds}s")
+ } else {
+ write!(f, "{total_seconds}.{tenths}s")
+ };
+ }
+ let days = total_seconds / 86_400;
+ let hours = (total_seconds % 86_400) / 3_600;
+ let minutes = (total_seconds % 3_600) / 60;
+ let seconds = total_seconds % 60;
+ let mut parts: Vec<String> = Vec::new();
+ if days > 0 {
+ parts.push(format!("{days}d"));
+ }
+ if hours > 0 {
+ parts.push(format!("{hours}h"));
+ }
+ if minutes > 0 {
+ parts.push(format!("{minutes}m"));
+ }
+ if seconds > 0 && days == 0 {
+ parts.push(format!("{seconds}s"));
+ }
+ f.write_str(&parts.join(" "))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn ts(s: &str) -> Timestamp {
+ s.parse().unwrap()
+ }
+
+ #[test]
+ fn matter_epoch_zero_is_year_2000() {
+ assert_eq!(
+ MatterMicros::from_timestamp(ts("2000-01-01T00:00:00Z")).0,
+ 0
+ );
+ assert_eq!(MatterMicros(0).to_timestamp(), ts("2000-01-01T00:00:00Z"));
+ assert_eq!(
+ MatterMicros(1_000_000).to_timestamp(),
+ ts("2000-01-01T00:00:01Z")
+ );
+ }
+
+ #[test]
+ fn conversion_round_trips_current_dates() {
+ let now = ts("2026-07-26T20:00:00.123456Z");
+ assert_eq!(MatterMicros::from_timestamp(now).to_timestamp(), now);
+ }
+
+ #[test]
+ fn compact_duration_scales_units() {
+ let cases = [
+ (0, "0us"),
+ (999, "999us"),
+ (412_000, "412ms"),
+ (3_200_000, "3.2s"),
+ (59_000_000, "59s"),
+ (125_000_000, "2m 5s"),
+ (3_840_000_000, "1h 4m"),
+ (90_061_000_000, "1d 1h 1m"),
+ ];
+ for (micros, expected) in cases {
+ assert_eq!(CompactDuration(micros).to_string(), expected);
+ }
+ }
+
+ #[test]
+ fn assessment_reports_unset_clock() {
+ let host = MatterMicros::from_timestamp(ts("2026-07-26T20:00:00Z"));
+ let assessment = ClockAssessment::compare(None, host);
+ assert_eq!(assessment, ClockAssessment::Unset);
+ assert_eq!(assessment.to_string(), "device clock was unset");
+ }
+
+ #[test]
+ fn assessment_reports_direction_and_magnitude() {
+ let host = MatterMicros::from_timestamp(ts("2026-07-26T20:00:00Z"));
+ let behind = ClockAssessment::compare(Some(MatterMicros(host.0 - 83_000_000)), host);
+ assert_eq!(behind.to_string(), "device clock was 1m 23s behind");
+ assert_eq!(behind.effective_delta_micros(), Some(-83_000_000));
+
+ let ahead = ClockAssessment::compare(Some(MatterMicros(host.0 + 5_500_000)), host);
+ assert_eq!(ahead.to_string(), "device clock was 5.5s ahead");
+
+ let close = ClockAssessment::compare(Some(MatterMicros(host.0 - 412_000)), host);
+ assert_eq!(
+ close.to_string(),
+ "device clock was within 1s of host time (412ms behind)"
+ );
+ }
+
+ #[test]
+ fn assessment_detects_wrong_epoch_encoding() {
+ let host = MatterMicros::from_timestamp(ts("2026-07-26T20:00:00Z"));
+ let shift = (MATTER_EPOCH_UNIX_SECONDS * 1_000_000) as u64;
+ let wrong = ClockAssessment::compare(Some(MatterMicros(host.0 + shift + 1_400_000)), host);
+ assert!(wrong.is_epoch_shifted());
+ assert_eq!(wrong.effective_delta_micros(), Some(1_400_000));
+ assert_eq!(
+ wrong.to_string(),
+ "device encodes Unix-epoch time on the wire (off-spec); corrected, its clock was 1.4s ahead"
+ );
+ }
+
+ #[test]
+ fn assessment_does_not_fold_out_ordinary_large_errors() {
+ let host = MatterMicros::from_timestamp(ts("2026-07-26T20:00:00Z"));
+ let shift = (MATTER_EPOCH_UNIX_SECONDS * 1_000_000) as u64;
+ let month = 30 * 86_400 * 1_000_000;
+ let broken = ClockAssessment::compare(Some(MatterMicros(host.0 + shift + month)), host);
+ assert!(!broken.is_epoch_shifted());
+ }
+}
+
+/// Parses an operator-supplied wall-clock time: "16:35", "4:35p", "4:35pm",
+/// each with an optional ":ss". Meridiem suffixes imply 12-hour form.
+pub fn parse_wall_clock(input: &str) -> Result<jiff::civil::Time, String> {
+ let lowered = input.trim().to_ascii_lowercase();
+ let (digits, meridiem) = if let Some(rest) = lowered
+ .strip_suffix("am")
+ .or_else(|| lowered.strip_suffix('a'))
+ {
+ (rest.trim_end(), Some(false))
+ } else if let Some(rest) = lowered
+ .strip_suffix("pm")
+ .or_else(|| lowered.strip_suffix('p'))
+ {
+ (rest.trim_end(), Some(true))
+ } else {
+ (lowered.as_str(), None)
+ };
+
+ let parts: Vec<&str> = digits.split(':').collect();
+ if !(2..=3).contains(&parts.len()) {
+ return Err(format!(
+ "cannot parse {input:?} as a time (expected HH:MM or HH:MM:SS, optionally with am/pm)"
+ ));
+ }
+ let numbers: Vec<u8> = parts
+ .iter()
+ .map(|p| {
+ p.parse()
+ .map_err(|_| format!("cannot parse {p:?} in {input:?} as a number"))
+ })
+ .collect::<Result<_, _>>()?;
+ let (mut hour, minute, second) = (numbers[0], numbers[1], *numbers.get(2).unwrap_or(&0));
+
+ match meridiem {
+ Some(pm) => {
+ if !(1..=12).contains(&hour) {
+ return Err(format!("hour in {input:?} must be 1-12 with am/pm"));
+ }
+ hour = if pm { hour % 12 + 12 } else { hour % 12 };
+ }
+ None if hour > 23 => return Err(format!("hour in {input:?} must be 0-23")),
+ None => {}
+ }
+ jiff::civil::Time::new(hour as i8, minute as i8, second as i8, 0)
+ .map_err(|e| format!("invalid time {input:?}: {e}"))
+}
+
+#[cfg(test)]
+mod wall_clock_tests {
+ use super::parse_wall_clock;
+
+ #[test]
+ fn parses_12_and_24_hour_forms() {
+ let cases = [
+ ("16:35", (16, 35, 0)),
+ ("4:35p", (16, 35, 0)),
+ ("4:35pm", (16, 35, 0)),
+ ("4:35a", (4, 35, 0)),
+ ("12:00am", (0, 0, 0)),
+ ("12:15PM", (12, 15, 0)),
+ ("16:35:20", (16, 35, 20)),
+ ("07:05", (7, 5, 0)),
+ ];
+ for (input, (h, m, s)) in cases {
+ let time = parse_wall_clock(input).unwrap();
+ assert_eq!(
+ (time.hour(), time.minute(), time.second()),
+ (h, m, s),
+ "{input}"
+ );
+ }
+ }
+
+ #[test]
+ fn rejects_nonsense() {
+ for input in ["25:00", "13:00pm", "4", "4:60", "0:00am", "banana"] {
+ assert!(parse_wall_clock(input).is_err(), "accepted {input:?}");
+ }
+ }
+}
diff --git a/src/tz.rs b/src/tz.rs
new file mode 100644
index 0000000..2820c59
--- /dev/null
+++ b/src/tz.rs
@@ -0,0 +1,268 @@
+//! IANA time-zone helpers: Matter TimeZone/DSTOffset structure generation
+//! and offset introspection for status output.
+//!
+//! jiff exposes the real tzdb transition table, so upcoming DST changes are
+//! read directly instead of probed for (the TypeScript implementation had to
+//! binary-search ICU offsets; here `TimeZone::following` is exact by
+//! construction).
+
+use jiff::Timestamp;
+use jiff::tz::TimeZone;
+
+use crate::time::MatterMicros;
+
+/// Matter TimeZoneStruct: the zone's standard offset, DST carried separately.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MatterTimeZoneEntry {
+ /// Standard (non-DST) UTC offset in seconds.
+ pub offset_seconds: i32,
+ /// Matter-epoch microseconds at which the entry takes effect; 0 = always.
+ pub valid_at: MatterMicros,
+ pub name: String,
+}
+
+/// Matter DSTOffsetStruct: one DST period, added on top of the TimeZone offset.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MatterDstOffsetEntry {
+ pub offset_seconds: i32,
+ pub valid_starting: MatterMicros,
+ /// None = valid until further notice (last entry only).
+ pub valid_until: Option<MatterMicros>,
+}
+
+/// The next instant the zone's UTC offset changes, with the offsets on both
+/// sides. None for fixed-offset zones.
+pub fn next_offset_transition(tz: &TimeZone, from: Timestamp) -> Option<(Timestamp, i32, i32)> {
+ let before = tz.to_offset(from).seconds();
+ tz.following(from)
+ .map(|transition| (transition.timestamp(), transition.offset().seconds()))
+ .find(|&(_, offset)| offset != before)
+ .map(|(at, after)| (at, before, after))
+}
+
+/// The zone's standard (non-DST) UTC offset in seconds: the smaller of the
+/// mid-January and mid-July offsets of the year. DST increases the offset in
+/// every zone as consumed here (including Europe/Dublin, which the tzdb
+/// models with a negative SAVE but presents as +00:00 winter / +01:00 summer).
+pub fn standard_offset_seconds(tz: &TimeZone, at: Timestamp) -> i32 {
+ let year = at.to_zoned(TimeZone::UTC).year();
+ let probe = |month: i8| -> i32 {
+ let date = jiff::civil::date(year, month, 15).at(0, 0, 0, 0);
+ let ts = date
+ .to_zoned(TimeZone::UTC)
+ .expect("UTC has no gaps")
+ .timestamp();
+ tz.to_offset(ts).seconds()
+ };
+ probe(1).min(probe(7))
+}
+
+/// The TimeZone list for SetTimeZone: a single entry carrying the zone's
+/// standard offset and IANA name, valid from the beginning of time.
+pub fn build_time_zone_list(tz: &TimeZone, name: &str, at: Timestamp) -> Vec<MatterTimeZoneEntry> {
+ vec![MatterTimeZoneEntry {
+ offset_seconds: standard_offset_seconds(tz, at),
+ valid_at: MatterMicros(0),
+ name: name.chars().take(64).collect(),
+ }]
+}
+
+/// The DSTOffset list for SetDstOffset: the DST state in effect at `from`
+/// followed by upcoming transitions, at most `max_entries` entries (the
+/// device's DSTOffsetListMaxSize; spec minimum 1). Entries carry concrete
+/// valid-until bounds where a next transition is known, so an unrefreshed
+/// device falls back to standard time rather than trusting stale DST; the
+/// periodic sync refreshes the list long before it expires. Zones without
+/// transitions yield a single open-ended entry.
+pub fn build_dst_offset_list(
+ tz: &TimeZone,
+ max_entries: usize,
+ from: Timestamp,
+) -> Vec<MatterDstOffsetEntry> {
+ let limit = max_entries.max(1);
+ let standard = standard_offset_seconds(tz, from);
+ let mut entries = Vec::with_capacity(limit);
+
+ let mut current_offset = tz.to_offset(from).seconds();
+ let mut valid_starting = MatterMicros(0);
+ let mut transitions = tz
+ .following(from)
+ .map(|transition| (transition.timestamp(), transition.offset().seconds()));
+
+ while entries.len() < limit {
+ // Skip tzdb transitions that don't change the offset (abbreviation or
+ // rule bookkeeping only); they are not DST boundaries.
+ let next = transitions.find(|&(_, offset)| offset != current_offset);
+ match next {
+ Some((at, offset_after)) => {
+ let until = MatterMicros::from_timestamp(at);
+ entries.push(MatterDstOffsetEntry {
+ offset_seconds: current_offset - standard,
+ valid_starting,
+ valid_until: Some(until),
+ });
+ valid_starting = until;
+ current_offset = offset_after;
+ }
+ None => {
+ entries.push(MatterDstOffsetEntry {
+ offset_seconds: current_offset - standard,
+ valid_starting,
+ valid_until: None,
+ });
+ break;
+ }
+ }
+ }
+ entries
+}
+
+/// "UTC-05:00" style rendering for logs and status output.
+pub fn format_utc_offset(offset_seconds: i32) -> String {
+ let sign = if offset_seconds < 0 { '-' } else { '+' };
+ let magnitude = offset_seconds.unsigned_abs();
+ format!(
+ "UTC{sign}{:02}:{:02}",
+ magnitude / 3600,
+ (magnitude % 3600) / 60
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn tz(name: &str) -> TimeZone {
+ TimeZone::get(name).unwrap()
+ }
+
+ fn ts(s: &str) -> Timestamp {
+ s.parse().unwrap()
+ }
+
+ fn matter(s: &str) -> MatterMicros {
+ MatterMicros::from_timestamp(ts(s))
+ }
+
+ // The two Chicago transitions after 2026-07-26.
+ fn fall_2026() -> MatterMicros {
+ matter("2026-11-01T07:00:00Z")
+ }
+ fn spring_2027() -> MatterMicros {
+ matter("2027-03-14T08:00:00Z")
+ }
+
+ #[test]
+ fn standard_offset_ignores_season_and_hemisphere() {
+ let chicago = tz("America/Chicago");
+ assert_eq!(
+ standard_offset_seconds(&chicago, ts("2026-07-15T00:00:00Z")),
+ -6 * 3600
+ );
+ assert_eq!(
+ standard_offset_seconds(&chicago, ts("2026-01-15T00:00:00Z")),
+ -6 * 3600
+ );
+ // Sydney: AEST UTC+10 standard, AEDT UTC+11 in southern summer.
+ assert_eq!(
+ standard_offset_seconds(&tz("Australia/Sydney"), ts("2026-01-15T00:00:00Z")),
+ 10 * 3600
+ );
+ assert_eq!(
+ standard_offset_seconds(&tz("UTC"), ts("2026-07-15T00:00:00Z")),
+ 0
+ );
+ assert_eq!(
+ standard_offset_seconds(&tz("Asia/Kolkata"), ts("2026-07-15T00:00:00Z")),
+ (5.5 * 3600.0) as i32
+ );
+ }
+
+ #[test]
+ fn finds_exact_chicago_transitions() {
+ let chicago = tz("America/Chicago");
+ let (at, before, after) =
+ next_offset_transition(&chicago, ts("2026-07-15T00:00:00Z")).unwrap();
+ // 2026-11-01 02:00 CDT (UTC-5) -> 01:00 CST (UTC-6): 07:00:00 UTC.
+ assert_eq!(at, ts("2026-11-01T07:00:00Z"));
+ assert_eq!((before, after), (-5 * 3600, -6 * 3600));
+ assert!(next_offset_transition(&tz("UTC"), ts("2026-01-15T00:00:00Z")).is_none());
+ }
+
+ #[test]
+ fn time_zone_list_is_single_standard_entry() {
+ let list = build_time_zone_list(
+ &tz("America/Chicago"),
+ "America/Chicago",
+ ts("2026-07-26T00:00:00Z"),
+ );
+ assert_eq!(
+ list,
+ vec![MatterTimeZoneEntry {
+ offset_seconds: -6 * 3600,
+ valid_at: MatterMicros(0),
+ name: "America/Chicago".into(),
+ }]
+ );
+ }
+
+ #[test]
+ fn dst_list_covers_active_period_plus_next() {
+ let list = build_dst_offset_list(&tz("America/Chicago"), 2, ts("2026-07-26T00:00:00Z"));
+ assert_eq!(
+ list,
+ vec![
+ MatterDstOffsetEntry {
+ offset_seconds: 3600,
+ valid_starting: MatterMicros(0),
+ valid_until: Some(fall_2026()),
+ },
+ MatterDstOffsetEntry {
+ offset_seconds: 0,
+ valid_starting: fall_2026(),
+ valid_until: Some(spring_2027()),
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn dst_list_respects_device_capacity() {
+ let list = build_dst_offset_list(&tz("America/Chicago"), 1, ts("2026-07-26T00:00:00Z"));
+ assert_eq!(list.len(), 1);
+ assert_eq!(list[0].valid_until, Some(fall_2026()));
+ }
+
+ #[test]
+ fn dst_list_is_single_open_ended_zero_for_fixed_zones() {
+ for name in ["UTC", "Asia/Kolkata"] {
+ let list = build_dst_offset_list(&tz(name), 2, ts("2026-07-26T00:00:00Z"));
+ assert_eq!(
+ list,
+ vec![MatterDstOffsetEntry {
+ offset_seconds: 0,
+ valid_starting: MatterMicros(0),
+ valid_until: None,
+ }],
+ "zone {name}"
+ );
+ }
+ }
+
+ #[test]
+ fn dst_list_starts_from_standard_time_in_winter() {
+ let list = build_dst_offset_list(&tz("America/Chicago"), 2, ts("2026-01-15T00:00:00Z"));
+ let spring_2026 = matter("2026-03-08T08:00:00Z");
+ assert_eq!(list[0].offset_seconds, 0);
+ assert_eq!(list[0].valid_until, Some(spring_2026));
+ assert_eq!(list[1].offset_seconds, 3600);
+ assert_eq!(list[1].valid_starting, spring_2026);
+ }
+
+ #[test]
+ fn formats_utc_offsets() {
+ assert_eq!(format_utc_offset(-6 * 3600), "UTC-06:00");
+ assert_eq!(format_utc_offset(19_800), "UTC+05:30");
+ assert_eq!(format_utc_offset(0), "UTC+00:00");
+ }
+}