Skip to main content

device_envoy_core/
clock_sync.rs

1//! A device abstraction that combines NTP time synchronization with a local clock.
2//!
3//! This module provides platform-independent types and logic for [`ClockSync`]
4//! and a concrete runtime implementation re-exported by platform crates.
5//! For a complete usage example see the platform crate's `clock_sync` module
6//! (for example `device_envoy_rp::clock_sync` or `device_envoy_esp::clock_sync`).
7//!
8//! The shared trait, time types, constants, and helpers are available on every
9//! platform. The NTP-backed runtime implementation is enabled by the `wifi`
10//! feature.
11
12#![allow(clippy::future_not_send, reason = "single-threaded")]
13
14#[cfg(feature = "wifi")]
15use embassy_executor::Spawner;
16#[cfg(feature = "wifi")]
17use embassy_net::Stack;
18#[cfg(feature = "wifi")]
19use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
20#[cfg(feature = "wifi")]
21use embassy_sync::signal::Signal;
22use embassy_time::Duration;
23#[cfg(feature = "wifi")]
24use embassy_time::Instant;
25#[cfg(feature = "wifi")]
26use portable_atomic::{AtomicBool, AtomicU64, Ordering};
27use time::OffsetDateTime;
28
29#[cfg(feature = "wifi")]
30use crate::clock::{Clock, ClockStatic};
31#[cfg(feature = "wifi")]
32use crate::time_sync::{TimeSync, TimeSyncEvent, TimeSyncStatic};
33#[cfg(feature = "wifi")]
34use crate::{Error, Result};
35
36// ============================================================================
37// Re-exports
38// ============================================================================
39
40/// Units-safe wrapper for Unix timestamps (seconds since 1970-01-01 00:00:00 UTC).
41#[repr(transparent)]
42#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug)]
43#[cfg_attr(feature = "defmt", derive(defmt::Format))]
44pub struct UnixSeconds(pub i64);
45
46impl UnixSeconds {
47    /// Get the underlying `i64` value.
48    #[must_use]
49    pub const fn as_i64(self) -> i64 {
50        self.0
51    }
52
53    /// Convert NTP seconds to Unix seconds.
54    #[must_use]
55    pub const fn from_ntp_seconds(ntp: u32) -> Option<Self> {
56        let seconds = (ntp as i64) - 2_208_988_800;
57        if seconds >= 0 {
58            Some(Self(seconds))
59        } else {
60            None
61        }
62    }
63
64    /// Convert to an [`OffsetDateTime`] with the given timezone offset.
65    #[must_use]
66    pub fn to_offset_datetime(self, offset: time::UtcOffset) -> Option<OffsetDateTime> {
67        OffsetDateTime::from_unix_timestamp(self.0)
68            .ok()
69            .map(|datetime| datetime.to_offset(offset))
70    }
71}
72
73// ============================================================================
74// Constants
75// ============================================================================
76
77/// Duration representing one second.
78pub const ONE_SECOND: Duration = Duration::from_secs(1);
79/// Duration representing one minute (60 seconds).
80pub const ONE_MINUTE: Duration = Duration::from_secs(60);
81/// Duration representing one day (24 hours).
82pub const ONE_DAY: Duration = Duration::from_secs(86_400);
83
84// ============================================================================
85// Helpers
86// ============================================================================
87
88/// Extract hour (12-hour format), minute, and second from an
89/// [`OffsetDateTime`](https://docs.rs/time/latest/time/struct.OffsetDateTime.html).
90pub fn h12_m_s(dt: &OffsetDateTime) -> (u8, u8, u8) {
91    let hour_24 = dt.hour();
92    let hour_12 = match hour_24 {
93        0 => 12,
94        1..=12 => hour_24,
95        _ => hour_24 - 12,
96    };
97    (hour_12, dt.minute(), dt.second())
98}
99
100// ============================================================================
101// ClockSync types
102// ============================================================================
103
104/// Tick event emitted by [`ClockSync`].
105///
106/// See the platform crate's `clock_sync` module for a usage example.
107pub struct ClockSyncTick {
108    /// The current local time (adjusted by timezone offset if set).
109    pub local_time: OffsetDateTime,
110    /// Duration since the last successful NTP synchronization.
111    pub since_last_sync: Duration,
112}
113
114/// Platform-agnostic ClockSync operation contract.
115///
116/// Platform crates can use this trait for generic clock control helpers while
117/// keeping constructors on the concrete type.
118///
119/// # Example
120///
121/// ```rust,no_run
122/// use device_envoy_core::clock_sync::{ClockSync, h12_m_s};
123///
124/// async fn log_one_tick(clock_sync: &impl ClockSync) {
125///     let clock_sync_tick = clock_sync.wait_for_tick().await;
126///     let (hours, minutes, seconds) = h12_m_s(&clock_sync_tick.local_time);
127///     let _ = (hours, minutes, seconds);
128/// }
129///
130/// # struct ClockSyncMock;
131/// # use device_envoy_core::clock_sync::{ClockSyncTick, UnixSeconds};
132/// # use time::OffsetDateTime;
133/// # impl ClockSync for ClockSyncMock {
134/// #     async fn wait_for_tick(&self) -> ClockSyncTick {
135/// #         panic!("ClockSyncMock::wait_for_tick is not implemented in this doctest")
136/// #     }
137/// #     fn now_local(&self) -> OffsetDateTime {
138/// #         panic!("ClockSyncMock::now_local is not implemented in this doctest")
139/// #     }
140/// #     fn set_offset_minutes(&self, _minutes: i32) {}
141/// #     fn offset_minutes(&self) -> i32 { 0 }
142/// #     fn set_tick_interval(&self, _interval: Option<embassy_time::Duration>) {}
143/// #     fn set_speed(&self, _speed_multiplier: f32) {}
144/// #     fn set_utc_time(&self, _unix_seconds: UnixSeconds) {}
145/// # }
146/// # let clock_sync_mock = ClockSyncMock;
147/// # let _future = log_one_tick(&clock_sync_mock);
148/// ```
149#[allow(async_fn_in_trait)]
150pub trait ClockSync {
151    /// Wait for and return the next tick after sync.
152    ///
153    /// See the [ClockSync trait documentation](Self) for usage examples.
154    async fn wait_for_tick(&self) -> ClockSyncTick;
155
156    /// Get the current local time without waiting for a tick.
157    fn now_local(&self) -> OffsetDateTime;
158
159    /// Update the UTC offset used for local time.
160    fn set_offset_minutes(&self, minutes: i32);
161
162    /// Get the current UTC offset in minutes.
163    fn offset_minutes(&self) -> i32;
164
165    /// Set the tick interval. Use `None` to disable periodic ticks.
166    ///
167    /// This uses [`embassy_time::Duration`](https://docs.rs/embassy-time/latest/embassy_time/struct.Duration.html) for interval timing.
168    fn set_tick_interval(&self, interval: Option<embassy_time::Duration>);
169
170    /// Update the speed multiplier (1.0 = real time).
171    fn set_speed(&self, speed_multiplier: f32);
172
173    /// Manually set the current UTC time and mark the clock as synced.
174    fn set_utc_time(&self, unix_seconds: UnixSeconds);
175}
176
177#[cfg(feature = "wifi")]
178type SyncReadySignal = Signal<CriticalSectionRawMutex, ()>;
179
180#[cfg(feature = "wifi")]
181pub struct ClockSyncStatic {
182    clock_static: ClockStatic,
183    time_sync_static: TimeSyncStatic,
184    initialized: AtomicBool,
185    sync_ready: SyncReadySignal,
186    last_sync_ticks: AtomicU64,
187    synced: AtomicBool,
188}
189
190#[cfg(feature = "wifi")]
191pub struct ClockSyncRuntime {
192    clock: Clock,
193    time_sync: TimeSync,
194    sync_ready: &'static SyncReadySignal,
195    last_sync_ticks: &'static AtomicU64,
196    synced: &'static AtomicBool,
197}
198
199#[cfg(feature = "wifi")]
200impl ClockSyncStatic {
201    /// Creates static resources for the clock-sync runtime device.
202    #[must_use]
203    pub(crate) const fn new() -> Self {
204        Self {
205            clock_static: Clock::new_static(),
206            time_sync_static: TimeSync::new_static(),
207            initialized: AtomicBool::new(false),
208            sync_ready: Signal::new(),
209            last_sync_ticks: AtomicU64::new(0),
210            synced: AtomicBool::new(false),
211        }
212    }
213}
214
215#[cfg(feature = "wifi")]
216impl ClockSyncRuntime {
217    /// Create clock-sync static resources.
218    #[must_use]
219    pub const fn new_static() -> ClockSyncStatic {
220        ClockSyncStatic::new()
221    }
222
223    /// Create a clock-sync runtime using an existing network stack.
224    ///
225    /// See the platform crate `clock_sync` module documentation for a full usage example.
226    /// The `tick_interval` parameter uses
227    /// [`embassy_time::Duration`](https://docs.rs/embassy-time/latest/embassy_time/struct.Duration.html).
228    pub fn new(
229        clock_sync_static: &'static ClockSyncStatic,
230        stack: &'static Stack<'static>,
231        offset_minutes: i32,
232        tick_interval: Option<embassy_time::Duration>,
233        spawner: Spawner,
234    ) -> Result<Self> {
235        let clock_sync_uninitialized = clock_sync_static
236            .initialized
237            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
238            .is_ok();
239        assert!(
240            clock_sync_uninitialized,
241            "ClockSyncRuntime::new must be called at most once per ClockSyncStatic"
242        );
243
244        let clock = Clock::new(
245            &clock_sync_static.clock_static,
246            offset_minutes,
247            tick_interval,
248            spawner,
249        )?;
250        let time_sync = TimeSync::new(&clock_sync_static.time_sync_static, stack, spawner)?;
251
252        let clock_sync = Self {
253            clock,
254            time_sync,
255            sync_ready: &clock_sync_static.sync_ready,
256            last_sync_ticks: &clock_sync_static.last_sync_ticks,
257            synced: &clock_sync_static.synced,
258        };
259
260        spawner.spawn(
261            clock_sync_loop(
262                &clock_sync_static.clock_static,
263                clock_sync.time_sync.events(),
264                clock_sync.sync_ready,
265                clock_sync.last_sync_ticks,
266                clock_sync.synced,
267            )
268            .map_err(Error::TaskSpawn)?,
269        );
270
271        Ok(clock_sync)
272    }
273
274    fn since_last_sync(&self) -> Duration {
275        let last_sync_ticks = self.last_sync_ticks.load(Ordering::Acquire);
276        if last_sync_ticks == 0 {
277            return Duration::from_secs(0);
278        }
279        let now_ticks = Instant::now().as_ticks();
280        assert!(now_ticks >= last_sync_ticks);
281        let elapsed_ticks = now_ticks - last_sync_ticks;
282        Duration::from_micros(elapsed_ticks)
283    }
284
285    async fn wait_for_first_sync(&self) {
286        if self.synced.load(Ordering::Acquire) {
287            return;
288        }
289        self.sync_ready.wait().await;
290    }
291
292    fn mark_synced(&self) {
293        let now_ticks = Instant::now().as_ticks();
294        self.last_sync_ticks.store(now_ticks, Ordering::Release);
295        self.synced.store(true, Ordering::Release);
296        self.sync_ready.signal(());
297    }
298}
299
300#[cfg(feature = "wifi")]
301impl ClockSync for ClockSyncRuntime {
302    async fn wait_for_tick(&self) -> ClockSyncTick {
303        self.wait_for_first_sync().await;
304        let local_time = self.clock.wait_for_tick().await;
305        ClockSyncTick {
306            local_time,
307            since_last_sync: self.since_last_sync(),
308        }
309    }
310
311    fn now_local(&self) -> OffsetDateTime {
312        self.clock.now_local()
313    }
314
315    fn set_offset_minutes(&self, minutes: i32) {
316        self.clock.set_offset_minutes(minutes);
317    }
318
319    fn offset_minutes(&self) -> i32 {
320        self.clock.offset_minutes()
321    }
322
323    fn set_tick_interval(&self, interval: Option<embassy_time::Duration>) {
324        self.clock.set_tick_interval(interval);
325    }
326
327    fn set_speed(&self, speed_multiplier: f32) {
328        self.clock.set_speed(speed_multiplier);
329    }
330
331    fn set_utc_time(&self, unix_seconds: UnixSeconds) {
332        self.clock.set_utc_time(unix_seconds);
333        self.mark_synced();
334    }
335}
336
337// ============================================================================
338// Task
339// ============================================================================
340
341#[embassy_executor::task(pool_size = 2)]
342#[cfg(feature = "wifi")]
343async fn clock_sync_loop(
344    clock_static: &'static ClockStatic,
345    time_sync_events: &'static crate::time_sync::TimeSyncEvents,
346    sync_ready: &'static SyncReadySignal,
347    last_sync_ticks: &'static AtomicU64,
348    synced: &'static AtomicBool,
349) -> ! {
350    let clock = Clock::from_static(clock_static);
351    loop {
352        match time_sync_events.wait().await {
353            TimeSyncEvent::Ok(unix_seconds) => {
354                clock.set_utc_time(unix_seconds);
355                let now_ticks = Instant::now().as_ticks();
356                last_sync_ticks.store(now_ticks, Ordering::Release);
357                synced.store(true, Ordering::Release);
358                sync_ready.signal(());
359            }
360            TimeSyncEvent::Err(message) => {
361                #[cfg(feature = "defmt")]
362                defmt::info!("ClockSync time sync failed: {}", message);
363                #[cfg(not(feature = "defmt"))]
364                let _ = message;
365            }
366        }
367    }
368}