Skip to main content

device_envoy/
time_sync.rs

1//! A device abstraction for Network Time Protocol (NTP) time synchronization over WiFi.
2//!
3//! This version uses an existing network stack (e.g., from `WifiAuto`).
4//!
5//! See the [`ClockSync` struct example](crate::clock_sync::ClockSync) for usage.
6
7#![allow(clippy::future_not_send, reason = "single-threaded")]
8#![allow(dead_code, unused_imports)]
9
10use time::{OffsetDateTime, UtcOffset};
11
12/// Units-safe wrapper for Unix timestamps (seconds since 1970-01-01 00:00:00 UTC).
13#[repr(transparent)]
14#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Debug, defmt::Format)]
15pub struct UnixSeconds(pub i64);
16
17impl UnixSeconds {
18    /// Get the underlying i64 value.
19    #[must_use]
20    pub const fn as_i64(self) -> i64 {
21        self.0
22    }
23
24    /// Convert NTP seconds (since 1900-01-01) to Unix seconds (since 1970-01-01).
25    #[must_use]
26    pub const fn from_ntp_seconds(ntp: u32) -> Option<Self> {
27        const NTP_TO_UNIX_SECONDS: i64 = 2_208_988_800;
28        let seconds = (ntp as i64) - NTP_TO_UNIX_SECONDS;
29        if seconds >= 0 {
30            Some(Self(seconds))
31        } else {
32            None
33        }
34    }
35
36    /// Convert to [`OffsetDateTime`] with the given timezone offset.
37    #[must_use]
38    pub fn to_offset_datetime(self, offset: UtcOffset) -> Option<OffsetDateTime> {
39        OffsetDateTime::from_unix_timestamp(self.as_i64())
40            .ok()
41            .map(|datetime| datetime.to_offset(offset))
42    }
43}
44
45#[cfg(feature = "wifi")]
46mod wifi_impl {
47    use core::convert::Infallible;
48    use defmt::*;
49    // Import panic! explicitly from prelude to disambiguate from defmt::*
50    use core::panic;
51    use embassy_executor::Spawner;
52    use embassy_net::{Stack, dns, udp};
53    use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
54    use embassy_sync::signal::Signal;
55    use embassy_time::{Duration, Timer};
56    use static_cell::StaticCell;
57
58    use crate::time_sync::UnixSeconds;
59    use crate::{Error, Result};
60
61    // ============================================================================
62    // Types
63    // ============================================================================
64
65    /// Result of a time sync attempt. Emitted by [`TimeSync`] when sync completes.
66    #[derive(Debug, defmt::Format)]
67    pub enum TimeSyncEvent {
68        /// Time synchronization succeeded with the given Unix seconds.
69        Ok(UnixSeconds),
70        /// Time synchronization failed with the given error message.
71        Err(&'static str),
72    }
73
74    /// Signal type used by [`TimeSync`] to publish events (see [`TimeSync`] docs).
75    type TimeSyncEvents = Signal<CriticalSectionRawMutex, TimeSyncEvent>;
76
77    /// Resources needed to construct a [`TimeSync`] (see [`TimeSync`] docs).
78    pub struct TimeSyncStatic {
79        events: TimeSyncEvents,
80        time_sync_cell: StaticCell<TimeSync>,
81    }
82
83    // ============================================================================
84    // TimeSync Virtual Device
85    // ============================================================================
86
87    /// Device abstraction that manages Network Time Protocol (NTP) synchronization over WiFi.
88    ///
89    /// Uses an existing network stack (typically from [`WifiAuto`](crate::wifi_auto::WifiAuto)).
90    ///
91    /// # Sync Timing
92    ///
93    /// - **Initial sync**: Fires immediately on start (retries at 10s, 30s, 60s, then 5min intervals if failed)
94    /// - **Periodic sync**: After first success, syncs every hour (retries every 5min on failure)
95    ///
96    /// See the [`ClockSync` struct example](crate::clock_sync::ClockSync) for usage.
97    pub struct TimeSync {
98        events: &'static TimeSyncEvents,
99    }
100
101    impl TimeSync {
102        /// Create [`TimeSync`] resources. See [`TimeSync`] docs for usage.
103        #[must_use]
104        pub const fn new_static() -> TimeSyncStatic {
105            TimeSyncStatic {
106                events: Signal::new(),
107                time_sync_cell: StaticCell::new(),
108            }
109        }
110
111        /// Create a [`TimeSync`] that uses an existing Embassy stack.
112        ///
113        /// WiFi is managed elsewhere (e.g. via [`WifiAuto`](crate::wifi_auto::WifiAuto))
114        /// and the networking stack is already initialized in client mode.
115        pub fn new(
116            time_sync_static: &'static TimeSyncStatic,
117            stack: &'static Stack<'static>,
118            spawner: Spawner,
119        ) -> &'static Self {
120            unwrap!(spawner.spawn(time_sync_stack_loop(stack, &time_sync_static.events,)));
121
122            time_sync_static.time_sync_cell.init(Self {
123                events: &time_sync_static.events,
124            })
125        }
126
127        /// Wait for and return the next [`TimeSyncEvent`]. See [`TimeSync`] docs for an example.
128        pub async fn wait_for_sync(&self) -> TimeSyncEvent {
129            self.events.wait().await
130        }
131    }
132
133    #[embassy_executor::task]
134    async fn time_sync_stack_loop(
135        stack: &'static Stack<'static>,
136        sync_events: &'static TimeSyncEvents,
137    ) -> ! {
138        let err = run_time_sync_loop(stack, sync_events).await.unwrap_err();
139        panic!("{err}");
140    }
141
142    async fn run_time_sync_loop(
143        stack: &'static Stack<'static>,
144        sync_events: &'static TimeSyncEvents,
145    ) -> Result<Infallible> {
146        info!("TimeSync received network stack");
147        info!("TimeSync device started");
148
149        // Initial sync with retry (exponential backoff: 10s, 30s, 60s, then 5min intervals)
150        let mut attempt = 0;
151        loop {
152            attempt += 1;
153            info!("Sync attempt {}", attempt);
154            match fetch_ntp_time(stack).await {
155                Ok(unix_seconds) => {
156                    info!(
157                        "Initial sync successful: unix_seconds={}",
158                        unix_seconds.as_i64()
159                    );
160
161                    sync_events.signal(TimeSyncEvent::Ok(unix_seconds));
162                    break;
163                }
164                Err(e) => {
165                    if let Error::Ntp(msg) = e {
166                        info!("Sync failed: {}", msg);
167                        sync_events.signal(TimeSyncEvent::Err(msg));
168                    }
169                    // Exponential backoff: 10s, 30s, 60s, then 5min intervals
170                    let delay_secs = if attempt == 1 {
171                        10
172                    } else if attempt == 2 {
173                        30
174                    } else if attempt == 3 {
175                        60
176                    } else {
177                        300 // 5 minutes for subsequent attempts
178                    };
179                    info!("Sync failed, retrying in {}s...", delay_secs);
180                    Timer::after_secs(delay_secs).await;
181                }
182            }
183        }
184
185        // Hourly sync loop (on failure, retry every 5 minutes)
186        let mut last_success_elapsed = 0_u64;
187        loop {
188            // Wait 1 hour after last success, or 5 minutes after failure
189            let wait_secs = if last_success_elapsed == 0 { 3600 } else { 300 };
190            Timer::after_secs(wait_secs).await;
191            last_success_elapsed = last_success_elapsed.saturating_add(wait_secs);
192
193            info!(
194                "Periodic sync ({}s since last success)...",
195                last_success_elapsed
196            );
197            match fetch_ntp_time(stack).await {
198                Ok(unix_seconds) => {
199                    info!(
200                        "Periodic sync successful: unix_seconds={}",
201                        unix_seconds.as_i64()
202                    );
203
204                    sync_events.signal(TimeSyncEvent::Ok(unix_seconds));
205                    last_success_elapsed = 0; // reset backoff
206                }
207                Err(e) => {
208                    if let Error::Ntp(msg) = e {
209                        info!("Periodic sync failed: {}", msg);
210                        sync_events.signal(TimeSyncEvent::Err(msg));
211                    }
212                    info!("Sync failed, will retry in 5 minutes");
213                }
214            }
215        }
216    }
217
218    // ============================================================================
219    // Network - Network Time Protocol (NTP) Fetch
220    // ============================================================================
221
222    async fn fetch_ntp_time(stack: &Stack<'static>) -> Result<UnixSeconds> {
223        use dns::DnsQueryType;
224        use udp::UdpSocket;
225
226        // Network Time Protocol (NTP) server configuration
227        const NTP_SERVER: &str = "pool.ntp.org";
228        const NTP_PORT: u16 = 123;
229
230        // DNS lookup
231        info!(
232            "Resolving Network Time Protocol (NTP) host {}...",
233            NTP_SERVER
234        );
235        let dns_result = stack
236            .dns_query(NTP_SERVER, DnsQueryType::A)
237            .await
238            .map_err(|e| {
239                warn!("DNS lookup failed: {:?}", e);
240                Error::Ntp("DNS lookup failed")
241            })?;
242        let server_addr = dns_result.first().ok_or(Error::Ntp("No DNS results"))?;
243
244        info!("Network Time Protocol (NTP) server IP: {}", server_addr);
245
246        // Create UDP socket
247        let mut rx_meta = [udp::PacketMetadata::EMPTY; 1];
248        let mut rx_buffer = [0; 128];
249        let mut tx_meta = [udp::PacketMetadata::EMPTY; 1];
250        let mut tx_buffer = [0; 128];
251        let mut socket = UdpSocket::new(
252            *stack,
253            &mut rx_meta,
254            &mut rx_buffer,
255            &mut tx_meta,
256            &mut tx_buffer,
257        );
258
259        socket.bind(0).map_err(|e| {
260            warn!("Socket bind failed: {:?}", e);
261            Error::Ntp("Socket bind failed")
262        })?;
263
264        // Build Network Time Protocol (NTP) request (48 bytes, version 3, client mode)
265        let mut ntp_request = [0u8; 48];
266        ntp_request[0] = 0x1B; // LI=0, VN=3, Mode=3 (client)
267
268        // Send request
269        info!(
270            "Sending Network Time Protocol (NTP) request to {}...",
271            server_addr
272        );
273        socket
274            .send_to(&ntp_request, (*server_addr, NTP_PORT))
275            .await
276            .map_err(|e| {
277                warn!("Network Time Protocol (NTP) send failed: {:?}", e);
278                Error::Ntp("Network Time Protocol (NTP) send failed")
279            })?;
280
281        // Receive response with timeout
282        let mut response = [0u8; 48];
283        let (n, _from) =
284            embassy_time::with_timeout(Duration::from_secs(5), socket.recv_from(&mut response))
285                .await
286                .map_err(|_| {
287                    warn!("Network Time Protocol (NTP) receive timeout");
288                    Error::Ntp("Network Time Protocol (NTP) receive timeout")
289                })?
290                .map_err(|e| {
291                    warn!("Network Time Protocol (NTP) receive failed: {:?}", e);
292                    Error::Ntp("Network Time Protocol (NTP) receive failed")
293                })?;
294
295        if n < 48 {
296            warn!(
297                "Network Time Protocol (NTP) response too short: {} bytes",
298                n
299            );
300            return Err(Error::Ntp("Network Time Protocol (NTP) response too short"));
301        }
302
303        // Extract Network Time Protocol (NTP) transmit timestamp (bytes 40-47, big-endian)
304        let ntp_seconds =
305            u32::from_be_bytes([response[40], response[41], response[42], response[43]]);
306
307        // Convert Network Time Protocol (NTP) timestamp to Unix seconds
308        let unix_time = UnixSeconds::from_ntp_seconds(ntp_seconds)
309            .ok_or(Error::Ntp("Invalid Network Time Protocol (NTP) timestamp"))?;
310
311        info!(
312            "Network Time Protocol (NTP) time: {} (unix timestamp)",
313            unix_time.as_i64()
314        );
315        Ok(unix_time)
316    }
317} // end wifi_impl module
318
319// Export wifi_impl types when wifi feature is enabled
320#[cfg(feature = "wifi")]
321pub use wifi_impl::{TimeSync, TimeSyncEvent, TimeSyncStatic};
322
323// ============================================================================
324// No-WiFi Stub Implementation
325// ============================================================================
326
327#[cfg(not(feature = "wifi"))]
328mod stub {
329    use crate::time_sync::UnixSeconds;
330    use embassy_executor::Spawner;
331    use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
332    use embassy_sync::signal::Signal;
333    use static_cell::StaticCell;
334
335    /// Result of a time sync attempt. Emitted by [`TimeSync`] when sync completes.
336    /// (Same structure as WiFi impl; the stub never emits events.)
337    #[derive(Debug, defmt::Format)]
338    pub enum TimeSyncEvent {
339        /// Time synchronization succeeded with the given Unix seconds.
340        Ok(UnixSeconds),
341        /// Time synchronization failed with the given error message.
342        Err(&'static str),
343    }
344
345    /// Signal type that mirrors the WiFi implementation (see [`TimeSync`] docs).
346    type TimeSyncEvents = Signal<CriticalSectionRawMutex, TimeSyncEvent>;
347
348    /// Static used to construct a [`TimeSync`] instance (see [`TimeSync`] docs).
349    pub struct TimeSyncStatic {
350        events: TimeSyncEvents,
351        time_sync_cell: StaticCell<TimeSync>,
352    }
353
354    /// Minimal [`TimeSync`] stub that never produces events. See the WiFi [`TimeSync`] docs for examples.
355    pub struct TimeSync {
356        events: &'static TimeSyncEvents,
357    }
358
359    impl TimeSync {
360        /// Create [`TimeSync`] resources (see [`TimeSync`] docs for the full device setup).
361        #[must_use]
362        pub const fn new_static() -> TimeSyncStatic {
363            TimeSyncStatic {
364                events: Signal::new(),
365                time_sync_cell: StaticCell::new(),
366            }
367        }
368
369        /// Construct the stub device and retain compatibility with [`TimeSync`] docs.
370        pub fn new(time_sync_static: &'static TimeSyncStatic, _spawner: Spawner) -> &'static Self {
371            time_sync_static.time_sync_cell.init(Self {
372                events: &time_sync_static.events,
373            })
374        }
375
376        /// Wait for the next [`TimeSyncEvent`]. This stub never signals, so waits forever (disabling sync).
377        pub async fn wait_for_sync(&self) -> TimeSyncEvent {
378            self.events.wait().await
379        }
380    }
381}
382
383#[cfg(not(feature = "wifi"))]
384pub use stub::{TimeSync, TimeSyncEvent, TimeSyncStatic};