Skip to content

ADR-011: NTP Time Sync Without Battery-Backed RTC

Status: Accepted Date: 2026-03-17

Context

Three firmware features require accurate wall-clock time:

  1. SCHEDULED access — time slots (e.g., "Tuesday 09:00–18:00") are checked against the current day-of-week, hour, and minute.
  2. TOTP keypad codes — TOTP is time-based (RFC 6238); a code is only valid within a ±30-second window of the correct Unix timestamp. A wrong clock = all codes invalid.
  3. Daily battery report — should fire at a predictable time (3am) rather than a random point 24 hours after the last boot.

The ESP32 has no battery-backed RTC. When the device powers on or reboots, the system clock starts at the Unix epoch (0). Without an external time source, all three features are broken until the clock is set.

A hardware RTC (e.g., DS3231) would solve this at the cost of extra circuitry, an I2C address, and a coin cell that must be replaced periodically. However, the device already connects to the network every 5 minutes to sync the allow-list. An NTP query is free.

Decision

Use SNTP, no hardware RTC

Use the ESP32 Arduino framework's built-in SNTP client (configTime() / esp_sntp.h). No extra library or hardware is needed. SNTP is called once in setup() after the network is initialised and runs autonomously in the background, retrying until it succeeds.

configTime(0, 0, NTP_SERVER_PRIMARY, NTP_SERVER_SECONDARY);

Primary server: pool.ntp.org. Secondary: time.cloudflare.com. Both are defined in config.h so they can be overridden at compile time for air-gapped installations that run a local NTP server.

UTC everywhere

The firmware uses UTC throughout (gmtime_r()). The backend generates time slots in UTC when building the allow-list. There is no timezone configuration on the device.

Implication for admins: when entering a time slot in the admin UI, times must be entered in UTC. This is a deliberate constraint — it avoids DST ambiguity in firmware and keeps the binary protocol simple. The admin UI is expected to handle local-time conversion before submitting to the backend.

Sync detection via threshold

The clock is considered valid when time(nullptr) > NTP_SYNC_THRESHOLD (1577836800 = 2020-01-01 UTC). The threshold guards against the epoch-start false positive (clock just booted = time is 0) and against the unlikely case where SNTP returns a timestamp in the 1970s due to a misconfigured server.

The helper get_wall_time() in main.cpp encapsulates this check and fills (unix_t, dow, hour, minute) in a single call. It returns false when the clock is not yet valid.

Fail-safe guardrails when clock is unknown

The firmware passes now_unix = 0 to handleCard() until NTP syncs. Each access type reacts safely:

Access type Behaviour before NTP sync
UNRESTRICTED Granted (no time check)
EMERGENCY Granted with notification (no time check)
CONDITIONAL Depends on network, not time
SCHEDULED — no time slots Granted (date-range entries with valid_from = 0 and no slots still work)
SCHEDULED — with time slots Denied (DENIED_TIME_RESTRICTION) — see below

The guardrail for SCHEDULED entries with time slots is a single check in evaluate_scheduled_access():

if (now == 0) {
    return AccessDecision::DENIED_TIME_RESTRICTION;
}

now == 0 is a safe sentinel: no real allow-list entry has a Unix timestamp at the epoch. This prevents accidental grants against the wrong time (e.g., matching a "Monday 00:00–01:00" slot during the first second after boot).

TOTP is disabled until synced

TOTP keypad handling (ADR-008) must verify time_ok from get_wall_time() before calling totp_verify(). If the clock is not synced, the keypad should reject all TOTP codes with a clear feedback signal (e.g., three rapid red blinks) rather than accept a code that was valid at the epoch. This is enforced at the call site in main.cpp.

HOTP (counter-based backup codes) does not need wall-clock time and is unaffected.

3am daily battery report

When the clock is synced, the daily battery report fires when hour_of_day == 3 (3am UTC) and the day number (unix_t / 86400) has advanced past the last report.

When the clock is not synced, the report falls back to a millis-based ~24-hour counter. This ensures the report still fires eventually on devices that never get NTP (bench test without internet, air-gapped deployment without a local NTP server).

Alternatives considered

Hardware RTC (DS3231): Maintains time across power loss without network. Adds a coin cell, I2C wiring, and replacement schedule. Unnecessary because the device always has network access within minutes of boot — the allow-list sync requires it.

Save last-known time to NVS on shutdown: Reduces the boot-to-sync window but adds NVS writes every shutdown cycle. The device rarely has a clean shutdown. The first 10–30 seconds after boot with SCHEDULED access denied is acceptable.

Configurable NTP server via NVS: Useful for air-gapped installations with a local NTP server. Deferred — config.h compile-time overrides are sufficient. Can be added to provisioning later.

Local timezone configuration: Would allow admins to enter times in local time. Rejected for firmware simplicity — DST transitions in firmware are error-prone. UTC is unambiguous. The admin UI handles local-time conversion.

Wait for NTP before accepting any cards: Would eliminate the brief window of degraded access (SCHEDULED cards denied). But it means the door is completely unresponsive for 10–30 s after boot, which is a worse user experience. UNRESTRICTED cards (the majority) work immediately.

Consequences

  • No hardware RTC required — fewer components, no coin cell maintenance
  • SCHEDULED time-slot entries are denied for the ~10–30 seconds before NTP syncs; UNRESTRICTED and EMERGENCY entries work immediately
  • TOTP codes are unavailable until NTP syncs; HOTP backup codes are unaffected
  • All time logic is in UTC — admin UI must convert local times to UTC before sending to the backend
  • Daily battery report fires at 3am UTC, not local time; adjust mentally (+1/+2 h for Berlin CET/CEST)
  • NTP servers are compile-time configurable for air-gapped or private installations
  • get_wall_time() is the single source of truth for time in main.cpp; future time-dependent features should use it