Skip to main content

Battery-Backed Time: the DS3231

The scheduler compares wall-clock time against stored schedules. That sentence hides a dependency that bites every embedded project eventually: where does the wall clock come from after a power cut?

The problem, from zero

The ESP32-S3 has an internal RTC, but it runs from the same supply as everything else. Pull the plug and the chip forgets the time completely; the next boot starts at January 1st 1970. The firmware then waits for WiFi and SNTP to learn the real time, and only after that does the scheduler have anything meaningful to compare against.

Now walk the failure chain. Power blips at 3 AM. The router takes four minutes to come back; the ESP32 boots in four seconds. The provisioning cycle (SoftAP window alternating with stored-credential retries) eventually rejoins the network, SNTP syncs, and the 5 AM recitation fires on time. Fine. But move the same blip to a house where the router never comes back, or a device deliberately used offline, and the scheduler is dead until someone notices. The gate is explicit in scheduler.c:

if (now < 1700000000) { // clock reads 1970, comparisons meaningless
return;
}

A battery-backed real-time clock removes the network from that chain entirely. The DS3231 costs about two dollars, sits on the I2C bus Safi already runs for the PN532, and keeps time for years on a coin cell.

Why the DS3231 specifically

Two properties separate it from the cheaper DS1307:

  • A temperature-compensated crystal oscillator (TCXO). Crystal frequency drifts with temperature; the DS3231 measures its own die temperature every 64 seconds and trims the oscillator capacitance to match. Rated drift is 2 ppm, about one minute per year. A DS1307 with a bare 32.768 kHz crystal drifts an order of magnitude worse, and worse again if the crystal layout is sloppy.
  • Alarms wired to a physical pin. Two alarm registers compare continuously against the running time; on match, the INT pin pulls low. That turns the RTC from a passive clock into a wake source: the ESP32-S3 can deep sleep at microamp draw and let the RTC wake it for the next schedule.

The die temperature that drives the compensation is readable over I2C at 0.25 degree resolution, so Safi exposes it in /api/settings and on the dashboard, a free sanity check that the chip is alive.

How Safi integrates it

The driver is about 300 lines in components/rtc_ds3231/, written directly against the new i2c_master API. The interesting decisions:

Bus sharing without coupling. system_init.c initializes the shared master bus on I2C_NUM_1 (GPIO 8 SDA, GPIO 9 SCL) with internal pull-ups. Both the PN532 NFC driver and the DS3231 RTC driver attach their device handles to this shared bus via i2c_master_get_bus_handle(). If either module is unplugged or unresponsive, its device handle is released without tearing down the shared bus, allowing the other peripheral to operate without disruption.

The RTC keeps UTC, always. Timezones are a rendering concern. The POSIX TZ machinery in time_sync converts for the display and the scheduler; the hardware clock never changes when the user changes timezone. The conversion helpers avoid mktime() deliberately, because newlib's mktime applies the process timezone and would silently corrupt the stored time.

Trust is explicit. Register 0x0F carries the oscillator-stop flag (OSF). If it is set, the clock stopped at some point (dead or removed coin cell) and the time is garbage; the driver refuses to seed the system clock and waits for SNTP or a manual set. Writing a fresh time clears the flag.

Time flows both ways. At boot, a trusted RTC seeds settimeofday() and the scheduler runs one immediate check, so an alarm wake plays its schedule within seconds. When SNTP later syncs, the corrected time is written back to the RTC. The settings API reports which source currently backs the clock (none, rtc, sntp, manual), and SNTP is never downgraded by an RTC seed.

Offline installs get a manual path. POST /api/settings/time accepts the browser's epoch, sets the system clock and the RTC in one step. The dashboard exposes it as a one-tap "use browser time" button.

Waking from deep sleep

Deep sleep used to be a one-way door with a single key: ext0 wake on the encoder button. ext0 supports exactly one pin, so adding the RTC alarm forced the move to ext1 with a mask:

uint64_t mask = BIT64(GPIO_NUM_15); // encoder button
mask |= BIT64(CONFIG_SAFI_RTC_INT_GPIO); // DS3231 INT, GPIO 10
esp_sleep_enable_ext1_wakeup(mask, ESP_EXT1_WAKEUP_ANY_LOW);

Both signals are active low and INT is open drain, so ANY_LOW fits and a pull-up on the INT line is mandatory (the PCB carries one; on jumper wires enable the internal pull-up or add 10 k externally).

The policy split matters more than the mechanics:

  • User-requested sleep (POST /api/system/sleep, or the dashboard's Sleep button) computes the next enabled schedule with scheduler_next_wake(), programs alarm 1, and sleeps. The device wakes itself for the dawn recitation.
  • Battery-emergency sleep never arms the alarm. A device that just shut down at 3.05 V must not wake itself to drive a speaker; that is how LiPo cells get killed. Button wake only.

On every boot the driver clears stale alarm flags and disarms the interrupt, so a leftover alarm can never wedge INT low and burn the wake path.

The coin cell trap on ZS-042 modules

The common blue ZS-042 breakout ships with a charging circuit: a resistor and diode from VCC into the battery holder, intended for a rechargeable LIR2032 cell. Most people fit a standard CR2032, which is not rechargeable. Trickle-charging a CR2032 slowly cooks it; the failure mode ranges from a dead cell in months to a leaking one.

Two correct configurations:

  1. Fit an LIR2032 and leave the module as is.
  2. Fit a CR2032 and remove the charging path: desolder the resistor (usually marked 201) or the diode next to the holder.

The datasheet library hosts the DS3231 datasheet; the register map in section 8.2 is the reference for everything the driver does.

Why not alternatives

  • Internal RTC plus a supercap keeps time for minutes, not weeks, and the S3's RC oscillator drifts percent-level. Not a clock, a brownout bridge.
  • DS1307 costs less but drifts 20+ ppm, runs at 5 V nominally, and has no alarm interrupt worth using. The two dollars buy a real feature (alarm wake), not just accuracy.
  • NTP-only with periodic reconnects burns the battery the deep-sleep path exists to save, and still fails the offline case entirely.