Skip to main content

rs_matter/dm/clusters/
time_sync.rs

1/*
2 *
3 *    Copyright (c) 2026 Project CHIP Authors
4 *
5 *    Licensed under the Apache License, Version 2.0 (the "License");
6 *    you may not use this file except in compliance with the License.
7 *    You may obtain a copy of the License at
8 *
9 *        http://www.apache.org/licenses/LICENSE-2.0
10 *
11 *    Unless required by applicable law or agreed to in writing, software
12 *    distributed under the License is distributed on an "AS IS" BASIS,
13 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *    See the License for the specific language governing permissions and
15 *    limitations under the License.
16 */
17
18//! Implementation of the Time Synchronization cluster.
19//!
20//! # Cluster-shape selection — endpoint-side, via [`Options`]
21//!
22//! Each Matter feature (`TIME_ZONE`, `NTP_CLIENT`, `NTP_SERVER`,
23//! `TIME_SYNC_CLIENT`) is mirrored 1:1 by a bit in the [`Options`]
24//! bitflags type, consumed by the [`cluster`] const-generic fn which
25//! returns the matching `Cluster<'static>` metadata.
26//!
27//! The shape is picked **endpoint-side**, on the `clusters!` /
28//! `root_endpoint!` macros — e.g. `clusters!(eth, time_sync(time_zone,
29//! ntp_client); …)` — not on the handler. [`TimeSyncHandler`] itself
30//! is non-generic and its [`Self::CLUSTER`](ClusterHandler::CLUSTER)
31//! is pinned to the empty-options shape; only `CLUSTER.id` is
32//! actually consulted by the dispatcher, and the per-attribute /
33//! per-command dispatch is driven by what the endpoint advertises.
34//!
35//! Spec invariant carried over independently of features: `TimeSource`
36//! is opted in even in the empty-options shape so the Matter test
37//! harness's `has_attribute(TimeSource)` gate on `TC_TIMESYNC_2_1`
38//! matches and the test runs rather than skipping.
39//!
40//! # Pluggable data source — [`TimeSync`]
41//!
42//! The cluster's mandatory members (`UTCTime`, `Granularity`,
43//! `TimeSource`, and the `SetUTCTime` command) are handled by
44//! [`TimeSyncHandler`] directly against the Matter-wide
45//! [Last-Known-Good UTC Time](crate::Matter::last_known_utc_time)
46//! state — they require no implementor input.
47//!
48//! [`TimeSync`] only carries the feature-gated members
49//! (`TIME_ZONE` / `NTP_CLIENT` / `NTP_SERVER` / `TIME_SYNC_CLIENT`).
50//! Every method has a "no value" default so `impl TimeSync for ()`
51//! is a fully usable no-op provider; implementors only override the
52//! methods matching the options they advertised.
53
54use core::num::NonZeroU8;
55
56use bitflags::bitflags;
57
58use heapless::String;
59
60use embassy_futures::select::select;
61
62use crate::dm::endpoints::ROOT_ENDPOINT_ID;
63use crate::dm::{
64    ArrayAttributeRead, AttrChangeNotifier, Attribute, Cluster, Command, Dataver, EndptId,
65    EventEmitter, HandlerContext, InvokeContext, NodeId, Quality, ReadContext,
66};
67use crate::error::{Error, ErrorCode};
68use crate::persist::{
69    KvBlobStore, KvBlobStoreAccess, Persist, LKG_UTC_KEY, TIME_ZONE_KEY, TRUSTED_TIME_SOURCE_KEY,
70};
71use crate::tlv::{
72    FromTLV, Nullable, NullableBuilder, TLVBuilderParent, TLVElement, TLVTag, TLVWrite, ToTLV,
73    Utf8StrBuilder, TLV,
74};
75use crate::utils::cell::RefCell;
76use crate::utils::epoch::FIRMWARE_BUILD_MATTER_US;
77use crate::utils::init::{init, into_init, try_init, Init};
78use crate::utils::storage::Vec;
79use crate::utils::sync::blocking::Mutex;
80use crate::utils::sync::Notification;
81
82pub use crate::dm::clusters::decl::time_synchronization::*;
83
84pub mod client;
85
86/// An enum describing the current UTC timestamp the real-time clock is aware of.
87///
88/// The timestamp is expressed as Matter-epoch microseconds.
89#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
90#[cfg_attr(feature = "defmt", derive(defmt::Format))]
91pub enum UtcTime {
92    /// The RTC is actively tracking the current time, anchored at the given Matter-epoch microseconds value.
93    Reliable(u64),
94    /// The RTC is not currently tracking the current time, but the given Matter-epoch microseconds value is
95    /// the last known good UTC time persisted on the device.
96    ///
97    /// Do note that a "last known" time is always available, as the firmware build timestamp is used as the
98    /// initial value on a freshly-flashed device.
99    LastKnown(u64),
100}
101
102impl UtcTime {
103    /// Return the current UTC time if available, or `None` if no reliable time is currently tracked.
104    pub const fn reliable(&self) -> Option<u64> {
105        match self {
106            UtcTime::Reliable(utc) => Some(*utc),
107            UtcTime::LastKnown(_) => None,
108        }
109    }
110
111    /// Return the current UTC time if available, or the persisted LKG UTC otherwise.
112    pub const fn any(&self) -> u64 {
113        match self {
114            UtcTime::Reliable(utc) | UtcTime::LastKnown(utc) => *utc,
115        }
116    }
117
118    /// Return the current UTC time in seconds if available, or `None` if no reliable time is currently tracked.
119    pub const fn reliable_secs(&self) -> Option<u64> {
120        match self {
121            UtcTime::Reliable(utc) => Some(*utc / 1_000_000),
122            UtcTime::LastKnown(_) => None,
123        }
124    }
125
126    /// Return the current UTC time in seconds if available, or the persisted LKG UTC otherwise.
127    pub const fn any_secs(&self) -> u64 {
128        match self {
129            UtcTime::Reliable(utc) | UtcTime::LastKnown(utc) => *utc / 1_000_000,
130        }
131    }
132}
133
134/// Last-Known-Good UTC Time tracking for the device (Matter Core spec).
135///
136/// The persisted `utc_us` field is the spec-mandated stored
137/// fallback used by cert path validation when no live time
138/// synchronization is available; it is seeded from
139/// [`crate::utils::epoch::FIRMWARE_BUILD_MATTER_US`] on a
140/// freshly-flashed device.
141///
142/// `anchor`, `granularity`, and `source` are **volatile** — they
143/// describe the current monotonic-clock anchoring around the most
144/// recent [`Matter::set_utc_time`] call. After reboot, `anchor` is
145/// `None` (no live current-time tracking is active), so the TimeSync
146/// cluster reports `UTCTime = Null`, `Granularity = NoTimeGranularity`
147/// and `TimeSource = None` (per spec) — while
148/// `utc_us` still carries the persisted LKG value for cert validity.
149pub struct Rtc {
150    /// Last-Known-Good UTC time, Matter-epoch microseconds.
151    utc_us: u64,
152    /// Same as `utc_us` except always equal to the last persisted value.
153    utc_us_persisted: u64,
154    /// Granularity at the time of the last `set_utc_time` call, with
155    /// the "one level lower than supplied" step-down already
156    /// applied and floored at `MinutesGranularity`.
157    /// **Not persisted** — resets to `NoTimeGranularity` at boot,
158    /// matching the `anchor = None` post-reboot state.
159    granularity: GranularityEnum,
160    /// Authority that last called `set_utc_time`. **Not persisted** —
161    /// resets to `None` at boot.
162    source: TimeSourceEnum,
163    /// `Instant::now()` captured at the last `set_utc_time` call.
164    /// Volatile — `None` after reboot until next set.
165    anchor: Option<embassy_time::Instant>,
166    /// Configured Trusted Time Source for the device (Matter Core spec).
167    /// At most one entry; the fabric that
168    /// installed it owns it and is cleared on fabric removal.
169    /// Persisted under [`crate::persist::TRUSTED_TIME_SOURCE_KEY`].
170    trusted_time_source: Option<TrustedTimeSource>,
171}
172
173impl Rtc {
174    #[inline(always)]
175    pub(crate) const fn new() -> Self {
176        Self {
177            utc_us: FIRMWARE_BUILD_MATTER_US,
178            utc_us_persisted: FIRMWARE_BUILD_MATTER_US,
179            granularity: GranularityEnum::NoTimeGranularity,
180            source: TimeSourceEnum::None,
181            anchor: None,
182            trusted_time_source: None,
183        }
184    }
185
186    /// Return an in-place initializer for `LkgUtc`.
187    pub(crate) fn init() -> impl Init<Self> {
188        init!(Self {
189            utc_us: FIRMWARE_BUILD_MATTER_US,
190            utc_us_persisted: FIRMWARE_BUILD_MATTER_US,
191            granularity: GranularityEnum::NoTimeGranularity,
192            source: TimeSourceEnum::None,
193            anchor: None,
194            trusted_time_source: None,
195        })
196    }
197
198    fn reset(&mut self) {
199        self.utc_us = FIRMWARE_BUILD_MATTER_US;
200        self.utc_us_persisted = FIRMWARE_BUILD_MATTER_US;
201        self.granularity = GranularityEnum::NoTimeGranularity;
202        self.source = TimeSourceEnum::None;
203        self.anchor = None;
204        self.trusted_time_source = None;
205    }
206
207    pub fn reset_persist<S: KvBlobStore>(
208        &mut self,
209        mut store: S,
210        buf: &mut [u8],
211    ) -> Result<(), Error> {
212        self.reset();
213
214        store.remove(LKG_UTC_KEY, buf)?;
215        store.remove(TRUSTED_TIME_SOURCE_KEY, buf)?;
216        Ok(())
217    }
218
219    pub fn load_persist<S: KvBlobStore>(&mut self, mut kv: S, buf: &mut [u8]) -> Result<(), Error> {
220        self.reset();
221
222        // Load the persisted Last-Known-Good UTC Time, if any.
223        // Floor at `FIRMWARE_BUILD_MATTER_US` per Matter Core spec -
224        // the on-disk value must never regress us
225        // below the build timestamp (the documented lower bound
226        // we never adjust backwards past).
227        if let Some(data) = kv.load(LKG_UTC_KEY, buf)? {
228            let stored = u64::from_tlv(&TLVElement::new(data))?;
229            let floor = FIRMWARE_BUILD_MATTER_US;
230
231            self.utc_us_persisted = stored;
232            self.utc_us = stored.max(floor);
233        }
234
235        // Load the persisted Trusted Time Source, if any.
236        if let Some(data) = kv.load(TRUSTED_TIME_SOURCE_KEY, buf)? {
237            self.trusted_time_source = Some(TrustedTimeSource::from_tlv(&TLVElement::new(data))?);
238        }
239
240        Ok(())
241    }
242
243    /// Return the configured Trusted Time Source, or `None` if unset
244    /// (Matter Core spec).
245    pub fn trusted_time_source(&self) -> Option<TrustedTimeSource> {
246        self.trusted_time_source
247    }
248
249    /// Install or clear the Trusted Time Source (Matter Core spec).
250    /// `fab_idx` is the fabric performing the change —
251    /// recorded so that fabric removal can clear an entry it owns.
252    pub fn set_trusted_time_source<E: EventEmitter>(
253        &mut self,
254        source: Option<TrustedTimeSource>,
255        change_notifier: &dyn AttrChangeNotifier,
256        event_emitter: E,
257    ) -> Result<(), Error> {
258        if self.trusted_time_source != source {
259            let previous = self.trusted_time_source;
260
261            self.trusted_time_source = source;
262
263            change_notifier.notify_attr_changed(
264                ROOT_ENDPOINT_ID,
265                TimeSyncHandler::CLUSTER.id,
266                AttributeId::TrustedTimeSource as _,
267            );
268
269            // Matter Core spec: emit `MissingTrustedTimeSource`
270            // when SetTrustedTimeSource clears a previously-set entry (null
271            // request payload, transitioning from `Some(..)` → `None`).
272            if self.trusted_time_source.is_none() && previous.is_some() {
273                MissingTrustedTimeSource::emit_for(event_emitter, ROOT_ENDPOINT_ID, |b| b.end())?;
274            }
275        }
276
277        Ok(())
278    }
279
280    /// Install or clear the Trusted Time Source (Matter Core spec).
281    /// `fab_idx` is the fabric performing the change —
282    /// recorded so that fabric removal can clear an entry it owns.
283    /// `source = None` clears any existing entry.
284    ///
285    /// Updates in-memory state, persists under
286    /// [`crate::persist::TRUSTED_TIME_SOURCE_KEY`], and notifies
287    /// subscribers of the `TrustedTimeSource` attribute change.
288    pub fn set_trusted_time_source_persist<S: KvBlobStoreAccess, E: EventEmitter>(
289        &mut self,
290        source: Option<TrustedTimeSource>,
291        persist: &mut Persist<S>,
292        change_notifier: &dyn AttrChangeNotifier,
293        event_emitter: E,
294    ) -> Result<(), Error> {
295        if self.trusted_time_source != source {
296            self.set_trusted_time_source(source, change_notifier, event_emitter)?;
297
298            match source {
299                Some(source) => {
300                    persist.store_tlv(TRUSTED_TIME_SOURCE_KEY, source)?;
301                }
302                None => {
303                    persist.remove(TRUSTED_TIME_SOURCE_KEY)?;
304                }
305            }
306        }
307
308        Ok(())
309    }
310
311    /// Return the current UTC time if available, or the persisted Last-Known-Good UTC Time otherwise.
312    pub fn utc_time(&self) -> UtcTime {
313        if let Some(anchor) = self.anchor {
314            let elapsed_us = embassy_time::Instant::now()
315                .checked_duration_since(anchor)
316                .map(|d| d.as_micros())
317                .unwrap_or(0);
318
319            UtcTime::Reliable(self.utc_us.saturating_add(elapsed_us))
320        } else {
321            UtcTime::LastKnown(self.utc_us)
322        }
323    }
324
325    /// Return the Granularity reported on the wire for the TimeSync
326    /// cluster's `Granularity` attribute, derived from the most
327    /// recent [`Self::set_utc_time`] (with the spec-required
328    /// step-down and floor already applied) — or `NoTimeGranularity`
329    /// if no `set_utc_time` has been called since boot (per
330    /// the spec, which forbids `NoTimeGranularity` only while
331    /// `UTCTime ≠ Null`).
332    pub fn utc_time_granularity(&self) -> GranularityEnum {
333        if self.anchor.is_some() {
334            self.granularity
335        } else {
336            GranularityEnum::NoTimeGranularity
337        }
338    }
339
340    /// Return the TimeSource reported on the wire for the TimeSync
341    /// cluster's `TimeSource` attribute — `None` until the first
342    /// [`Self::set_utc_time`] (per spec).
343    pub fn utc_time_source(&self) -> TimeSourceEnum {
344        if self.anchor.is_some() {
345            self.source
346        } else {
347            TimeSourceEnum::None
348        }
349    }
350
351    /// Update the Last-Known-Good UTC Time (Matter Core spec),
352    /// capturing a fresh monotonic anchor so subsequent
353    /// [`Self::utc_time`] reads advance from the supplied value.
354    ///
355    /// Per the spec: the supplied `granularity` is recorded
356    /// stepped-down by one level (with a floor of
357    /// `MinutesGranularity` per spec); the supplied `source`
358    /// is recorded verbatim.
359    ///
360    /// The new value is written to the in-memory state immediately.
361    /// Persistence to `LKG_UTC_KEY` happens separately — the
362    /// TimeSync cluster handler invokes this from inside a
363    /// `kv.access(...)` closure and writes through the same handle.
364    /// Direct callers that need on-disk durability should call
365    /// [`Self::persist_lkg_utc`] explicitly.
366    pub fn set_utc_time(
367        &mut self,
368        utc_us: u64,
369        granularity: GranularityEnum,
370        source: TimeSourceEnum,
371        change_notifier: &dyn AttrChangeNotifier,
372    ) -> bool {
373        let stepped = match granularity {
374            GranularityEnum::MicrosecondsGranularity => GranularityEnum::MillisecondsGranularity,
375            GranularityEnum::MillisecondsGranularity => GranularityEnum::SecondsGranularity,
376            GranularityEnum::SecondsGranularity => GranularityEnum::MinutesGranularity,
377            // Minutes / NoTime → floor at Minutes
378            // (spec forbids NoTime while UTCTime is non-null).
379            _ => GranularityEnum::MinutesGranularity,
380        };
381
382        let changed = self.utc_us != utc_us || self.granularity != stepped || self.source != source;
383
384        if changed || self.anchor.is_none() {
385            self.utc_us = utc_us;
386            self.granularity = stepped;
387            self.source = source;
388            self.anchor = Some(embassy_time::Instant::now());
389
390            change_notifier.notify_attr_changed(
391                ROOT_ENDPOINT_ID,
392                TimeSyncHandler::CLUSTER.id,
393                AttributeId::UTCTime as _,
394            );
395            change_notifier.notify_attr_changed(
396                ROOT_ENDPOINT_ID,
397                TimeSyncHandler::CLUSTER.id,
398                AttributeId::Granularity as _,
399            );
400            change_notifier.notify_attr_changed(
401                ROOT_ENDPOINT_ID,
402                TimeSyncHandler::CLUSTER.id,
403                AttributeId::TimeSource as _,
404            );
405        }
406
407        changed
408    }
409
410    pub fn set_utc_time_persist<S: KvBlobStoreAccess>(
411        &mut self,
412        utc_us: u64,
413        granularity: GranularityEnum,
414        source: TimeSourceEnum,
415        persist: &mut Persist<S>,
416        change_notifier: &dyn AttrChangeNotifier,
417    ) -> Result<(), Error> {
418        const DELTA: u64 = 24 * 60 * 60 * 1_000_000; // 1 day in microseconds
419
420        let delta = self.utc_us_persisted.abs_diff(utc_us);
421
422        self.set_utc_time(utc_us, granularity, source, change_notifier);
423
424        if delta >= DELTA {
425            // As per the Matter Core spec, we have to persist the new LKG UTC at least once per month
426            // Since this would be an involved math, we instead persist if the new LKG UTC is different
427            // by more than a day than the previous one, which should be good enough to cover the requirement
428            // without needing a separate timer for periodic persistence.
429
430            info!("TimeSync: UTC time changed by more than a day, persisting");
431
432            persist.store_tlv(LKG_UTC_KEY, utc_us.to_le_bytes())?;
433            self.utc_us_persisted = utc_us;
434        }
435
436        Ok(())
437    }
438}
439
440/// Persisted Trusted Time Source descriptor (Matter Core spec).
441/// Records which fabric configured the source so that fabric removal can
442/// clear it and emit `MissingTrustedTimeSource`.
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, FromTLV, ToTLV)]
444#[cfg_attr(feature = "defmt", derive(defmt::Format))]
445pub struct TrustedTimeSource {
446    /// Fabric that installed the source (the `FabricIndex` injected by
447    /// the IM dispatcher into the `SetTrustedTimeSource` invoke).
448    pub fab_idx: NonZeroU8,
449    /// Node ID of the trusted source on that fabric.
450    pub node_id: NodeId,
451    /// Endpoint on the trusted source's node that hosts the TimeSync
452    /// cluster server.
453    pub endpoint: EndptId,
454}
455
456bitflags! {
457    /// Cluster-shape selectors for the [`TimeSyncHandler`]. Each bit
458    /// turns on exactly one Matter `Feature` — there are no
459    /// independent-optional toggles on this cluster, so the mapping
460    /// is 1:1.
461    ///
462    /// Used as the const-generic argument to [`cluster`] (via its
463    /// `bits()` value) to compute the matching `Cluster<'static>`
464    /// metadata, which is then installed onto the endpoint via the
465    /// `clusters!` / `root_endpoint!` macros (e.g.
466    /// `clusters!(eth, time_sync(time_zone, ntp_client); …)`).
467    #[derive(Default, Debug, Copy, Clone, Eq, PartialEq, Hash)]
468    pub struct Options: u8 {
469        /// Claim the Matter `TIME_ZONE` feature. Advertises `TimeZone`,
470        /// `DSTOffset`, `LocalTime`, `TimeZoneDatabase`,
471        /// `TimeZoneListMaxSize`, `DSTOffsetListMaxSize` attributes
472        /// and the `SetTimeZone` + `SetDSTOffset` commands.
473        const TIME_ZONE = 0x1;
474        /// Claim the Matter `NTP_CLIENT` feature. Advertises
475        /// `DefaultNTP` + `SupportsDNSResolve` attributes and the
476        /// `SetDefaultNTP` command.
477        const NTP_CLIENT = 0x2;
478        /// Claim the Matter `NTP_SERVER` feature. Advertises the
479        /// `NTPServerAvailable` attribute.
480        const NTP_SERVER = 0x4;
481        /// Claim the Matter `TIME_SYNC_CLIENT` feature. Advertises the
482        /// `TrustedTimeSource` attribute and the `SetTrustedTimeSource`
483        /// command.
484        const TIME_SYNC_CLIENT = 0x8;
485    }
486}
487
488/// One time-zone entry yielded by [`TimeSync::time_zone`] via the
489/// visitor callback. The lifetime `'a` is the borrow of the
490/// implementor's internal storage for the duration of the visit, so
491/// `name` can point straight into the implementor's table without
492/// copying.
493#[derive(Debug, Clone, Eq, PartialEq, Hash)]
494pub struct TimeZoneEntry<'a> {
495    /// Offset from UTC, in seconds.
496    pub offset: i32,
497    /// Matter-epoch microseconds after which this offset takes effect.
498    pub valid_at: u64,
499    /// Human-readable IANA time-zone name (`Europe/Sofia` …); `None`
500    /// if the implementation doesn't track names.
501    pub name: Option<&'a str>,
502}
503
504/// One DST-offset entry yielded by [`TimeSync::dst_offset`] via the
505/// visitor callback.
506#[derive(Debug, Clone, Eq, PartialEq, Hash, FromTLV, ToTLV)]
507pub struct DSTOffsetEntry {
508    /// Offset from local standard time, in seconds, while DST is in
509    /// effect.
510    pub offset: i32,
511    /// Matter-epoch microseconds at which the offset becomes valid.
512    pub valid_starting: u64,
513    /// Matter-epoch microseconds at which the offset stops being
514    /// valid. `None` means "indefinitely" (`Null` on the wire).
515    pub valid_until: Option<u64>,
516}
517
518/// Snapshot of the device's currently-configured trusted time source.
519/// Returned by [`TimeSync::trusted_time_source`] wrapped in a
520/// [`Nullable`].
521#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
522pub struct TrustedTimeSourceData {
523    /// Fabric index that configured this trusted time source.
524    pub fabric_index: u8,
525    /// Node ID of the trusted source.
526    pub node_id: u64,
527    /// Endpoint on the trusted source's node.
528    pub endpoint: u16,
529}
530
531/// Pluggable data source for the feature-gated members of the Time
532/// Synchronization cluster (`TIME_ZONE` / `NTP_CLIENT` / `NTP_SERVER`).
533///
534/// The mandatory members — `UTCTime`, `Granularity`, `TimeSource`,
535/// and the `SetUTCTime` command — are handled by [`TimeSyncHandler`] directly
536/// against the built-in Matter RTC state and do **not** appear on this trait.
537///
538/// The `TIME_SYNC_CLIENT` feature (if enabled) is also handled by the handler
539/// directly against the `TrustedTimeSource` entry in the built-in Matter RTC state,
540/// so it also doesn't appear here.
541/// Data provider for the `TIME_ZONE` feature: the `TimeZone` / `DSTOffset`
542/// lists and their `SetTimeZone` / `SetDSTOffset` mutations.
543///
544/// Implemented by [`TimeZoneStore`], the batteries-included validated
545/// storage; custom implementors take over validation and storage themselves.
546pub trait TimeZones {
547    /// Stream the active time-zone entries into `visit`.
548    fn time_zone(
549        &self,
550        visit: &mut dyn FnMut(&TimeZoneEntry<'_>) -> Result<(), Error>,
551    ) -> Result<(), Error>;
552
553    /// Stream the active DST-offset entries into `visit`.
554    fn dst_offset(
555        &self,
556        visit: &mut dyn FnMut(&DSTOffsetEntry) -> Result<(), Error>,
557    ) -> Result<(), Error>;
558
559    /// How complete the device's IANA time-zone database is.
560    fn time_zone_database(&self) -> Result<TimeZoneDatabaseEnum, Error>;
561
562    /// Maximum length of the `TimeZone` list this device accepts.
563    fn time_zone_list_max_size(&self) -> Result<u8, Error>;
564
565    /// Maximum length of the `DSTOffset` list this device accepts.
566    fn dst_offset_list_max_size(&self) -> Result<u8, Error>;
567
568    /// Handle `SetTimeZone`. Returns the `DSTOffsetRequired` field for the
569    /// response.
570    fn set_time_zone(&self, request: &SetTimeZoneRequest<'_>) -> Result<bool, Error>;
571
572    /// Handle `SetDSTOffset`.
573    fn set_dst_offset(&self, request: &SetDSTOffsetRequest<'_>) -> Result<(), Error>;
574}
575
576impl<T> TimeZones for &T
577where
578    T: TimeZones,
579{
580    fn time_zone(
581        &self,
582        visit: &mut dyn FnMut(&TimeZoneEntry<'_>) -> Result<(), Error>,
583    ) -> Result<(), Error> {
584        (*self).time_zone(visit)
585    }
586
587    fn dst_offset(
588        &self,
589        visit: &mut dyn FnMut(&DSTOffsetEntry) -> Result<(), Error>,
590    ) -> Result<(), Error> {
591        (*self).dst_offset(visit)
592    }
593
594    fn time_zone_database(&self) -> Result<TimeZoneDatabaseEnum, Error> {
595        (*self).time_zone_database()
596    }
597
598    fn time_zone_list_max_size(&self) -> Result<u8, Error> {
599        (*self).time_zone_list_max_size()
600    }
601
602    fn dst_offset_list_max_size(&self) -> Result<u8, Error> {
603        (*self).dst_offset_list_max_size()
604    }
605
606    fn set_time_zone(&self, request: &SetTimeZoneRequest<'_>) -> Result<bool, Error> {
607        (*self).set_time_zone(request)
608    }
609
610    fn set_dst_offset(&self, request: &SetDSTOffsetRequest<'_>) -> Result<(), Error> {
611        (*self).set_dst_offset(request)
612    }
613}
614
615/// Data provider for the `NTP_CLIENT` feature.
616pub trait NtpClient {
617    /// Hostname or IP address of the default NTP server, or `Null` if
618    /// none is configured.
619    fn default_ntp(&self) -> Result<Nullable<&str>, Error>;
620
621    /// Whether the device's NTP-client resolver supports DNS names
622    /// (vs. only literal IP addresses).
623    fn supports_dns_resolve(&self) -> Result<bool, Error>;
624
625    /// Handle `SetDefaultNTP`.
626    fn set_default_ntp(&self, request: &SetDefaultNTPRequest<'_>) -> Result<(), Error>;
627}
628
629impl<T> NtpClient for &T
630where
631    T: NtpClient,
632{
633    fn default_ntp(&self) -> Result<Nullable<&str>, Error> {
634        (*self).default_ntp()
635    }
636
637    fn supports_dns_resolve(&self) -> Result<bool, Error> {
638        (*self).supports_dns_resolve()
639    }
640
641    fn set_default_ntp(&self, request: &SetDefaultNTPRequest<'_>) -> Result<(), Error> {
642        (*self).set_default_ntp(request)
643    }
644}
645
646/// Data provider for the `NTP_SERVER` feature.
647pub trait NtpServer {
648    /// Whether the device is currently serving NTP queries.
649    fn ntp_server_available(&self) -> Result<bool, Error>;
650}
651
652impl<T> NtpServer for &T
653where
654    T: NtpServer,
655{
656    fn ntp_server_available(&self) -> Result<bool, Error> {
657        (*self).ntp_server_available()
658    }
659}
660
661/// Maximum byte length of a `TimeZoneStruct::name` (spec constraint 0..=64).
662pub const TIME_ZONE_NAME_MAX: usize = 64;
663
664/// One owned `TimeZone` entry as stored by [`TimeZoneStore`].
665#[derive(FromTLV, ToTLV)]
666struct TimeZoneOwned {
667    offset: i32,
668    valid_at: u64,
669    name: Option<String<TIME_ZONE_NAME_MAX>>,
670}
671
672/// The persisted shape of [`TimeZoneStore`]: both `nonVolatile`-quality lists
673/// as one TLV blob under [`TIME_ZONE_KEY`].
674struct TimeZoneStoreData<const TIME_ZONE_MAX: usize, const DST_OFFSET_MAX: usize> {
675    time_zone: Vec<TimeZoneOwned, TIME_ZONE_MAX>,
676    dst_offset: Vec<DSTOffsetEntry, DST_OFFSET_MAX>,
677}
678
679impl<const TIME_ZONE_MAX: usize, const DST_OFFSET_MAX: usize>
680    TimeZoneStoreData<TIME_ZONE_MAX, DST_OFFSET_MAX>
681{
682    const fn new() -> Self {
683        Self {
684            time_zone: Vec::new(),
685            dst_offset: Vec::new(),
686        }
687    }
688
689    fn init() -> impl Init<Self> {
690        init!(Self {
691            time_zone <- Vec::init(),
692            dst_offset <- Vec::init(),
693        })
694    }
695}
696
697impl<'a, const TIME_ZONE_MAX: usize, const DST_OFFSET_MAX: usize> FromTLV<'a>
698    for TimeZoneStoreData<TIME_ZONE_MAX, DST_OFFSET_MAX>
699{
700    fn from_tlv(tlv: &TLVElement<'a>) -> Result<Self, Error> {
701        let tlv = tlv.structure()?;
702
703        Ok(Self {
704            time_zone: FromTLV::from_tlv(&tlv.ctx(0)?)?,
705            dst_offset: FromTLV::from_tlv(&tlv.ctx(1)?)?,
706        })
707    }
708
709    fn init_from_tlv(tlv: TLVElement<'a>) -> impl Init<Self, Error> {
710        into_init(move || {
711            let seq = tlv.structure()?;
712
713            let init = try_init!(Self {
714                time_zone <- Vec::<TimeZoneOwned, TIME_ZONE_MAX>::init_from_tlv(seq.ctx(0)?),
715                dst_offset <- Vec::<DSTOffsetEntry, DST_OFFSET_MAX>::init_from_tlv(seq.ctx(1)?),
716            }? Error);
717
718            Ok(init)
719        })
720    }
721}
722
723impl<const TIME_ZONE_MAX: usize, const DST_OFFSET_MAX: usize> ToTLV
724    for TimeZoneStoreData<TIME_ZONE_MAX, DST_OFFSET_MAX>
725{
726    fn to_tlv<W: TLVWrite>(&self, tag: &TLVTag, mut tw: W) -> Result<(), Error> {
727        tw.start_struct(tag)?;
728
729        self.time_zone.to_tlv(&TLVTag::Context(0), &mut tw)?;
730        self.dst_offset.to_tlv(&TLVTag::Context(1), &mut tw)?;
731
732        tw.end_container()
733    }
734
735    fn tlv_iter(&self, tag: TLVTag) -> impl Iterator<Item = Result<TLV<'_>, Error>> {
736        use crate::tlv::TLVIter;
737
738        core::iter::empty()
739            .start_struct(tag)
740            .chain_iter(self.time_zone.tlv_iter(TLVTag::Context(0)))
741            .chain_iter(self.dst_offset.tlv_iter(TLVTag::Context(1)))
742            .end_container()
743    }
744}
745
746/// The mutable innards of [`TimeZoneStore`].
747struct TimeZoneStoreState<const TIME_ZONE_MAX: usize, const DST_OFFSET_MAX: usize> {
748    data: TimeZoneStoreData<TIME_ZONE_MAX, DST_OFFSET_MAX>,
749    /// Bumped on every accepted `SetTimeZone` / `SetDSTOffset`, so the
750    /// transition timer can re-evaluate its schedule.
751    generation: u32,
752}
753
754impl<const TIME_ZONE_MAX: usize, const DST_OFFSET_MAX: usize>
755    TimeZoneStoreState<TIME_ZONE_MAX, DST_OFFSET_MAX>
756{
757    const fn new() -> Self {
758        Self {
759            data: TimeZoneStoreData::new(),
760            generation: 0,
761        }
762    }
763
764    fn init() -> impl Init<Self> {
765        init!(Self {
766            data <- TimeZoneStoreData::init(),
767            generation: 0,
768        })
769    }
770}
771
772/// A concrete [`TimeSync`] provider implementing the `TIME_ZONE` feature's
773/// storage and validation: the `TimeZone` / `DSTOffset` lists with all the
774/// Matter Core spec constraint checks on `SetTimeZone` / `SetDSTOffset`.
775///
776/// Pure validated storage: event emission, persistence and `LocalTime`
777/// computation are orchestrated by [`TimeSyncHandler`], which has the
778/// contexts this store deliberately does not.
779///
780/// `TimeZoneDatabase` is reported as `None` - names are stored and echoed
781/// back verbatim, but never interpreted.
782pub struct TimeZoneStore<const TIME_ZONE_MAX: usize = 2, const DST_OFFSET_MAX: usize = 2> {
783    state: Mutex<RefCell<TimeZoneStoreState<TIME_ZONE_MAX, DST_OFFSET_MAX>>>,
784    /// Signalled on every accepted mutation (and by the handler when UTC time
785    /// is set), so the transition timer in [`TimeSyncHandler::run`] can
786    /// re-evaluate its schedule.
787    changed: Notification,
788}
789
790impl<const TIME_ZONE_MAX: usize, const DST_OFFSET_MAX: usize>
791    TimeZoneStore<TIME_ZONE_MAX, DST_OFFSET_MAX>
792{
793    /// Create a store with the spec-default single `{offset: 0, valid_at: 0}`
794    /// time-zone entry and no DST offsets.
795    pub const fn new() -> Self {
796        Self {
797            state: Mutex::new(RefCell::new(TimeZoneStoreState::new())),
798            changed: Notification::new(),
799        }
800    }
801
802    /// Return an in-place initializer for `TimeZoneStore`.
803    pub fn init() -> impl Init<Self> {
804        init!(Self {
805            state <- Mutex::init(RefCell::init(TimeZoneStoreState::init())),
806            changed <- Notification::init(),
807        })
808    }
809
810    /// Wait until the store's contents (or the node's UTC time) may have
811    /// changed. Used by the transition timer.
812    pub async fn wait_changed(&self) {
813        self.changed.wait().await
814    }
815
816    /// Signal [`Self::wait_changed`] waiters. Called internally on accepted
817    /// mutations; also called by the handler when UTC time is (re)set, since
818    /// that changes which entries are active.
819    pub fn note_changed(&self) {
820        self.changed.notify();
821    }
822
823    /// Re-hydrate both lists from `store` under [`TIME_ZONE_KEY`]. Call at
824    /// startup, before the store is shared with the handler. A missing key
825    /// (first boot / cleared persistence) leaves the defaults.
826    pub fn load_persist<S: KvBlobStore>(&self, mut store: S, buf: &mut [u8]) -> Result<(), Error> {
827        let Some(data) = store.load(TIME_ZONE_KEY, buf)? else {
828            return Ok(());
829        };
830
831        // TODO: LARGE BUFFER
832        let loaded = TimeZoneStoreData::from_tlv(&TLVElement::new(data))?;
833
834        self.state.lock(|state| {
835            let mut state = state.borrow_mut();
836
837            state.data = loaded;
838        });
839
840        info!("Loaded TimeZone / DSTOffset lists from storage");
841
842        Ok(())
843    }
844
845    /// Serialise both lists to `kv` under [`TIME_ZONE_KEY`]. Called by the
846    /// handler after every accepted mutation (the lists are `nonVolatile`
847    /// quality per the Matter Core spec).
848    fn store_persist<S: KvBlobStoreAccess>(&self, kv: S) -> Result<(), Error> {
849        let mut persist = Persist::new(kv);
850
851        self.state.lock(|state| {
852            let state = state.borrow();
853
854            persist.store_tlv(TIME_ZONE_KEY, &state.data)
855        })?;
856
857        persist.run()
858    }
859
860    /// The current change generation; bumped on every accepted mutation.
861    pub fn generation(&self) -> u32 {
862        self.state.lock(|state| state.borrow().generation)
863    }
864
865    /// The `(offset, name)` of the currently-active time-zone entry: the last
866    /// entry whose `valid_at` has passed, or the implicit `(0, None)` default
867    /// when the list is empty.
868    pub fn active_time_zone(&self, now: u64) -> (i32, Option<String<TIME_ZONE_NAME_MAX>>) {
869        self.state.lock(|state| {
870            let state = state.borrow();
871
872            state
873                .data
874                .time_zone
875                .iter()
876                .rfind(|entry| entry.valid_at <= now)
877                .map(|entry| (entry.offset, entry.name.clone()))
878                .unwrap_or((0, None))
879        })
880    }
881
882    /// The DST offset active at `now`, if any.
883    pub fn active_dst_offset(&self, now: u64) -> Option<i32> {
884        self.state.lock(|state| {
885            let state = state.borrow();
886
887            state
888                .data
889                .dst_offset
890                .iter()
891                .find(|entry| {
892                    entry.valid_starting <= now
893                        && entry.valid_until.map(|until| now < until).unwrap_or(true)
894                })
895                .map(|entry| entry.offset)
896        })
897    }
898
899    /// Whether the DST table is empty.
900    pub fn dst_table_empty(&self) -> bool {
901        self.state
902            .lock(|state| state.borrow().data.dst_offset.is_empty())
903    }
904
905    /// Whether the DST table still carries usable information at `now`: at
906    /// least one entry is active or scheduled (its `valid_until` is Null or in
907    /// the future).
908    ///
909    /// `false` covers both "empty" (e.g. just cleared by `SetTimeZone`) and
910    /// "exhausted" (every entry expired). `DSTTableEmpty` is emitted on the
911    /// usable -> not-usable *edge* - `TC_TIMESYNC_2_10` requires it both when
912    /// `SetTimeZone` clears a non-empty table and when the last entry expires
913    /// naturally. Entries are deliberately *retained* rather than pruned:
914    /// `TestTimeSynchronization` (and chip) expect `DSTOffset` reads to return
915    /// exactly what was written.
916    pub fn dst_usable(&self, now: u64) -> bool {
917        self.state.lock(|state| {
918            let state = state.borrow();
919
920            state
921                .data
922                .dst_offset
923                .iter()
924                .any(|entry| entry.valid_until.map(|until| now < until).unwrap_or(true))
925        })
926    }
927
928    /// The next Matter-epoch-microseconds instant after `now` at which the
929    /// active time zone or DST state can change - i.e. the earliest
930    /// `valid_at` / `valid_starting` / `valid_until` still in the future.
931    /// `None` when no transition is scheduled.
932    pub fn next_transition(&self, now: u64) -> Option<u64> {
933        self.state.lock(|state| {
934            let state = state.borrow();
935
936            let tz = state
937                .data
938                .time_zone
939                .iter()
940                .map(|entry| entry.valid_at)
941                .filter(|at| *at > now)
942                .min();
943
944            let dst = state
945                .data
946                .dst_offset
947                .iter()
948                .flat_map(|entry| {
949                    [Some(entry.valid_starting), entry.valid_until]
950                        .into_iter()
951                        .flatten()
952                })
953                .filter(|at| *at > now)
954                .min();
955
956            match (tz, dst) {
957                (Some(a), Some(b)) => Some(a.min(b)),
958                (a, b) => a.or(b),
959            }
960        })
961    }
962
963    /// The `SetDSTOffset` constraint checks, factored out so the caller can
964    /// clear the table on rejection:
965    /// - at most [`DST_OFFSET_MAX`] entries, else RESOURCE_EXHAUSTED
966    /// - sorted ascending by `validStarting`, else CONSTRAINT_ERROR
967    /// - ranges must not overlap: an entry must not start before its
968    ///   predecessor's `validUntil`, else CONSTRAINT_ERROR
969    /// - only the last entry may have a Null `validUntil`, else
970    ///   CONSTRAINT_ERROR
971    /// - `validStarting < validUntil` within an entry, else CONSTRAINT_ERROR
972    fn validate_dst_offset(&self, request: &SetDSTOffsetRequest<'_>) -> Result<(), Error> {
973        let mut prev_starting: Option<u64> = None;
974        let mut prev_until: Option<u64> = None;
975        let mut seen_null_until = false;
976
977        for (index, entry) in request.dst_offset()?.iter().enumerate() {
978            let entry = entry?;
979
980            if index == DST_OFFSET_MAX {
981                return Err(ErrorCode::ResourceExhausted.into());
982            }
983
984            // A Null `validUntil` on a previous entry means that entry was
985            // not last - reject.
986            if seen_null_until {
987                Err(ErrorCode::ConstraintError)?;
988            }
989
990            let starting = entry.valid_starting()?;
991
992            if let Some(prev) = prev_starting {
993                if starting <= prev {
994                    Err(ErrorCode::ConstraintError)?;
995                }
996            }
997
998            if let Some(until) = prev_until {
999                if starting < until {
1000                    // Overlaps the predecessor's still-valid range.
1001                    Err(ErrorCode::ConstraintError)?;
1002                }
1003            }
1004
1005            match entry.valid_until()?.into_option() {
1006                Some(until) => {
1007                    if starting >= until {
1008                        Err(ErrorCode::ConstraintError)?;
1009                    }
1010
1011                    prev_until = Some(until);
1012                }
1013                None => seen_null_until = true,
1014            }
1015
1016            prev_starting = Some(starting);
1017        }
1018
1019        Ok(())
1020    }
1021}
1022
1023impl<const TIME_ZONE_MAX: usize, const DST_OFFSET_MAX: usize> Default
1024    for TimeZoneStore<TIME_ZONE_MAX, DST_OFFSET_MAX>
1025{
1026    fn default() -> Self {
1027        Self::new()
1028    }
1029}
1030
1031impl<const TIME_ZONE_MAX: usize, const DST_OFFSET_MAX: usize> TimeZones
1032    for TimeZoneStore<TIME_ZONE_MAX, DST_OFFSET_MAX>
1033{
1034    fn time_zone(
1035        &self,
1036        visit: &mut dyn FnMut(&TimeZoneEntry<'_>) -> Result<(), Error>,
1037    ) -> Result<(), Error> {
1038        self.state.lock(|state| {
1039            let state = state.borrow();
1040
1041            if state.data.time_zone.is_empty() {
1042                // The spec-default entry: reported even before any
1043                // `SetTimeZone`, as the `TimeZone` list has a min size of 1.
1044                return visit(&TimeZoneEntry {
1045                    offset: 0,
1046                    valid_at: 0,
1047                    name: None,
1048                });
1049            }
1050
1051            for entry in state.data.time_zone.iter() {
1052                visit(&TimeZoneEntry {
1053                    offset: entry.offset,
1054                    valid_at: entry.valid_at,
1055                    name: entry.name.as_deref(),
1056                })?;
1057            }
1058
1059            Ok(())
1060        })
1061    }
1062
1063    fn dst_offset(
1064        &self,
1065        visit: &mut dyn FnMut(&DSTOffsetEntry) -> Result<(), Error>,
1066    ) -> Result<(), Error> {
1067        self.state.lock(|state| {
1068            let state = state.borrow();
1069
1070            for entry in state.data.dst_offset.iter() {
1071                visit(entry)?;
1072            }
1073
1074            Ok(())
1075        })
1076    }
1077
1078    fn time_zone_database(&self) -> Result<TimeZoneDatabaseEnum, Error> {
1079        Ok(TimeZoneDatabaseEnum::None)
1080    }
1081
1082    fn time_zone_list_max_size(&self) -> Result<u8, Error> {
1083        Ok(TIME_ZONE_MAX as u8)
1084    }
1085
1086    fn dst_offset_list_max_size(&self) -> Result<u8, Error> {
1087        Ok(DST_OFFSET_MAX as u8)
1088    }
1089
1090    fn set_time_zone(&self, request: &SetTimeZoneRequest<'_>) -> Result<bool, Error> {
1091        // First pass: validate every constraint before mutating anything, so
1092        // a rejected request leaves the stored list untouched.
1093        //
1094        // Matter Core spec (and `TestTimeSynchronization` step-for-step):
1095        // - at most `TimeZoneListMaxSize` entries, else RESOURCE_EXHAUSTED
1096        // - first entry `validAt` SHALL be 0, else CONSTRAINT_ERROR
1097        // - subsequent entries `validAt` SHALL NOT be 0, else CONSTRAINT_ERROR
1098        // - strictly ascending by `validAt`, else CONSTRAINT_ERROR. NB: the
1099        //   spec states no such rule explicitly - it doesn't need to: with the
1100        //   spec's own cap of 2 entries (`TimeZoneListMaxSize` "may take the
1101        //   value of 1 or 2") the two checks above already force
1102        //   ascending order. The explicit check is defense-in-depth for
1103        //   larger `TIME_ZONE_MAX` instantiations (themselves off-spec, but
1104        //   expressible), where an unsorted list would silently corrupt the
1105        //   active-entry resolution in `active_time_zone` /
1106        //   `next_transition` / the LocalTime computation, all of which
1107        //   assume ascending order.
1108        // - `offset` in -12h..=+14h, `name` at most 64 bytes, else
1109        //   CONSTRAINT_ERROR
1110        // - an *empty* list is valid and resets to the default entry
1111        let mut prev_valid_at: Option<u64> = None;
1112
1113        for (index, entry) in request.time_zone()?.iter().enumerate() {
1114            let entry = entry?;
1115
1116            if index == TIME_ZONE_MAX {
1117                return Err(ErrorCode::ResourceExhausted.into());
1118            }
1119
1120            let valid_at = entry.valid_at()?;
1121
1122            if (index == 0) != (valid_at == 0) {
1123                Err(ErrorCode::ConstraintError)?;
1124            }
1125
1126            if let Some(prev) = prev_valid_at {
1127                if valid_at <= prev {
1128                    Err(ErrorCode::ConstraintError)?;
1129                }
1130            }
1131
1132            prev_valid_at = Some(valid_at);
1133
1134            let offset = entry.offset()?;
1135
1136            if !(-12 * 3600..=14 * 3600).contains(&offset) {
1137                Err(ErrorCode::ConstraintError)?;
1138            }
1139
1140            if let Some(name) = entry.name()? {
1141                if name.len() > TIME_ZONE_NAME_MAX {
1142                    Err(ErrorCode::ConstraintError)?;
1143                }
1144            }
1145        }
1146
1147        // Second pass: commit. Per the spec, an accepted `SetTimeZone` also
1148        // clears the DST table (the handler emits the matching events).
1149        self.state.lock(|state| {
1150            let mut state = state.borrow_mut();
1151
1152            state.data.time_zone.clear();
1153
1154            for entry in request.time_zone()?.iter() {
1155                let entry = entry?;
1156
1157                let name = match entry.name()? {
1158                    Some(name) => {
1159                        Some(String::try_from(name).map_err(|_| ErrorCode::ConstraintError)?)
1160                    }
1161                    None => None,
1162                };
1163
1164                // `unwrap` is safe: the first pass bounded the count.
1165                unwrap!(state
1166                    .data
1167                    .time_zone
1168                    .push(TimeZoneOwned {
1169                        offset: entry.offset()?,
1170                        valid_at: entry.valid_at()?,
1171                        name,
1172                    })
1173                    .ok());
1174            }
1175
1176            state.data.dst_offset.clear();
1177            state.generation = state.generation.wrapping_add(1);
1178
1179            Ok::<_, Error>(())
1180        })?;
1181
1182        self.note_changed();
1183
1184        // `DSTOffsetRequired`: with `TimeZoneDatabase = None` the device can
1185        // never compute DST itself, so offsets are always required.
1186        Ok(true)
1187    }
1188
1189    fn set_dst_offset(&self, request: &SetDSTOffsetRequest<'_>) -> Result<(), Error> {
1190        // Matter Core spec (and `TestTimeSynchronization`):
1191        // - at most `DSTOffsetListMaxSize` entries, else RESOURCE_EXHAUSTED
1192        // - sorted ascending by `validStarting`, else CONSTRAINT_ERROR
1193        // - only the last entry may have a Null `validUntil`, else
1194        //   CONSTRAINT_ERROR
1195        // - `validStarting < validUntil` within an entry, else
1196        //   CONSTRAINT_ERROR
1197        // - an empty list is valid and clears the table
1198        // Per the Matter Core spec (and `TC_TIMESYNC_2_5` steps 5/7/9), a
1199        // *rejected* `SetDSTOffset` still clears the stored list - the command
1200        // semantically replaces the table, and a failed replacement leaves it
1201        // empty rather than restoring the old contents.
1202        let validated = self.validate_dst_offset(request);
1203
1204        if let Err(e) = validated {
1205            self.state.lock(|state| {
1206                let mut state = state.borrow_mut();
1207
1208                state.data.dst_offset.clear();
1209                state.generation = state.generation.wrapping_add(1);
1210            });
1211
1212            self.note_changed();
1213
1214            return Err(e);
1215        }
1216
1217        self.state.lock(|state| {
1218            let mut state = state.borrow_mut();
1219
1220            state.data.dst_offset.clear();
1221
1222            for entry in request.dst_offset()?.iter() {
1223                let entry = entry?;
1224
1225                // `unwrap` is safe: the first pass bounded the count.
1226                unwrap!(state
1227                    .data
1228                    .dst_offset
1229                    .push(DSTOffsetEntry {
1230                        offset: entry.offset()?,
1231                        valid_starting: entry.valid_starting()?,
1232                        valid_until: entry.valid_until()?.into_option(),
1233                    })
1234                    .ok());
1235            }
1236
1237            state.generation = state.generation.wrapping_add(1);
1238
1239            Ok::<_, Error>(())
1240        })?;
1241
1242        self.note_changed();
1243
1244        Ok(())
1245    }
1246}
1247
1248// ---- Cluster-shape selection -------------------------------------------------
1249
1250const fn time_sync_attrs<const OPTS: u8>(attr: &Attribute, _: u16, _: u32) -> bool {
1251    use AttributeId as A;
1252
1253    // Mandatory always (UTCTime, Granularity)
1254    if !attr.quality.contains(Quality::OPTIONAL) {
1255        return true;
1256    }
1257
1258    // TimeSource: always exposed independently of features so the
1259    // Matter test harness's TC_TIMESYNC_2_1 gate matches.
1260    if attr.id == A::TimeSource as u32 {
1261        return true;
1262    }
1263
1264    let opts = Options::from_bits_truncate(OPTS);
1265    if opts.contains(Options::TIME_ZONE)
1266        && (attr.id == A::TimeZone as u32
1267            || attr.id == A::DSTOffset as u32
1268            || attr.id == A::LocalTime as u32
1269            || attr.id == A::TimeZoneDatabase as u32
1270            || attr.id == A::TimeZoneListMaxSize as u32
1271            || attr.id == A::DSTOffsetListMaxSize as u32)
1272    {
1273        return true;
1274    }
1275
1276    if opts.contains(Options::NTP_CLIENT)
1277        && (attr.id == A::DefaultNTP as u32 || attr.id == A::SupportsDNSResolve as u32)
1278    {
1279        return true;
1280    }
1281
1282    if opts.contains(Options::NTP_SERVER) && attr.id == A::NTPServerAvailable as u32 {
1283        return true;
1284    }
1285
1286    if opts.contains(Options::TIME_SYNC_CLIENT) && attr.id == A::TrustedTimeSource as u32 {
1287        return true;
1288    }
1289
1290    false
1291}
1292
1293const fn time_sync_cmds<const OPTS: u8>(cmd: &Command, _: u16, _: u32) -> bool {
1294    use CommandId as C;
1295
1296    // `SetUTCTime` is mandatory whenever the cluster is present
1297    // (Matter Core spec, conformance `M`), independent of
1298    // features. Devices reporting `Granularity = NoTimeGranularity`
1299    // are additionally required to accept it.
1300    if cmd.id == C::SetUTCTime as u32 {
1301        return true;
1302    }
1303
1304    let opts = Options::from_bits_truncate(OPTS);
1305
1306    if opts.contains(Options::TIME_ZONE)
1307        && (cmd.id == C::SetTimeZone as u32 || cmd.id == C::SetDSTOffset as u32)
1308    {
1309        return true;
1310    }
1311
1312    if opts.contains(Options::NTP_CLIENT) && cmd.id == C::SetDefaultNTP as u32 {
1313        return true;
1314    }
1315
1316    if opts.contains(Options::TIME_SYNC_CLIENT) && cmd.id == C::SetTrustedTimeSource as u32 {
1317        return true;
1318    }
1319
1320    false
1321}
1322
1323/// Compute the `Cluster<'static>` metadata for a TimeSync handler
1324/// advertising the features encoded in `OPTS` (the [`Options::bits`]
1325/// value). See the [`Options`] flags for the per-bit detail.
1326///
1327/// Pair the returned shape with a [`TimeSync`] implementation whose
1328/// methods supply real values for the corresponding option bits.
1329pub const fn cluster<const OPTS: u8>() -> Cluster<'static> {
1330    let opts = Options::from_bits_truncate(OPTS);
1331
1332    let mut features = 0u32;
1333
1334    if opts.contains(Options::TIME_ZONE) {
1335        features |= Feature::TIME_ZONE.bits();
1336    }
1337
1338    if opts.contains(Options::NTP_CLIENT) {
1339        features |= Feature::NTP_CLIENT.bits();
1340    }
1341
1342    if opts.contains(Options::NTP_SERVER) {
1343        features |= Feature::NTP_SERVER.bits();
1344    }
1345
1346    if opts.contains(Options::TIME_SYNC_CLIENT) {
1347        features |= Feature::TIME_SYNC_CLIENT.bits();
1348    }
1349
1350    Cluster {
1351        feature_map: features,
1352        with_attrs: time_sync_attrs::<OPTS>,
1353        with_cmds: time_sync_cmds::<OPTS>,
1354        ..FULL_CLUSTER
1355    }
1356}
1357
1358// ---- Handler -----------------------------------------------------------------
1359
1360/// Handler for the Time Synchronization Matter cluster.
1361///
1362/// Borrows a `&dyn TimeSync` data provider for the lifetime `'a` and
1363/// forwards every non-builtin attribute read / command invoke to it.
1364///
1365/// The handler is **not** parameterized by cluster shape:
1366/// [`Self::CLUSTER`](ClusterHandler::CLUSTER) is pinned to the
1367/// empty-options form and only its `id` is consulted by the
1368/// dispatcher. The on-wire shape — which optional attributes /
1369/// commands / features are advertised — is decided by the cluster
1370/// metadata supplied on the endpoint side (e.g. `clusters!(eth,
1371/// time_sync(time_zone, ntp_client); …)`); per-attribute dispatch
1372/// follows the endpoint's metadata, so the handler answers exactly
1373/// what the endpoint exposes.
1374#[derive(Clone)]
1375pub struct TimeSyncHandler<'a> {
1376    dataver: Dataver,
1377    /// `TIME_ZONE` feature provider; `None` when the feature is not hosted.
1378    time_zones: Option<&'a dyn TimeZones>,
1379    /// `NTP_CLIENT` feature provider; `None` when the feature is not hosted.
1380    ntp_client: Option<&'a dyn NtpClient>,
1381    /// `NTP_SERVER` feature provider; `None` when the feature is not hosted.
1382    ntp_server: Option<&'a dyn NtpServer>,
1383    /// Set when the `TimeZones` provider is a [`TimeZoneStore`]: enables the
1384    /// transition timer in the `Handler::run` impl (TimeZoneStatus /
1385    /// DSTStatus / DSTTableEmpty events) and persistence of the `nonVolatile`
1386    /// lists. `None` for custom [`TimeZones`] providers, which own those
1387    /// concerns themselves.
1388    tz_store: Option<&'a TimeZoneStore>,
1389}
1390
1391impl<'a> TimeSyncHandler<'a> {
1392    /// Create a handler with no feature providers: only the always-mandatory
1393    /// members (`UTCTime` / `Granularity` / `TimeSource` / `SetUTCTime`, plus
1394    /// the `TIME_SYNC_CLIENT` state), all served from the Matter-wide RTC.
1395    pub const fn new(dataver: Dataver) -> Self {
1396        Self {
1397            dataver,
1398            time_zones: None,
1399            ntp_client: None,
1400            ntp_server: None,
1401            tz_store: None,
1402        }
1403    }
1404
1405    /// Create a handler backed by a [`TimeZoneStore`] - the batteries-included
1406    /// `TIME_ZONE` feature shape: SetTimeZone/SetDSTOffset validation and
1407    /// storage, transition events driven by the `Handler::run` impl, and
1408    /// persistence of the lists under [`TIME_ZONE_KEY`].
1409    pub const fn new_with_time_zone(dataver: Dataver, store: &'a TimeZoneStore) -> Self {
1410        Self {
1411            dataver,
1412            time_zones: Some(store),
1413            ntp_client: None,
1414            ntp_server: None,
1415            tz_store: Some(store),
1416        }
1417    }
1418
1419    /// Provide a custom [`TimeZones`] implementation.
1420    pub const fn with_time_zones(mut self, time_zones: &'a dyn TimeZones) -> Self {
1421        self.time_zones = Some(time_zones);
1422        self
1423    }
1424
1425    /// Provide an [`NtpClient`] implementation.
1426    pub const fn with_ntp_client(mut self, ntp_client: &'a dyn NtpClient) -> Self {
1427        self.ntp_client = Some(ntp_client);
1428        self
1429    }
1430
1431    /// Provide an [`NtpServer`] implementation.
1432    pub const fn with_ntp_server(mut self, ntp_server: &'a dyn NtpServer) -> Self {
1433        self.ntp_server = Some(ntp_server);
1434        self
1435    }
1436
1437    /// Adapt the handler instance to the generic `rs-matter` `Handler` trait.
1438    pub const fn adapt(self) -> HandlerAdaptor<Self> {
1439        HandlerAdaptor(self)
1440    }
1441}
1442
1443impl ClusterHandler for TimeSyncHandler<'_> {
1444    const CLUSTER: Cluster<'static> = cluster::<0>();
1445
1446    fn dataver(&self) -> u32 {
1447        self.dataver.get()
1448    }
1449
1450    fn dataver_changed(&self) {
1451        self.dataver.changed();
1452    }
1453
1454    // ---- Always-on reads (served from Matter-wide LKG state, not
1455    // from the user-supplied `TimeSync` provider).
1456
1457    async fn run(&self, ctx: impl HandlerContext) -> Result<(), Error> {
1458        // The transition timer only exists for the batteries-included
1459        // [`TimeZoneStore`] shape; custom providers drive their own events.
1460        let Some(store) = self.tz_store else {
1461            return core::future::pending().await;
1462        };
1463
1464        // Change-edge state. `None` = not yet evaluated (no valid UTC time) -
1465        // the first evaluation baselines silently, so a boot does not emit
1466        // spurious "changed to inactive" events; every later divergence emits.
1467        let mut last_tz: Option<i32> = None;
1468        let mut last_dst_active: Option<bool> = None;
1469        let mut last_dst_usable: Option<bool> = None;
1470
1471        loop {
1472            let now = ctx
1473                .matter()
1474                .with_state(|state| state.rtc.utc_time())
1475                .reliable();
1476
1477            let next = if let Some(now) = now {
1478                // ---- TimeZoneStatus: the active offset changed.
1479                let (tz_offset, tz_name) = store.active_time_zone(now);
1480
1481                if last_tz != Some(tz_offset) {
1482                    if last_tz.is_some() {
1483                        let emitted = TimeZoneStatus::emit_for(&ctx, ROOT_ENDPOINT_ID, |event| {
1484                            event.offset(tz_offset)?.name(tz_name.as_deref())?.end()
1485                        });
1486
1487                        if let Err(e) = emitted {
1488                            warn!("Failed to emit TimeZoneStatus: {:?}", e);
1489                        }
1490                    }
1491
1492                    last_tz = Some(tz_offset);
1493                }
1494
1495                // ---- DSTStatus: DST became active / inactive.
1496                let dst_active = store.active_dst_offset(now).is_some();
1497
1498                if last_dst_active != Some(dst_active) {
1499                    if last_dst_active.is_some() || dst_active {
1500                        let emitted = DSTStatus::emit_for(&ctx, ROOT_ENDPOINT_ID, |event| {
1501                            event.dst_offset_active(dst_active)?.end()
1502                        });
1503
1504                        if let Err(e) = emitted {
1505                            warn!("Failed to emit DSTStatus: {:?}", e);
1506                        }
1507                    }
1508
1509                    last_dst_active = Some(dst_active);
1510                }
1511
1512                // ---- DSTTableEmpty: the table just ran out of usable DST
1513                // information - cleared (e.g. by SetTimeZone) or every entry
1514                // expired. Edge-triggered on usable -> not-usable; see
1515                // `dst_usable`.
1516                let dst_usable = store.dst_usable(now);
1517
1518                if last_dst_usable != Some(dst_usable) {
1519                    if !dst_usable && last_dst_usable == Some(true) {
1520                        let emitted =
1521                            DSTTableEmpty::emit_for(&ctx, ROOT_ENDPOINT_ID, |event| event.end());
1522
1523                        if let Err(e) = emitted {
1524                            warn!("Failed to emit DSTTableEmpty: {:?}", e);
1525                        }
1526                    }
1527
1528                    last_dst_usable = Some(dst_usable);
1529                }
1530
1531                store.next_transition(now)
1532            } else {
1533                // No valid UTC time - nothing is "active"; wait for a change
1534                // (SetUTCTime wakes us via `note_changed`).
1535                None
1536            };
1537
1538            // Sleep until the next scheduled boundary (with a small margin so
1539            // we evaluate just *after* it), or until the store changes.
1540            let boundary = async {
1541                match (next, now) {
1542                    (Some(at), Some(now)) => {
1543                        let delta_us = at.saturating_sub(now).saturating_add(100_000);
1544
1545                        embassy_time::Timer::after(embassy_time::Duration::from_micros(delta_us))
1546                            .await
1547                    }
1548                    _ => core::future::pending().await,
1549                }
1550            };
1551
1552            select(boundary, store.wait_changed()).await;
1553        }
1554    }
1555
1556    fn utc_time(&self, ctx: impl ReadContext) -> Result<Nullable<u64>, Error> {
1557        Ok(Nullable::new(
1558            ctx.matter()
1559                .with_state(|state| state.rtc.utc_time())
1560                .reliable(),
1561        ))
1562    }
1563
1564    fn granularity(&self, ctx: impl ReadContext) -> Result<GranularityEnum, Error> {
1565        Ok(ctx
1566            .matter()
1567            .with_state(|state| state.rtc.utc_time_granularity()))
1568    }
1569
1570    fn time_source(&self, ctx: impl ReadContext) -> Result<TimeSourceEnum, Error> {
1571        Ok(ctx.matter().with_state(|state| state.rtc.utc_time_source()))
1572    }
1573
1574    // ---- Feature-gated reads
1575
1576    // Served directly from the Matter-wide TrustedTimeSource state
1577    // (Matter Core spec) — fabric-scoped storage lives on
1578    // `MatterState::rtc`, not on the user-supplied `TimeSync` provider.
1579    fn trusted_time_source<P: TLVBuilderParent>(
1580        &self,
1581        ctx: impl ReadContext,
1582        builder: NullableBuilder<P, TrustedTimeSourceStructBuilder<P>>,
1583    ) -> Result<P, Error> {
1584        match ctx
1585            .matter()
1586            .with_state(|state| state.rtc.trusted_time_source())
1587        {
1588            Some(tts) => builder
1589                .non_null()?
1590                .fabric_index(tts.fab_idx.get())?
1591                .node_id(tts.node_id)?
1592                .endpoint(tts.endpoint)?
1593                .end(),
1594            None => builder.null(),
1595        }
1596    }
1597
1598    fn default_ntp<P: TLVBuilderParent>(
1599        &self,
1600        _ctx: impl ReadContext,
1601        builder: NullableBuilder<P, Utf8StrBuilder<P>>,
1602    ) -> Result<P, Error> {
1603        match self
1604            .ntp_client
1605            .ok_or(ErrorCode::AttributeNotFound)?
1606            .default_ntp()?
1607            .into_option()
1608        {
1609            Some(s) => builder.non_null()?.set(s),
1610            None => builder.null(),
1611        }
1612    }
1613
1614    fn supports_dns_resolve(&self, _ctx: impl ReadContext) -> Result<bool, Error> {
1615        self.ntp_client
1616            .ok_or(ErrorCode::AttributeNotFound)?
1617            .supports_dns_resolve()
1618    }
1619
1620    fn ntp_server_available(&self, _ctx: impl ReadContext) -> Result<bool, Error> {
1621        self.ntp_server
1622            .ok_or(ErrorCode::AttributeNotFound)?
1623            .ntp_server_available()
1624    }
1625
1626    fn time_zone<P: TLVBuilderParent>(
1627        &self,
1628        _ctx: impl ReadContext,
1629        builder: ArrayAttributeRead<TimeZoneStructArrayBuilder<P>, TimeZoneStructBuilder<P>>,
1630    ) -> Result<P, Error> {
1631        match builder {
1632            ArrayAttributeRead::ReadAll(array) => {
1633                let mut array_opt = Some(array);
1634                self.time_zones
1635                    .ok_or(ErrorCode::AttributeNotFound)?
1636                    .time_zone(&mut |entry| {
1637                        let array = unwrap!(array_opt.take());
1638                        let next = array
1639                            .push()?
1640                            .offset(entry.offset)?
1641                            .valid_at(entry.valid_at)?
1642                            .name(entry.name)?
1643                            .end()?;
1644                        array_opt = Some(next);
1645                        Ok(())
1646                    })?;
1647                unwrap!(array_opt.take()).end()
1648            }
1649            ArrayAttributeRead::ReadOne(index, item_builder) => {
1650                let mut item_opt = Some(item_builder);
1651                let mut returned: Option<P> = None;
1652                let mut current = 0u16;
1653                self.time_zones
1654                    .ok_or(ErrorCode::AttributeNotFound)?
1655                    .time_zone(&mut |entry| {
1656                        if returned.is_none() && current == index {
1657                            let b = unwrap!(item_opt.take());
1658                            returned = Some(
1659                                b.offset(entry.offset)?
1660                                    .valid_at(entry.valid_at)?
1661                                    .name(entry.name)?
1662                                    .end()?,
1663                            );
1664                        }
1665                        current = current.saturating_add(1);
1666                        Ok(())
1667                    })?;
1668                returned.ok_or_else(|| ErrorCode::ConstraintError.into())
1669            }
1670            ArrayAttributeRead::ReadNone(array) => array.end(),
1671        }
1672    }
1673
1674    fn dst_offset<P: TLVBuilderParent>(
1675        &self,
1676        _ctx: impl ReadContext,
1677        builder: ArrayAttributeRead<DSTOffsetStructArrayBuilder<P>, DSTOffsetStructBuilder<P>>,
1678    ) -> Result<P, Error> {
1679        match builder {
1680            ArrayAttributeRead::ReadAll(array) => {
1681                let mut array_opt = Some(array);
1682                self.time_zones
1683                    .ok_or(ErrorCode::AttributeNotFound)?
1684                    .dst_offset(&mut |entry| {
1685                        let array = unwrap!(array_opt.take());
1686                        let next = array
1687                            .push()?
1688                            .offset(entry.offset)?
1689                            .valid_starting(entry.valid_starting)?
1690                            .valid_until(Nullable::new(entry.valid_until))?
1691                            .end()?;
1692                        array_opt = Some(next);
1693                        Ok(())
1694                    })?;
1695                unwrap!(array_opt.take()).end()
1696            }
1697            ArrayAttributeRead::ReadOne(index, item_builder) => {
1698                let mut item_opt = Some(item_builder);
1699                let mut returned: Option<P> = None;
1700                let mut current = 0u16;
1701                self.time_zones
1702                    .ok_or(ErrorCode::AttributeNotFound)?
1703                    .dst_offset(&mut |entry| {
1704                        if returned.is_none() && current == index {
1705                            let b = unwrap!(item_opt.take());
1706                            returned = Some(
1707                                b.offset(entry.offset)?
1708                                    .valid_starting(entry.valid_starting)?
1709                                    .valid_until(Nullable::new(entry.valid_until))?
1710                                    .end()?,
1711                            );
1712                        }
1713                        current = current.saturating_add(1);
1714                        Ok(())
1715                    })?;
1716                returned.ok_or_else(|| ErrorCode::ConstraintError.into())
1717            }
1718            ArrayAttributeRead::ReadNone(array) => array.end(),
1719        }
1720    }
1721
1722    fn local_time(&self, ctx: impl ReadContext) -> Result<Nullable<u64>, Error> {
1723        // Matter Core spec: `LocalTime = UTCTime + active-TimeZone.offset +
1724        // active-DSTOffset.offset`, Null whenever UTC time is unknown. Computed
1725        // here - like `UTCTime`/`Granularity`, which are also handler-owned -
1726        // because no provider can derive it without the RTC: the provider only
1727        // owns the *lists*.
1728        let Some(utc) = ctx
1729            .matter()
1730            .with_state(|state| state.rtc.utc_time())
1731            .reliable()
1732        else {
1733            return Ok(Nullable::none());
1734        };
1735
1736        let mut offset_secs: i64 = 0;
1737
1738        // Active time zone: the last entry whose `valid_at` has passed.
1739        self.time_zones
1740            .ok_or(ErrorCode::AttributeNotFound)?
1741            .time_zone(&mut |entry| {
1742                if entry.valid_at <= utc {
1743                    offset_secs = entry.offset as i64;
1744                }
1745                Ok(())
1746            })?;
1747
1748        // The DST component is *required*, not additive-when-present: per the
1749        // Matter Core spec (via `TC_TIMESYNC_2_8` steps 11 and 20), `LocalTime`
1750        // is Null exactly when the DST table carries no usable information -
1751        // empty, or every entry expired. While the table is usable, the DST
1752        // contribution is the currently-active entry's offset, or zero
1753        // *between* windows (an expired entry followed by a future one).
1754        let mut usable = false;
1755        let mut active: Option<i32> = None;
1756
1757        self.time_zones
1758            .ok_or(ErrorCode::AttributeNotFound)?
1759            .dst_offset(&mut |entry| {
1760                let unexpired = entry.valid_until.map(|u| utc < u).unwrap_or(true);
1761
1762                if unexpired {
1763                    usable = true;
1764
1765                    if entry.valid_starting <= utc {
1766                        active = Some(entry.offset);
1767                    }
1768                }
1769
1770                Ok(())
1771            })?;
1772
1773        if !usable {
1774            return Ok(Nullable::none());
1775        }
1776
1777        offset_secs += active.unwrap_or(0) as i64;
1778
1779        Ok(Nullable::some(utc.saturating_add_signed(
1780            offset_secs.saturating_mul(1_000_000),
1781        )))
1782    }
1783
1784    fn time_zone_database(&self, _ctx: impl ReadContext) -> Result<TimeZoneDatabaseEnum, Error> {
1785        self.time_zones
1786            .ok_or(ErrorCode::AttributeNotFound)?
1787            .time_zone_database()
1788    }
1789
1790    fn time_zone_list_max_size(&self, _ctx: impl ReadContext) -> Result<u8, Error> {
1791        self.time_zones
1792            .ok_or(ErrorCode::AttributeNotFound)?
1793            .time_zone_list_max_size()
1794    }
1795
1796    fn dst_offset_list_max_size(&self, _ctx: impl ReadContext) -> Result<u8, Error> {
1797        self.time_zones
1798            .ok_or(ErrorCode::AttributeNotFound)?
1799            .dst_offset_list_max_size()
1800    }
1801
1802    // ---- Commands
1803
1804    fn handle_set_utc_time(
1805        &self,
1806        ctx: impl InvokeContext,
1807        request: SetUTCTimeRequest<'_>,
1808    ) -> Result<(), Error> {
1809        // Matter Core spec: regardless of the optional
1810        // `TimeSource` field in the request, the device SHALL set
1811        // `TimeSource` to `Admin` when `SetUTCTime` populates UTCTime.
1812        let utc_us = request.utc_time()?;
1813        let granularity = request.granularity()?;
1814        ctx.matter().with_state(|state| {
1815            state
1816                .rtc
1817                .set_utc_time(utc_us, granularity, TimeSourceEnum::Admin, &ctx)
1818        });
1819
1820        // A (re)set clock changes which TimeZone/DSTOffset entries are
1821        // active - let the transition timer re-evaluate.
1822        if let Some(store) = self.tz_store {
1823            store.note_changed();
1824        }
1825
1826        Ok(())
1827    }
1828
1829    // Matter Core spec — installs or clears the per-device
1830    // Trusted Time Source. The fabric performing the change is
1831    // recorded so that fabric removal can clear an entry it owns
1832    // and emit `MissingTrustedTimeSource`.
1833    fn handle_set_trusted_time_source(
1834        &self,
1835        ctx: impl InvokeContext,
1836        request: SetTrustedTimeSourceRequest<'_>,
1837    ) -> Result<(), Error> {
1838        let fab_idx = NonZeroU8::new(ctx.cmd().fab_idx).ok_or(ErrorCode::InvalidCommand)?;
1839
1840        let source = request
1841            .trusted_time_source()?
1842            .into_option()
1843            .map(|tts| {
1844                Ok::<_, Error>(TrustedTimeSource {
1845                    fab_idx,
1846                    node_id: tts.node_id()?,
1847                    endpoint: tts.endpoint()?,
1848                })
1849            })
1850            .transpose()?;
1851
1852        let mut persist = Persist::new(ctx.kv());
1853
1854        ctx.matter().with_state(|state| {
1855            state
1856                .rtc
1857                .set_trusted_time_source_persist(source, &mut persist, &ctx, &ctx)
1858        })?;
1859
1860        persist.run()?;
1861
1862        Ok(())
1863    }
1864
1865    fn handle_set_time_zone<P: TLVBuilderParent>(
1866        &self,
1867        ctx: impl InvokeContext,
1868        request: SetTimeZoneRequest<'_>,
1869        response: SetTimeZoneResponseBuilder<P>,
1870    ) -> Result<P, Error> {
1871        let dst_offset_required = self
1872            .time_zones
1873            .ok_or(ErrorCode::CommandNotFound)?
1874            .set_time_zone(&request)?;
1875
1876        // An accepted SetTimeZone mutates the (nonVolatile) `TimeZone` list
1877        // and clears `DSTOffset`; persist and report both. The
1878        // TimeZoneStatus/DSTStatus/DSTTableEmpty *events* are emitted by the
1879        // transition timer in [`Self::run`], the single emission authority -
1880        // it was woken by the store on commit.
1881        if let Some(store) = self.tz_store {
1882            store.store_persist(ctx.kv())?;
1883        }
1884
1885        ctx.notify_own_attr_changed(AttributeId::TimeZone as _);
1886        ctx.notify_own_attr_changed(AttributeId::DSTOffset as _);
1887
1888        response.dst_offset_required(dst_offset_required)?.end()
1889    }
1890
1891    fn handle_set_dst_offset(
1892        &self,
1893        ctx: impl InvokeContext,
1894        request: SetDSTOffsetRequest<'_>,
1895    ) -> Result<(), Error> {
1896        self.time_zones
1897            .ok_or(ErrorCode::CommandNotFound)?
1898            .set_dst_offset(&request)?;
1899
1900        if let Some(store) = self.tz_store {
1901            store.store_persist(ctx.kv())?;
1902        }
1903
1904        ctx.notify_own_attr_changed(AttributeId::DSTOffset as _);
1905
1906        Ok(())
1907    }
1908
1909    fn handle_set_default_ntp(
1910        &self,
1911        _ctx: impl InvokeContext,
1912        request: SetDefaultNTPRequest<'_>,
1913    ) -> Result<(), Error> {
1914        self.ntp_client
1915            .ok_or(ErrorCode::CommandNotFound)?
1916            .set_default_ntp(&request)
1917    }
1918}
1919
1920impl core::fmt::Debug for TimeSyncHandler<'_> {
1921    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1922        f.debug_struct("TimeSyncHandler")
1923            .field("dataver", &self.dataver)
1924            .finish()
1925    }
1926}
1927
1928#[cfg(feature = "defmt")]
1929impl defmt::Format for TimeSyncHandler<'_> {
1930    fn format(&self, f: defmt::Formatter) {
1931        defmt::write!(f, "TimeSyncHandler {{ dataver: {} }}", self.dataver.get());
1932    }
1933}