From 07d4713f554b2ae2ccf4871f6be0590c129342b0 Mon Sep 17 00:00:00 2001 From: Luke Hoersten Date: Tue, 28 Jul 2026 16:58:20 -0500 Subject: 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. --- src/tz.rs | 268 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 src/tz.rs (limited to 'src/tz.rs') 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, +} + +/// 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 { + 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 { + 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"); + } +} -- cgit v1.2.3