src.nth.io/

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