[](https://github.com/vpetrigo/sntpc/actions/workflows/ci.yml)
[](https://crates.io/crates/sntpc)
[](https://docs.rs/sntpc)
[](https://codecov.io/gh/vpetrigo/sntpc)
# Simple Rust SNTP client
This crate provides a method for sending requests to NTP servers and process responses, extracting received timestamp.
Supported SNTP protocol versions:
- [SNTPv4 (RFC 5905)](https://datatracker.ietf.org/doc/html/rfc5905)
> **Note**: Broadcast mode (NTP mode 5) is not supported. This library only supports unicast client mode (mode 3 → mode
> 4).
### Features
- `std` - Standard library support (includes `StdTimestampGen`)
- `sync` - Synchronous API in `sync` module
- `dispersion` - Enables root dispersion estimation per RFC 5905 §9.2
- `utils` - OS-specific utilities for system time sync
- `log` / `defmt` - Debug logging
### Return value: `NtpResult`
The `get_time` and `sntp_process_response` functions return `NtpResult` which includes:
| `sec()` / `sec_fraction()` | Server-reported timestamp (seconds and fraction) |
| `roundtrip()` | Round-trip delay in microseconds |
| `offset()` | Estimated clock offset in microseconds |
| `stratum()` | Server stratum level |
| `precision()` | Server clock precision as log2(seconds) |
| `leap_indicator()` | Leap indicator (0-3) from the server response |
| `root_delay()` | Root delay in NTP short format (16.16 fixed-point) |
| `root_dispersion()` | Root dispersion in NTP short format (16.16 fixed-point) |
| `reference_id()` | Reference identifier (4-byte array in network byte order) |
| `reference_timestamp()` | Reference timestamp in NTP timestamp format |
| `poll()` | Server poll interval in log2 seconds |
| `dispersion()` | Estimated dispersion in microseconds (requires `dispersion` feature) |
`NtpResult.seconds` is `u64` because NTP wire timestamps carry only 32 bits of
seconds and roll over every 2^32 seconds. The normal `get_time` /
`sntp_process_response` APIs are rollover-safe by default.
### NTP era rollover and embedded cold start
NTP wire timestamps contain only 32 bits of seconds, so after the 2036
rollover the same wire value can represent multiple 2^32-second "eras".
`sntpc` reconstructs the era by asking the timestamp generator for its best
guess at the current Unix time (the *pivot*, `timestamp_gen.timestamp_sec()`)
and picking whichever of the three eras closest to that pivot best matches
the wire timestamp.
**This is a nearest-era guess, not a verified result.** If the pivot is
within about half an era (±2^31 seconds, ~68 years) of the truth, reconstruction
is correct. If the pivot is off by more than that, `sntpc` does **not** return
an error — it silently reconstructs into the wrong era and returns a
plausible-looking but incorrect timestamp. The only rejected input is a raw
wire timestamp of exactly zero (`Error::InvalidTimestamp`); a stale or
implausible pivot is never detected internally.
For embedded systems that may boot with an invalid RTC or an uptime-only
clock, keep persistence and pivot policy in your own generator and/or
application code. The snippet below is illustrative: `load_last_successful_sync`,
`validated_rtc_time`, `save_last_successful_sync`, and the internal pivot field
are all application-defined, not part of `sntpc`.
```rust,ignore
const FIRMWARE_BUILD_TIME: u64 = 1_735_689_600; // 2025-01-01, last resort floor
struct MyTimestampGen {
pivot: u64, // seconds since UNIX_EPOCH; app-managed, not an sntpc concept
}
impl MyTimestampGen {
fn new() -> Self {
// Your own fallback chain: saved sync time, then a validated RTC
// read, then the firmware build time as an absolute last resort.
let pivot = load_last_successful_sync()
.or_else(validated_rtc_time)
.unwrap_or(FIRMWARE_BUILD_TIME);
Self { pivot }
}
}
impl NtpTimestampGenerator for MyTimestampGen {
fn init(&mut self) { /* refresh self.pivot from your uptime/RTC source */ }
fn timestamp_sec(&self) -> u64 { self.pivot }
fn timestamp_subsec_micros(&self) -> u32 { 0 }
}
let context = NtpContext::new(MyTimestampGen::new());
let result = sntpc::get_time(addr, &socket, context).await?;
// Sanity-check the result before trusting or persisting it — see below.
if result.sec() >= FIRMWARE_BUILD_TIME {
save_last_successful_sync(result.sec());
} else {
// result.sec() before the firmware build time means the pivot was bad
// enough to land reconstruction in a stale era; do not persist it.
flag_suspect_sync(result.sec());
}
```
The crate intentionally does not manage flash/EEPROM/RTC persistence, and it
does not validate the pivot's plausibility. If no RTC, saved time, or other
external hint is available, the client cannot reliably infer the absolute NTP
era from a single SNTP response — only your application can decide what
"reasonable" means for its own deployment.
#### Sanity-checking sync results with no trusted RTC
If a device has no battery-backed RTC and no saved sync time (e.g. first
boot, or storage was wiped), the only pivot available is the firmware build
time. In that situation, after every successful sync:
- **Reject or flag results earlier than your build-time floor.** Since the
build time is a hard lower bound on "now", `result.sec() < FIRMWARE_BUILD_TIME`
is proof the reconstruction landed in a stale era (the true wire timestamp
was ambiguous and the nearest-era guess picked the wrong one). Treat such a
result as untrusted: don't persist it as the new pivot, and consider
retrying the sync or falling back to a different server.
- **Expect monotonicity within a boot session.** Once you have one trusted
sync result, later syncs in the same session should not regress to an
earlier time by more than your expected offset/network jitter. A large
backward jump between two syncs in the same session — with no era rollover
in between — is a strong signal that the second sync's pivot (or response)
should not be trusted, even if it's individually above the build-time
floor.
- **Persist the sync result, not just the pivot you started with.** Feeding
each `NtpTimestampGenerator` re-init from the previous successful
`result.sec()` (rather than from an unvalidated uptime counter) keeps the
pivot self-correcting across reboots, shrinking the ±68-year ambiguity
window down to whatever clock drift accumulated since the last sync.
### Usage example
- dependency for the app
```toml
[dependencies]
sntpc = { version = "0.11", features = ["sync"] }
```
- application code. Based on a socket implementation used, you may want to use supplementary crates with UDP socket
wrappers:
- [`sntpc-net-std`](https://docs.rs/sntpc-net-std): for standard library UDP sockets
- [`sntpc-net-embassy`](https://docs.rs/sntpc-net-embassy): for `embassy-net` UDP sockets
- [`sntpc-net-tokio`](https://docs.rs/sntpc-net-tokio): for `tokio` UDP sockets
- [`sntpc-time-embassy`](https://docs.rs/sntpc-time-embassy): timestamp generation based on the `embassy-time`
interface
Example below uses `sntpc-net-std` crate:
```toml
[dependencies]
sntpc-net-std = "1"
```
```rust
use sntpc::{sync::get_time, NtpContext, StdTimestampGen};
use sntpc_net_std::UdpSocketWrapper;
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
use std::thread;
use std::time::Duration;
#[allow(dead_code)]
const POOL_NTP_ADDR: &str = "pool.ntp.org:123";
#[allow(dead_code)]
const GOOGLE_NTP_ADDR: &str = "time.google.com:123";
fn main() {
#[cfg(feature = "log")]
if cfg!(debug_assertions) {
simple_logger::init_with_level(log::Level::Trace).unwrap();
} else {
simple_logger::init_with_level(log::Level::Info).unwrap();
}
let socket =
UdpSocket::bind("0.0.0.0:0").expect("Unable to crate UDP socket");
socket
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("Unable to set UDP socket read timeout");
let socket = UdpSocketWrapper::new(socket);
for addr in POOL_NTP_ADDR.to_socket_addrs().unwrap() {
let ntp_context = NtpContext::new(StdTimestampGen::default());
let result = get_time(addr, &socket, ntp_context);
match result {
Ok(time) => {
assert_ne!(time.sec(), 0);
let seconds = time.sec();
let microseconds = u64::from(time.sec_fraction()) * 1_000_000
/ u64::from(u32::MAX);
println!("Got time from [{POOL_NTP_ADDR}] {addr}: {seconds}.{microseconds}");
break;
}
Err(err) => println!("Err: {err:?}"),
}
thread::sleep(Duration::new(2, 0));
}
}
```
You can find this [example](examples/simple-request) as well as other example projects in the
[example directory](examples).
## `no_std` support
There is an example available on how to use [`smoltcp`](examples/smoltcp-request) stack and that should provide general
idea on how to bootstrap `no_std` networking and timestamping tools for `sntpc` library usage
## `async` support
Starting version `0.5` the default interface is `async`. If you want to use synchronous interface, read about `sync`
feature below.
`tokio` example: [`examples/tokio`](examples/tokio)
There is also `no_std` support with feature `async`, but it requires Rust >= `1.75-nightly` version. The example can be
found in [separate repository](https://github.com/vpikulik/sntpc_embassy).
## `sync` support
`sntpc` crate is `async` by default, since most of the frameworks (I have seen) for embedded systems utilize
asynchronous approach, e.g.:
- [RTIC](https://github.com/rtic-rs/rtic)
- [embassy](https://github.com/embassy-rs/embassy)
If you need a fully synchronous interface, it is available in the `sntpc::sync` submodule and respective `sync`-feature
enabled. In the case someone needs a synchronous socket support the currently async `NtpUdpSocket` trait can be
implemented in a fully synchronous manner. This is an example for the `std::net::UdpSocket` that is available in the
crate:
```rust
#[cfg(feature = "std")]
impl NtpUdpSocket for UdpSocket {
async fn send_to(&self, buf: &[u8], addr: SocketAddr) -> Result<usize> {
match self.send_to(buf, addr) {
Ok(usize) => Ok(usize),
Err(_) => Err(Error::Network),
}
}
async fn recv_from(&self, buf: &mut [u8]) -> Result<(usize, SocketAddr)> {
match self.recv_from(buf) {
Ok((size, addr)) => Ok((size, addr)),
Err(_) => Err(Error::Network),
}
}
}
```
As you can see, you may implement everything as synchronous, `sntpc` synchronous interface handles `async`-like stuff
internally.
That approach also allows to avoid issues with [`maybe_async`](https://docs.rs/maybe-async/latest/maybe_async/) when the
sync/async feature [violates Cargo requirements](https://doc.rust-lang.org/cargo/reference/features.html):
> That is, enabling a feature should not disable functionality, and it should usually be safe to enable any combination
> of features.
Small overhead introduced by creating an executor should be negligible.
# Network Adapters
The `sntpc` uses a modular architecture:
- [`sntpc`](sntpc): Core SNTP protocol implementation (network-agnostic)
- [`sntpc-net-std`](sntpc-net-std): Standard library UDP socket adapter
- [`sntpc-net-tokio`](sntpc-net-tokio): Tokio async runtime adapter
- [`sntpc-net-embassy`](sntpc-net-embassy): Embassy embedded async runtime adapter
# Contribution
Contributions are always welcome! If you have an idea, it's best to float it by me before working on it to ensure no
effort is wasted. If there's already an open issue for it, knock yourself out. See the
[**contributing section**](CONTRIBUTING.md) for additional details
## Thanks
1. [Frank A. Stevenson](https://github.com/snakehand): for implementing stricter adherence to RFC4330 verification
scheme
2. [Timothy Mertz](https://github.com/mertzt89): for fixing possible overflow in offset calculation
3. [HannesH](https://github.com/HannesGitH): for fixing a typo in the README.md
4. [Richard Penney](https://github.com/rwpenney): for adding two indicators of the NTP server's accuracy into the
`NtpResult` structure
5. [Vitali Pikulik](https://github.com/vpikulik): for adding `async` support
6. [tsingwong](https://github.com/tsingwong): for fixing invalid link in the `README.md`
7. [Robert Bastian](https://github.com/robertbastian): for fixing the overflow issue in the `calculate_offset`
8. [oleid](https://github.com/oleid): for bringing `embassy` socket support
9. [Damian Peckett](https://github.com/dpeckett): for adding `defmt` support and elaborating on `embassy` example
10. [icalder](https://github.com/icalder): for improving `embassy-net` support and adding missing `defmt` format support
for some `sntpc` types
11. [Boris Faure](https://github.com/borisfaure): for allowing embassy-net-0.9 that works without code change
12. [Jaroslav Henner](https://github.com/jarovo): for fixing the `sntpc-net-embassy` documentation on how to get
microseconds from fractions of seconds properly
13. [SimonIT](https://github.com/SimonIT): for introducing the `sntpc-time-embassy` crate with `embassy-time` support
and updating `defmt` version of the dependent crates
Really appreciate all your efforts! Please [let me know](mailto:vladimir.petrigo@gmail.com) if I forgot someone.
# License
<sup>
Licensed under either of <a href="LICENSE-APACHE">Apache License, Version 2.0</a> or
<a href="LICENSE-MIT">MIT license</a> at your option.
</sup>
<br>
<sub>
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in this codebase by you, as defined in the Apache-2.0 license,
shall be dual licensed as above, without any additional terms or conditions.
</sub>