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:
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 (includesStdTimestampGen)sync- Synchronous API insyncmoduledispersion- Enables root dispersion estimation per RFC 5905 §9.2utils- OS-specific utilities for system time synclog/defmt- Debug logging
Return value: NtpResult
The get_time and sntp_process_response functions return NtpResult which includes:
| Field | Description |
|---|---|
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.
const FIRMWARE_BUILD_TIME: u64 = 1_735_689_600; // 2025-01-01, last resort floor
let context = new;
let result = get_time.await?;
// Sanity-check the result before trusting or persisting it — see below.
if result.sec >= FIRMWARE_BUILD_TIME else
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_TIMEis 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
NtpTimestampGeneratorre-init from the previous successfulresult.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
[]
= { = "0.11", = ["sync"] }
- application code. Based on a socket implementation used, you may want to use supplementary crates with UDP socket
wrappers:
sntpc-net-std: for standard library UDP socketssntpc-net-embassy: forembassy-netUDP socketssntpc-net-tokio: fortokioUDP socketssntpc-time-embassy: timestamp generation based on theembassy-timeinterface
Example below uses sntpc-net-std crate:
[]
= "1"
use ;
use UdpSocketWrapper;
use ;
use thread;
use Duration;
const POOL_NTP_ADDR: &str = "pool.ntp.org:123";
const GOOGLE_NTP_ADDR: &str = "time.google.com:123";
You can find this example as well as other example projects in the example directory.
no_std support
There is an example available on how to use smoltcp 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
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.
sync support
sntpc crate is async by default, since most of the frameworks (I have seen) for embedded systems utilize
asynchronous approach, e.g.:
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:
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 when the
sync/async feature violates Cargo requirements:
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: Core SNTP protocol implementation (network-agnostic)sntpc-net-std: Standard library UDP socket adaptersntpc-net-tokio: Tokio async runtime adaptersntpc-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 for additional details
Thanks
- Frank A. Stevenson: for implementing stricter adherence to RFC4330 verification scheme
- Timothy Mertz: for fixing possible overflow in offset calculation
- HannesH: for fixing a typo in the README.md
- Richard Penney: for adding two indicators of the NTP server's accuracy into the
NtpResultstructure - Vitali Pikulik: for adding
asyncsupport - tsingwong: for fixing invalid link in the
README.md - Robert Bastian: for fixing the overflow issue in the
calculate_offset - oleid: for bringing
embassysocket support - Damian Peckett: for adding
defmtsupport and elaborating onembassyexample - icalder: for improving
embassy-netsupport and adding missingdefmtformat support for somesntpctypes - Boris Faure: for allowing embassy-net-0.9 that works without code change
- Jaroslav Henner: for fixing the
sntpc-net-embassydocumentation on how to get microseconds from fractions of seconds properly - SimonIT: for introducing the
sntpc-time-embassycrate withembassy-timesupport and updatingdefmtversion of the dependent crates
Really appreciate all your efforts! Please let me know if I forgot someone.