diff options
| author | Luke Hoersten <[email protected]> | 2026-07-28 16:58:20 -0500 |
|---|---|---|
| committer | Luke Hoersten <[email protected]> | 2026-07-28 16:58:20 -0500 |
| commit | 07d4713f554b2ae2ccf4871f6be0590c129342b0 (patch) | |
| tree | e9675e5e8a7cfce544927339c8f93ee7f412a71b /src/time.rs | |
Implement mattertimesync: one-shot Matter time synchronization CLI
A standalone CLI Matter controller that joins Matter devices as a
secondary administrator (multi-admin) and sets their clocks via the
standard Time Synchronization cluster. Built on rs-matter 0.2.0, the
official CSA Rust Matter stack: its PASE/CASE initiators, Commissioner
flow, builtin mDNS, and generated cluster clients. One-shot runs from a
systemd timer; there is no daemon.
Hardware-validated end to end (commission, inspect, sync, decommission)
against an IKEA ALPSTUGA air quality monitor over Thread, commissioned
alongside Apple Home. Design and operation are documented in the
README. 34 unit tests, clippy clean.
Diffstat (limited to 'src/time.rs')
| -rw-r--r-- | src/time.rs | 367 |
1 files changed, 367 insertions, 0 deletions
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:?}"); + } + } +} |
