Something went wrong. Try again.
Something went wrong. Try again.
12 kB · 328 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329//! Wall clock: fetched from timeapi.io over HTTPS, then counted on the crystal.//!//! Each sync records the fetched UTC time together with the [`Instant`] it arrived at,//! and reads add the time elapsed since. `Instant` runs on TIMG0, clocked from the//! 40 MHz crystal (tens of ppm), so the clock holds to well under a second an hour.//!//! The ESP32's RTC timer is *not* used for reading. The CYD has no 32 kHz crystal, so//! that timer counts the internal ~150 kHz RC oscillator, which esp-hal calibrates once//! at boot over 16 cycles and never again; as the board warms up it drifted about 1 s a//! minute. The RTC is still set on every sync so it keeps counting across soft resets//! and deep sleep, where TIMG0 stops.//!//! The time is fetched at boot and then every [`RESYNC_INTERVAL`] (which also picks up//! DST changes, since the UTC offset comes from the same response).
use core::cell::{Cell, RefCell};use core::fmt;use core::sync::atomic::{AtomicI32, Ordering};
use embassy_executor::Spawner;use embassy_futures::select::select;use embassy_net::{ Stack, dns::DnsSocket, tcp::client::{TcpClient, TcpClientState},};use embassy_sync::{ blocking_mutex::{Mutex, raw::CriticalSectionRawMutex}, signal::Signal,};use embassy_time::{Duration, Instant, Timer, with_timeout};use esp_hal::{rng::Rng, rtc_cntl::Rtc};use log::{info, warn};use reqwless::{ client::{HttpClient, TlsConfig, TlsVerify}, request::Method,};use static_cell::ConstStaticCell;
const URL: &str = concat!( "https://timeapi.io/api/v1/time/current/zone?timezone=", env!("TIMEZONE"),);
const RESYNC_INTERVAL: Duration = Duration::from_secs(60 * 60);const RETRY_DELAY: Duration = Duration::from_secs(30);const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// A TLS record can be 16 KiB plus header and tag; the server's certificate chain/// arrives in one, so the read buffer must hold a full record.const TLS_RX_SIZE: usize = 16640;const TLS_TX_SIZE: usize = 4096;const HTTP_RX_SIZE: usize = 2048;const TCP_BUF_SIZE: usize = 2048;
/// A fetched UTC time and the [`Instant`] it was current at.#[derive(Debug, Clone, Copy)]struct Anchor { utc_us: u64, at: Instant,}
impl Anchor { /// UTC microseconds now, counted forward on the crystal. fn now_us(&self) -> u64 { self.utc_us + Instant::now().duration_since(self.at).as_micros() }}
/// `None` until the first successful sync.static ANCHOR: Mutex<CriticalSectionRawMutex, Cell<Option<Anchor>>> = Mutex::new(Cell::new(None));static RTC: Mutex<CriticalSectionRawMutex, RefCell<Option<Rtc<'static>>>> = Mutex::new(RefCell::new(None));static UTC_OFFSET_S: AtomicI32 = AtomicI32::new(0);
/// Signal to sync now instead of waiting for the next scheduled resync.pub static RESYNC: Signal<CriticalSectionRawMutex, ()> = Signal::new();
/// Take ownership of the RTC and spawn the task that keeps it in sync.pub fn start(spawner: &Spawner, stack: Stack<'static>, rtc: Rtc<'static>) { RTC.lock(|cell| cell.replace(Some(rtc))); spawner.spawn(sync_task(stack).unwrap());}
/// Local time from the RTC, or `None` until the first successful sync.pub fn now_local() -> Option<LocalTime> { let local_s = now_utc()? + UTC_OFFSET_S.load(Ordering::Relaxed) as i64; Some(LocalTime::from_unix(local_s))}
/// Seconds since the Unix epoch (UTC), or `None` until the first successful sync.pub fn now_utc() -> Option<i64> { let anchor = ANCHOR.lock(|cell| cell.get())?; Some((anchor.now_us() / 1_000_000) as i64)}
/// Unix seconds for an RFC 3339 timestamp such as `2026-09-24T00:26:39Z` or/// `2026-09-24T00:26:39.123-05:00`. Fractional seconds are dropped.pub fn parse_rfc3339(s: &str) -> Option<i64> { let num = |from: usize, to: usize| s.get(from..to)?.parse::<i64>().ok(); let days = days_from_civil(num(0, 4)?, num(5, 7)?, num(8, 10)?); let secs = days * 86_400 + num(11, 13)? * 3600 + num(14, 16)? * 60 + num(17, 19)?;
let zone = s.get(19..)?.trim_start_matches(|c: char| c == '.' || c.is_ascii_digit()); let offset = match zone.as_bytes().first()? { b'Z' | b'z' => 0, sign @ (b'+' | b'-') => { let h: i64 = zone.get(1..3)?.parse().ok()?; let m: i64 = zone.get(4..6)?.parse().ok()?; let offset = h * 3600 + m * 60; if *sign == b'-' { -offset } else { offset } } _ => return None, }; Some(secs - offset)}
#[embassy_executor::task]async fn sync_task(stack: Stack<'static>) { static TCP_STATE: ConstStaticCell<TcpClientState<1, TCP_BUF_SIZE, TCP_BUF_SIZE>> = ConstStaticCell::new(TcpClientState::new()); static TLS_RX: ConstStaticCell<[u8; TLS_RX_SIZE]> = ConstStaticCell::new([0; TLS_RX_SIZE]); static TLS_TX: ConstStaticCell<[u8; TLS_TX_SIZE]> = ConstStaticCell::new([0; TLS_TX_SIZE]); static HTTP_RX: ConstStaticCell<[u8; HTTP_RX_SIZE]> = ConstStaticCell::new([0; HTTP_RX_SIZE]);
let tcp = TcpClient::new(stack, TCP_STATE.take()); let dns = DnsSocket::new(stack); let tls_rx = TLS_RX.take(); let tls_tx = TLS_TX.take(); let http_rx = HTTP_RX.take(); let rng = Rng::new();
loop { stack.wait_config_up().await; // A request made while this fetch runs is covered by it. RESYNC.reset();
let seed = ((rng.random() as u64) << 32) | rng.random() as u64; let tls = TlsConfig::new(seed, tls_rx, tls_tx, TlsVerify::None); let mut client = HttpClient::new_with_tls(&tcp, &dns, tls);
let delay = match with_timeout(REQUEST_TIMEOUT, fetch(&mut client, http_rx)).await { Ok(Ok((utc_us, offset_s))) => { let anchor = Anchor { utc_us, at: Instant::now(), }; // How far each clock had wandered since the last sync: the crystal // count is what the display shows, the RTC is logged for comparison. let previous = ANCHOR.lock(|cell| cell.replace(Some(anchor))); let rtc_us = RTC.lock(|cell| { let cell = cell.borrow(); let rtc = cell.as_ref()?; let before = rtc.current_time_us(); rtc.set_current_time_us(utc_us); Some(before) }); UTC_OFFSET_S.store(offset_s, Ordering::Relaxed); info!("clock set: {utc_us} us UTC, offset {offset_s} s"); if let Some(previous) = previous { let crystal_ms = (previous.now_us() as i64 - utc_us as i64) / 1000; let rtc_ms = rtc_us.map_or(0, |us| (us as i64 - utc_us as i64) / 1000); info!("clock drift since last sync: crystal {crystal_ms} ms, rtc {rtc_ms} ms"); } RESYNC_INTERVAL } Ok(Err(e)) => { warn!("time sync failed: {e:?}"); RETRY_DELAY } Err(_) => { warn!("time sync timed out"); RETRY_DELAY } }; select(Timer::after(delay), RESYNC.wait()).await; }}
type Tcp<'a> = TcpClient<'a, 1, TCP_BUF_SIZE, TCP_BUF_SIZE>;type Client<'a> = HttpClient<'a, Tcp<'a>, DnsSocket<'a>>;
#[derive(Debug)]#[allow(dead_code, reason = "fields are only read through Debug, in the log")]enum SyncError { Http(reqwless::Error), Status(u16), Parse,}
impl From<reqwless::Error> for SyncError { fn from(e: reqwless::Error) -> Self { Self::Http(e) }}
/// GET the time API and return (UTC microseconds since the Unix epoch, UTC offset seconds).async fn fetch(client: &mut Client<'_>, rx: &mut [u8]) -> Result<(u64, i32), SyncError> { let mut request = client.request(Method::GET, URL).await?; let response = request.send(rx).await?; if !response.status.is_successful() { return Err(SyncError::Status(response.status.0)); } let body = response.body().read_to_end().await?; parse(body).ok_or(SyncError::Parse)}
/// Pull `date_time` (local wall time) and `utc_offset_seconds` out of a response like/// `{"date_time":"2026-09-23T16:49:49.605192-05:00",...,"utc_offset_seconds":-18000}`.fn parse(body: &[u8]) -> Option<(u64, i32)> { let s = core::str::from_utf8(body).ok()?; let dt = after(s, "\"date_time\":\"")?; let offset_s: i32 = { let rest = after(s, "\"utc_offset_seconds\":")?; let end = rest .find(|c: char| c != '-' && !c.is_ascii_digit()) .unwrap_or(rest.len()); rest[..end].parse().ok()? };
// YYYY-MM-DDTHH:MM:SS[.ffffff] let num = |from: usize, to: usize| dt.get(from..to)?.parse::<i64>().ok(); let days = days_from_civil(num(0, 4)?, num(5, 7)?, num(8, 10)?); let local_s = days * 86_400 + num(11, 13)? * 3600 + num(14, 16)? * 60 + num(17, 19)?;
let mut frac_us = 0i64; if let Some(frac) = dt.get(19..).and_then(|r| r.strip_prefix('.')) { let mut scale = 100_000; for c in frac.chars().take_while(char::is_ascii_digit).take(6) { frac_us += (c as i64 - '0' as i64) * scale; scale /= 10; } }
let utc_us = (local_s - offset_s as i64) * 1_000_000 + frac_us; Some((u64::try_from(utc_us).ok()?, offset_s))}
fn after<'a>(s: &'a str, key: &str) -> Option<&'a str> { s.find(key).map(|i| &s[i + key.len()..])}
/// Days since 1970-01-01 for a proleptic Gregorian date (Howard Hinnant's algorithm).fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { let y = if m <= 2 { y - 1 } else { y }; let era = y.div_euclid(400); let yoe = y - era * 400; let mp = (m + 9) % 12; let doy = (153 * mp + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468}
/// Inverse of [`days_from_civil`]: (year, month, day).fn civil_from_days(z: i64) -> (i64, u8, u8) { let z = z + 719_468; let era = z.div_euclid(146_097); let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u8; let m = if mp < 10 { mp + 3 } else { mp - 9 } as u8; let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 }; (y, m, d)}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct LocalTime { pub year: i64, pub month: u8, pub day: u8, pub hour: u8, pub minute: u8, pub second: u8, /// 0 = Sunday. pub weekday: u8,}
impl LocalTime { fn from_unix(secs: i64) -> Self { let days = secs.div_euclid(86_400); let sod = secs.rem_euclid(86_400); let (year, month, day) = civil_from_days(days); Self { year, month, day, hour: (sod / 3600) as u8, minute: (sod / 60 % 60) as u8, second: (sod % 60) as u8, // 1970-01-01 was a Thursday. weekday: (days + 4).rem_euclid(7) as u8, } }}
impl LocalTime { /// `Wed 09/23`. pub fn date(&self) -> impl fmt::Display + '_ { const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; fmt::from_fn(move |f| { write!( f, "{} {:02}/{:02}", DAYS[self.weekday as usize], self.month, self.day ) }) }
/// `04:49:49 PM`. pub fn time(&self) -> impl fmt::Display + '_ { let (hour12, ampm) = match self.hour { 0 => (12, "AM"), 1..=11 => (self.hour, "AM"), 12 => (12, "PM"), h => (h - 12, "PM"), }; fmt::from_fn(move |f| { write!( f, "{:02}:{:02}:{:02} {}", hour12, self.minute, self.second, ampm ) }) }}