diff options
Diffstat (limited to 'src/tz.rs')
| -rw-r--r-- | src/tz.rs | 268 |
1 files changed, 268 insertions, 0 deletions
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"); + } +} |
