NTP, RTC & Timekeeping¶
A board that boots up thinking it's January 1, 2000 can't timestamp
sensor readings meaningfully. This module covers syncing the clock over
the network with ntptime, reading/writing machine.RTC, handling
timezones and DST on-device (MicroPython gives you no timezone database —
you do the math), timestamping data, and keeping time across deep sleep.
Time-math helpers run via python3; ntptime/machine.RTC calls are
reviewed against MicroPython docs, exercised in Wokwi where its simulated
network clock allows it.
The RTC starts wrong¶
On power-up, machine.RTC() holds whatever the last set time was (or the
epoch, on a cold boot with no battery-backed RTC) — it does not know
the real date/time until something sets it.
import machine
rtc = machine.RTC()
print(rtc.datetime())
# (2000, 1, 1, 5, 0, 0, 0, 0) on a fresh board — obviously wrong
rtc.datetime() returns (year, month, day, weekday, hours, minutes,
seconds, subseconds) — note weekday before hours, an easy field to
transpose.
Syncing with NTP¶
import network
import ntptime
import time
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect("Wokwi-GUEST", "")
while not wlan.isconnected():
time.sleep(0.5)
ntptime.settime() # blocks briefly; sets RTC to UTC from an NTP server
print("RTC synced:", machine.RTC().datetime())
ntptime.settime() sets the system clock to UTC, always — there is no
timezone parameter. It can raise OSError if the network request times
out or the NTP server is unreachable; wrap it and retry rather than
assuming it always succeeds:
def sync_time(retries=3):
for attempt in range(retries):
try:
ntptime.settime()
return True
except OSError as e:
print("NTP sync failed:", e)
time.sleep(1)
return False
ntptime.host can be changed from the default (pool.ntp.org) if a
particular server is preferred:
Timezones and DST — you do the math¶
MicroPython has no zoneinfo/pytz equivalent. After ntptime.settime()
sets UTC, apply an offset yourself:
import time
def local_time(utc_offset_hours):
utc_secs = time.time()
local_secs = utc_secs + int(utc_offset_hours * 3600)
return time.localtime(local_secs)
# IST is UTC+5:30
lt = local_time(5.5)
print("{:04d}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(
lt[0], lt[1], lt[2], lt[3], lt[4], lt[5]
))
DST (where it applies) is just a seasonally-changing offset — track it with a small date-range check, since there's no library to do it for you:
def is_us_dst(year, month, day):
# Simplified: DST roughly mid-March to early November.
# Real code should compute the actual 2nd-Sunday/1st-Sunday rule.
if month < 3 or month > 11:
return False
if 3 < month < 11:
return True
return True # placeholder for edge months; refine per-locale
Treat DST logic like this as a stand-in to refine per-locale — the placeholder above intentionally oversimplifies; a real deployment needs the exact transition rule for its timezone.
Timestamping sensor data¶
import time
def timestamped_reading(temp_c, hum_pct):
t = time.localtime()
return {
"ts": "{:04d}-{:02d}-{:02d}T{:02d}:{:02d}:{:02d}".format(
t[0], t[1], t[2], t[3], t[4], t[5]
),
"temp": temp_c,
"hum": hum_pct,
}
print(timestamped_reading(21.4, 55.0))
# {'ts': '2026-08-25T14:03:11', 'temp': 21.4, 'hum': 55.0}
This is plain string formatting and testable entirely with python3 —
only the time.localtime() source of the numbers is board-specific.
Keeping time across deep sleep¶
Deep sleep (module 3) resets the chip, but on ESP32 the RTC domain
specifically survives — machine.RTC() keeps ticking, so you don't need
to re-sync with NTP after every wake, only periodically:
import machine
import time
rtc = machine.RTC()
def maybe_resync(max_drift_hours=24):
# Store last-sync timestamp in RTC memory (module 3 pattern)
last_sync_raw = rtc.memory()
last_sync = int(last_sync_raw) if last_sync_raw else 0
now = time.time()
if now - last_sync > max_drift_hours * 3600:
if sync_time():
rtc.memory(str(time.time()).encode())
maybe_resync()
print("current time:", time.localtime())
machine.deepsleep(60_000)
This resyncs at most once a day, saving the WiFi-connect cost on every short wake cycle while still catching drift before it becomes meaningful.
Timekeeping traps¶
ntptime.settime() sets UTC — don't store it as local
Store raw timestamps in UTC (from time.time() right after a sync)
and apply the offset only for display/logging. Storing already-offset
"local" timestamps makes later math (durations, comparisons) wrong
the next time you touch the data.
machine.RTC field order is (year, month, day, weekday, ...)
Weekday sits before hours/minutes/seconds — a very easy field to
misindex when hand-building a datetime() tuple. Double check against
the docs every time you write one.
Cold boot with no RTC battery loses everything
A board with no coin-cell RTC backup wakes from a full power loss (not deep sleep — actual power removal) back at the epoch. Deep sleep preserves RTC time; unplugging power does not. Don't assume "survives sleep" implies "survives being unplugged."
NTP over an unreliable network hangs, doesn't fail fast
A flaky WiFi connection can make ntptime.settime() take several
seconds before raising OSError — budget that latency (and the
retry loop) into any deep-sleep wake cycle that includes a resync, or
it silently eats your battery savings.
How It Actually Works¶
The clock on a microcontroller is a genuinely different piece of hardware from what "time" means on a desktop, and NTP's job is bridging a free-running oscillator to real-world time over an unreliable network.
machine.RTC()is backed by a dedicated low-power oscillator circuit in the RTC power domain — the same domain that survives deep sleep (module 3) — counting seconds independent of the main CPU clock. On cold boot, that counter simply starts from whatever its reset default is (often epoch-like values), because there's no battery-backed reference and no OS clock daemon to seed it;rtc.datetime()reads/writes the counter's raw register fields directly, which is also why the field order(year, month, day, weekday, ...)follows the chip vendor's register layout rather than any Python convention — it's a direct mirror of hardware bit-fields, not a designed API.ntptime.settime()is a genuine, if minimal, implementation of the NTP wire protocol — it builds a 48-byte NTP request packet, sends it via UDP to the configured server, waits for a reply, extracts the 64-bit NTP timestamp field from a specific byte offset in the response, converts NTP epoch (1900) to Unix epoch (1970) with a hardcoded constant, and callsmachine.RTC().datetime(...)to load the result into the RTC's hardware registers. It genuinely blocks on a UDP round-trip over a real network path, which is precisely why it can hang for seconds under a flaky WiFi connection: it isn't polling a fast local peripheral, it's waiting on packets crossing the actual internet.- There's no timezone database because a timezone database is tens to
hundreds of kilobytes of transition-rule data (
tzdata), and MicroPython budgets its entire flash image far below what desktop Python'szoneinfoassumes is available. UTC arithmetic (add/subtract a fixed offset) is the only timezone handling that fits the footprint; anything smarter (DST transition dates that shift ever few years by international agreement) would need shipping and periodically updating real calendar data no embedded flash budget in this course's scope can spare. time.time()on MicroPython counts seconds from the port's own epoch (2000-01-01 on most ports, not 1970 like CPython), converted transparently bytime.localtime()/time.time()so your Python code never sees the raw offset — but this is exactly why timestamps computed on-device and compared against a server's Unix-epoch timestamps need care about which epoch each number is actually counted from. The RTC hardware itself doesn't know or care about any epoch at all; it's MicroPython'stimemodule doing the epoch-relative arithmetic in software on top of the raw hardware seconds counter.
Cheat sheet¶
| Function / idiom | Purpose |
|---|---|
machine.RTC().datetime() |
Get/set (year, mon, day, weekday, hr, min, sec, subsec) |
ntptime.settime() |
Sync RTC to UTC via NTP; raises OSError on failure |
ntptime.host = "..." |
Change the NTP server |
time.time() |
Seconds since epoch, UTC |
time.localtime(secs) |
Break seconds into a time tuple |
local = utc + offset_hours * 3600 |
Manual timezone conversion — no library does this |
rtc.memory(bytes) / .memory() |
Persist last-sync time across deep sleep |
| RTC domain survives deep sleep, not power loss | Resync occasionally, not every wake |
Exercise¶
Write a sync_time(retries=3) function (as above) plus a
timestamped_reading(temp_c, hum_pct, utc_offset_hours=5.5) function that
returns a dict with an ISO-style local timestamp string. Test both the
timestamp-formatting logic and a local_time(utc_offset_hours) helper
using python3 with a fixed, hardcoded time.time()-like value (pass the
seconds in directly rather than calling the real clock) so the test is
deterministic. Then write the on-device integration: connect WiFi, call
sync_time(), and if it succeeds, print one timestamped_reading() per
second for 10 seconds. Add the maybe_resync() RTC-memory pattern so a
version of this script survives being wrapped in a machine.deepsleep()
loop without resyncing more than once per (simulated) day.