From f9cd45fff8875b8521ef610a2adf1e4ae9f59150 Mon Sep 17 00:00:00 2001 From: Sachymetsu Date: Mon, 15 Dec 2025 16:56:58 +0100 Subject: [PATCH] feat: SNTP crate Change-Id: nywrykvqknkswvyyumprlwvpyqwtqyzr --- Cargo.lock | 228 ++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 11 +- sachy-fmt/Cargo.toml | 2 +- sachy-sntp/Cargo.toml | 26 +++++ sachy-sntp/src/lib.rs | 209 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 465 insertions(+), 11 deletions(-) create mode 100644 sachy-sntp/Cargo.toml create mode 100644 sachy-sntp/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index a22eafc..dd41970 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "num-traits", +] + [[package]] name = "core-foundation" version = "0.10.0" @@ -64,6 +73,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "defmt" version = "0.3.100" @@ -105,6 +120,87 @@ dependencies = [ "thiserror", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "embassy-net" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0558a231a47e7d4a06a28b5278c92e860f1200f24821d2f365a2f40fe3f3c7b2" +dependencies = [ + "document-features", + "embassy-net-driver", + "embassy-sync", + "embassy-time", + "embedded-io-async", + "embedded-nal-async", + "heapless 0.8.0", + "managed", + "smoltcp", +] + +[[package]] +name = "embassy-net-driver" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" + +[[package]] +name = "embassy-sync" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" +dependencies = [ + "cfg-if", + "critical-section", + "embedded-io-async", + "futures-core", + "futures-sink", + "heapless 0.8.0", +] + +[[package]] +name = "embassy-time" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4fa65b9284d974dad7a23bb72835c4ec85c0b540d86af7fc4098c88cff51d65" +dependencies = [ + "cfg-if", + "critical-section", + "document-features", + "embassy-time-driver", + "embedded-hal 0.2.7", + "embedded-hal 1.0.0", + "embedded-hal-async", + "futures-core", +] + +[[package]] +name = "embassy-time-driver" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0a244c7dc22c8d0289379c8d8830cae06bb93d8f990194d0de5efb3b5ae7ba6" +dependencies = [ + "document-features", +] + +[[package]] +name = "embedded-hal" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" +dependencies = [ + "nb 0.1.3", + "void", +] + [[package]] name = "embedded-hal" version = "1.0.0" @@ -120,7 +216,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" dependencies = [ - "embedded-hal", + "embedded-hal 1.0.0", ] [[package]] @@ -129,7 +225,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9a0f04f8886106faf281c47b6a0e4054a369baedaf63591fdb8da9761f3f379" dependencies = [ - "embedded-hal", + "embedded-hal 1.0.0", "embedded-hal-nb", ] @@ -139,8 +235,42 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fba4268c14288c828995299e59b12babdbe170f6c6d73731af1b4648142e8605" dependencies = [ - "embedded-hal", - "nb", + "embedded-hal 1.0.0", + "nb 1.1.0", +] + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "embedded-io-async" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" +dependencies = [ + "embedded-io", +] + +[[package]] +name = "embedded-nal" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56a28be191a992f28f178ec338a0bf02f63d7803244add736d026a471e6ed77" +dependencies = [ + "nb 1.1.0", +] + +[[package]] +name = "embedded-nal-async" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76959917cd2b86f40a98c28dd5624eddd1fa69d746241c8257eac428d83cb211" +dependencies = [ + "embedded-io-async", + "embedded-nal", ] [[package]] @@ -149,6 +279,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + [[package]] name = "gpio-cdev" version = "0.6.0" @@ -169,6 +311,16 @@ dependencies = [ "byteorder", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.9.2" @@ -215,17 +367,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8a605c95f708c78554738a12153b213f107d3bd5323f7ce32d6deb3faafb40" dependencies = [ "cast", - "embedded-hal", + "embedded-hal 1.0.0", "embedded-hal-nb", "gpio-cdev", "i2cdev", - "nb", + "nb 1.1.0", "nix 0.27.1", "serialport", "spidev", "sysfs_gpio", ] +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "mach2" version = "0.4.3" @@ -235,6 +393,12 @@ dependencies = [ "libc", ] +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "memoffset" version = "0.6.5" @@ -253,6 +417,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nb" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" +dependencies = [ + "nb 1.1.0", +] + [[package]] name = "nb" version = "1.1.0" @@ -296,6 +469,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "pin-utils" version = "0.1.0" @@ -351,7 +533,7 @@ name = "sachy-bthome" version = "0.1.0" dependencies = [ "defmt 1.0.1", - "heapless", + "heapless 0.9.2", "sachy-fmt", ] @@ -371,12 +553,23 @@ name = "sachy-shtc3" version = "0.1.0" dependencies = [ "defmt 1.0.1", - "embedded-hal", + "embedded-hal 1.0.0", "embedded-hal-async", "embedded-hal-mock", "linux-embedded-hal", ] +[[package]] +name = "sachy-sntp" +version = "0.1.0" +dependencies = [ + "chrono", + "defmt 1.0.1", + "embassy-net", + "embassy-time", + "sachy-fmt", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -408,6 +601,19 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "smoltcp" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "heapless 0.8.0", + "managed", +] + [[package]] name = "spidev" version = "0.6.1" @@ -480,6 +686,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index ec929cf..8b4b321 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["sachy-battery","sachy-bthome", "sachy-fmt", "sachy-fnv", "sachy-shtc3"] +members = ["sachy-battery","sachy-bthome", "sachy-fmt", "sachy-fnv", "sachy-shtc3", "sachy-sntp"] [workspace.package] authors = ["Sachymetsu "] @@ -8,4 +8,11 @@ edition = "2024" repository = "https://tangled.org/sachy.dev/sachy-embed-core/" license = "MIT OR Apache-2.0" version = "0.1.0" -rust-version = "1.88.0" +rust-version = "1.89.0" + +[workspace.dependencies] +embassy-futures = { version = "0.1" } +embassy-time = { version = "0.5" } +embassy-sync = { version = "0.7" } +embassy-net = { version = "0.7" } +defmt = { version = "1" } diff --git a/sachy-fmt/Cargo.toml b/sachy-fmt/Cargo.toml index e68a60d..acd7e1f 100644 --- a/sachy-fmt/Cargo.toml +++ b/sachy-fmt/Cargo.toml @@ -9,7 +9,7 @@ license = { workspace = true } rust-version = { workspace = true } [dependencies] -defmt = { version = "1", optional = true } +defmt = { workspace = true, optional = true } [features] defmt = ["dep:defmt"] diff --git a/sachy-sntp/Cargo.toml b/sachy-sntp/Cargo.toml new file mode 100644 index 0000000..c391260 --- /dev/null +++ b/sachy-sntp/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sachy-sntp" +authors.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +version.workspace = true +rust-version.workspace = true + +[dependencies] +chrono = { version = "0.4.41", default-features = false, optional = true } +defmt = { workspace = true, optional = true } +embassy-net = { workspace = true, features = [ + "udp", + "proto-ipv4", + "medium-ip", + "medium-ethernet", +], optional = true } +embassy-time = { workspace = true, optional = true } +sachy-fmt = { path = "../sachy-fmt" } + +[features] +default = ["chrono", "embassy-net"] +chrono = ["dep:chrono"] +embassy-net = ["dep:embassy-net", "dep:embassy-time"] +defmt = ["dep:defmt"] diff --git a/sachy-sntp/src/lib.rs b/sachy-sntp/src/lib.rs new file mode 100644 index 0000000..4bf20dd --- /dev/null +++ b/sachy-sntp/src/lib.rs @@ -0,0 +1,209 @@ +#![no_std] + +#[derive(Debug, PartialEq, Eq)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub enum SntpError { + InvalidPacket, + InvalidVersion, + InvalidMode, + KissOfDeath, + InvalidTime, + NetFailure, + NetTimeout, +} + +pub struct SntpRequest; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +/// SNTP timestamp. +pub struct SntpTimestamp(u64); + +impl SntpTimestamp { + pub fn microseconds(&self) -> u64 { + (self.0 >> 32) * 1_000_000 + ((self.0 & 0xFFFFFFFF) * 1_000_000 / 0x100000000) + } + + /// Returns true if the most significant bit is set. + /// + /// Relevant documentation from RFC 2030: + /// + /// ```text + /// Note that, since some time in 1968 (second 2,147,483,648) the most + /// significant bit (bit 0 of the integer part) has been set and that the + /// 64-bit field will overflow some time in 2036 (second 4,294,967,296). + /// Should NTP or SNTP be in use in 2036, some external means will be + /// necessary to qualify time relative to 1900 and time relative to 2036 + /// (and other multiples of 136 years). There will exist a 200-picosecond + /// interval, henceforth ignored, every 136 years when the 64-bit field + /// will be 0, which by convention is interpreted as an invalid or + /// unavailable timestamp. + /// As the NTP timestamp format has been in use for the last 17 years, + /// it remains a possibility that it will be in use 40 years from now + /// when the seconds field overflows. As it is probably inappropriate + /// to archive NTP timestamps before bit 0 was set in 1968, a + /// convenient way to extend the useful life of NTP timestamps is the + /// following convention: If bit 0 is set, the UTC time is in the + /// range 1968-2036 and UTC time is reckoned from 0h 0m 0s UTC on 1 + /// January 1900. If bit 0 is not set, the time is in the range 2036- + /// 2104 and UTC time is reckoned from 6h 28m 16s UTC on 7 February + /// 2036. Note that when calculating the correspondence, 2000 is not a + /// leap year. Note also that leap seconds are not counted in the + /// reckoning. + ///``` + pub fn msb_set(&self) -> bool { + self.0 & (1 << 63) != 0 + } + + /// Microseconds since the UNIX epoch. + pub fn utc_micros(&self) -> i64 { + let ntp_epoch_micros = self.microseconds() as i64; + let offset: i64 = if self.msb_set() { + -2208988800000000 + } else { + 2085978496000000 + }; + + ntp_epoch_micros + offset + } + + #[cfg(feature = "chrono")] + pub fn try_to_naive_datetime(self) -> Result { + self.try_into() + } +} + +impl SntpRequest { + pub const SNTP_PACKET_SIZE: usize = 48; + + pub const fn create_packet() -> [u8; Self::SNTP_PACKET_SIZE] { + let mut packet = [0u8; Self::SNTP_PACKET_SIZE]; + packet[0] = (3 << 6) | (4 << 3) | 3; + packet + } + + pub fn create_packet_from_buffer(packet: &mut [u8]) -> Result<(), SntpError> { + if packet.len() != Self::SNTP_PACKET_SIZE { + return Err(SntpError::InvalidPacket); + } + + packet[0] = (3 << 6) | (4 << 3) | 3; + + Ok(()) + } + + pub fn read_timestamp(packet: &[u8]) -> Result { + if packet.len() == Self::SNTP_PACKET_SIZE { + let header = packet[0]; + let version = (header & 0x38) >> 3; + + if version != 4 { + return Err(SntpError::InvalidVersion); + } + + let mode = header & 0x7; + + if !(4..=5).contains(&mode) { + return Err(SntpError::InvalidMode); + } + + let kiss_of_death = packet[1] == 0; + + if kiss_of_death { + return Err(SntpError::KissOfDeath); + } + + let timestamp = SntpTimestamp(read_be_u64(&packet[40..48])); + + return Ok(timestamp); + } + + Err(SntpError::InvalidPacket) + } + + #[cfg(feature = "chrono")] + pub fn as_naive_datetime(raw_time: SntpTimestamp) -> Result { + raw_time.try_into() + } +} + +#[cfg(feature = "chrono")] +impl TryFrom for chrono::NaiveDateTime { + type Error = SntpError; + + fn try_from(timestamp: SntpTimestamp) -> Result { + Ok(chrono::DateTime::::try_from(timestamp)?.naive_utc()) + } +} + +#[cfg(feature = "chrono")] +impl TryFrom for chrono::DateTime { + type Error = SntpError; + + fn try_from(timestamp: SntpTimestamp) -> Result { + chrono::DateTime::::from_timestamp_micros(timestamp.utc_micros()) + .ok_or(SntpError::InvalidTime) + } +} + +#[inline] +fn read_be_u64(input: &[u8]) -> u64 { + let (int_bytes, _) = input.split_at(core::mem::size_of::()); + u64::from_be_bytes(int_bytes.try_into().unwrap()) +} + +#[cfg(feature = "embassy-net")] +pub trait SntpSocket { + fn resolve_time( + &mut self, + addrs: &[embassy_net::IpAddress], + ) -> impl Future>; +} + +#[cfg(feature = "embassy-net")] +impl SntpSocket for embassy_net::udp::UdpSocket<'_> { + async fn resolve_time( + &mut self, + addrs: &[embassy_net::IpAddress], + ) -> Result { + use embassy_time::{Duration, WithTimeout}; + use sachy_fmt::{debug, error}; + + if addrs.is_empty() { + return Err(SntpError::NetFailure); + } + + debug!("SNTP address list: {}", addrs); + + let addr = addrs[0]; + + debug!("Binding to port 123"); + self.bind(123).map_err(|_| SntpError::NetFailure)?; + + debug!("Sending SNTP request"); + self.send_to_with( + SntpRequest::SNTP_PACKET_SIZE, + embassy_net::IpEndpoint::new(addr, 123), + SntpRequest::create_packet_from_buffer, + ) + .await + .map_err(|e| { + error!("Failed to send: {}", e); + SntpError::NetFailure + })??; + + debug!("Waiting for a response..."); + let res = self + .recv_from_with(|buf, _from| SntpRequest::read_timestamp(buf)) + .with_timeout(Duration::from_secs(5)) + .await + .map_err(|_| SntpError::NetTimeout) + .flatten(); + + debug!("Received: {}", &res); + debug!("Closing SNTP port..."); + self.close(); + + res + } +} -- 2.52.0