SDK

Crystals and wall clock

The Kode Dot's two crystals, how the driver tells a working 32.768 kHz crystal from a silent fallback, and how the wall clock is set, kept and restored.

What it is

A Kode Dot counts time with two crystals. X1, 40 MHz, is the main clock: the PLLs multiply it into the 400 MHz CPU clock, the display's MIPI lanes, the 250 MHz PSRAM clock and every peripheral bus. The P4 accepts 40 MHz and nothing else, and a board whose X1 does not run does not boot, so its failure is never quiet.

X3, 32.768 kHz, drives the RTC, the chip's real-time clock. Its failure is quiet. When X3 does not start, ESP-IDF retries three times, switches the RTC to an internal RC oscillator, prints one warning before the console exists and carries on. The board boots, runs and looks fine. kdrv_clocks exists to make that fallback visible.

The same driver holds the rules of the wall clock, the date and time of day: whether the board knows the time, which timestamps it accepts, where the value came from, and how it survives a power cut. The crystals are what the clock runs on.

How it is connected

flowchart LR
subgraph main["Main clock"]
  x1["X1<br/>40 MHz"] --> pll["PLLs"]
  pll --> cpu["CPU 400 MHz, DSI,<br/>PSRAM, buses"]
end
subgraph slow["Slow clock"]
  x3["X3<br/>32.768 kHz"] --> mux["RTC slow mux"]
  rc["internal RC<br/>fallback"] --> mux
  mux --> rtc["RTC and<br/>wall clock"]
end
clk["khal_clk"] --> drv["kdrv_clocks"]
drv --> con["console: clocks,<br/>time, test"]
class x1 chip
class x3 chip
class rc chip
The two crystals and what reads them

Both crystals are parts on the main board. The driver owns no device and keeps no state between two calls, so no Active Object claims it. The console calls it directly, which makes clocks, time and test component=clocks among the few commands AO_Console answers in its own task. Underneath it is khal_clk (sdk/khal), which reads the slow-clock mux and counts a slow source against X1.

X1X3
Frequency40 MHz32.768 kHz
CL15 pF12.5 pF (SC-16S)
P4 pads99 and 100104 and 1, the dedicated XTAL_32K_N and XTAL_32K_P
Load capacitors, revision DVTC35 and C40, 27 pFC59 and C60, 22 pF
Espressif's requirement±10 ppm±20 ppm, ESR at most 70 kΩ

X3's two pads are the crystal, so no program can use them as pins, and the pin table has no GPIO0 or GPIO1. The ESP32-C5 has a third crystal, X2, a 32.768 kHz part with the same capacitors as X3.

Selecting X3

CONFIG_RTC_CLK_SRC_EXT_CRYS=y in sdkconfig.board selects the crystal once, at boot. The driver never enables it: switching the oscillator on before measuring it would hide the fallback the driver exists to detect. The option is a Kconfig choice, so flipping it needs the generated sdkconfig deleted. Two options must stay unset: CONFIG_ESP_VBAT_INIT_AUTO, which does nothing without deep sleep (kodeOS never enters it), and CONFIG_ESP_VBAT_USE_RECHARGEABLE_BATTERY, which would push charge current into VDD_BAT, the output of an LDO on this board.

Finding a silent X3

Two checks find a silent X3, and neither is enough alone. The mux says which source the RTC picked: on the RC means the crystal did not start. A measurement says what the crystal does: a mux on X3 proves only that the start succeeded once. Never use ESP-IDF's rtc_clk_32k_enabled() for either. It reads a power-domain bit and returns true with the crystal desoldered, so khal_clk does not expose it.

The chip cannot measure X1. The P4 measures a slow clock by counting its cycles inside a window of X1 cycles and dividing by X1's nominal 40 MHz. Every frequency it reports is a ratio against X1. An X1 that is 35 ppm fast makes every other source read 35 ppm slow, and nothing on the chip can tell the two apart. The console prints X1's nominal frequency and a second value, the X1 that would exist if X3 were exact. That value is the same ratio read backwards, not an independent measurement.

What X3 keeps running

The system clock runs on the systimer, which X1 feeds. The firmware reads the RTC at boot and on wake, and for nothing else.

X3 matters for keeping the time while the chip stays powered, across a reset. It does not keep the time across ship mode: the LDO that supplies VDD_BAT still runs from the cell, but CHIP_PU is pulled to 3.3 V by R19 and ship mode switches 3.3 V off, so the P4 and its RTC stop. BOT and LEFT held together pull CHIP_PU low and do the same. pmic_reset keeps the time, because it does not switch the rails off.

Keeping the time across a power cut

The Kode Dot has no RTC chip on a battery of its own, so the driver writes the clock to NVS while it runs and puts it back at boot.

  1. AO_Power calls kdrv_clocks_wall_keep(false) on each 2 s poll, and kdrv_clocks_wall_keep(true) immediately before the write that opens the battery switch. The write goes to the NVS namespace kclock, key wall, as one u64 of seconds.
  2. AO_Boot calls kdrv_clocks_wall_restore() once, before anything reads the hour, in kodeOS and in an app alike. A clock that came up unknown gets the kept value with the source saved, and the log says the clock lost its power; restored to <date>, approximate until the network or a person sets it. A clock that came up known keeps running.
  3. A restored value is a lower bound: late by however long the board was off, plus up to a minute. kdrv_clocks_wall_approx() is true while the source is saved. The console's time says state=approx. A time server or time set ends it.
  4. The approximate mark lives in NVS too, as the key approx (a u8), because the reset that follows a restore must not turn a guess into a known time. Opening the console port is such a reset.

Specifications

What the crystals measure

An off-board reference measured X1 on three Kode Dots and X3 on two of them. Those boards carried 15 pF capacitors on X1 and 10 pF on X3.

MeanSpecificationSpread, part to part
X1+35.0 ppm±10 ppm9.1 ppm
X3+221.4 ppm±20 ppm39.5 ppm

The mean is far from zero, so the offset comes from the board. For X1 the cause is known: two 15 pF capacitors in series give the crystal 7.5 pF against the 15 pF it is made for, and a crystal under a light load runs fast. Revision DVT carries 27 pF. For X3 the same fault applies, and revision DVT carries 22 pF. An error of tens of ppm remains after that, and the oscillator's bias setting can move the frequency by that much.

Separately, the SC-16S guarantees an ESR of 90 kΩ at most against Espressif's 70 kΩ, whatever capacitors are fitted. The replacement is the FC1610BN, same size and land pattern, ESR 45 kΩ typical and 60 kΩ maximum.

TBD: the figures for X1 and X3 on a revision DVT board with the corrected capacitors.

For scale, 1 ppm is 86 ms in a day, so 35 ppm is about 3 s a day. The internal RC measures about 121 kHz on this board; Espressif's own sources quote 136 and 150 kHz.

The driver

CallWhat it does
kdrv_clocks_read(&r, cycles)One pass over the clock block. Counts X3 and the slow RC against X1 and reads four derived clocks. A busy wait on the calling core with interrupts on: about 31 µs per counted cycle of X3. KODE_DENIED for a NULL pointer or 0 cycles
kdrv_clocks_log(&r)Logs the pass as ! lines in the order an operator reads them
kdrv_clocks_test()Reads at KDRV_CLOCKS_CYCLES_FAST, logs, and judges. Both checks run and both report
kdrv_clocks_slow_token(s)One-word name of a slow source for a console record: rc, xtal32k, rc32k, unknown
kdrv_clocks_wall_known()True when the wall clock holds a value later than 2026-01-01T00:00:00Z (KODE_CLOCK_KNOWN_AFTER_S). A threshold decides it, not a flag
kdrv_clocks_wall_set_iso(iso)Accepts exactly YYYY-MM-DDThh:mm:ssZ, 20 characters. No offsets, no missing zone, no fractional seconds, no guessing; a date at or before the threshold is refused as a typo. KODE_DENIED leaves the clock untouched. The parser is hand-written because strptime would take a loose input and return a plausible value
kdrv_clocks_wall_iso(out, n)Formats the clock as YYYY-MM-DDThh:mm:ssZ. Refuses an unknown clock and a buffer under 21 bytes, and writes nothing, since a truncated date is worse than none
kdrv_clocks_wall_source(), _source_token(s)Where the value came from: rtc (already there), set (an operator), sntp (a time server), saved (put back from NVS)
kdrv_clocks_wall_note_sntp()Records that the current value came from a time server; AO_Net calls it
kdrv_clocks_wall_restore()Once at boot; see above. KODE_DENIED when the clock is unknown and nothing was kept
kdrv_clocks_wall_keep(force)Writes the clock to NVS when due: the first keep of the boot, every 60 s (KDRV_CLOCKS_WALL_KEEP_S), on a jump of 60 s against the monotonic time (KDRV_CLOCKS_WALL_JUMP_S), or when force. Never writes an unknown clock. A failed write waits for the next period
kdrv_clocks_wall_approx()True while the source is saved

One pass

kdrv_clocks_t, filled by kdrv_clocks_read(). Counted frequencies are in milli-hertz so that a 32-bit field holds the 32.768 kHz of X3 (32,768,000) at 0.03 ppm of resolution. The largest value is about 4.29 MHz, so the driver can count only slow sources. The derived clocks are read, not counted, and are in hertz.

FieldMeaning
main_hzX1's nominal frequency, 40,000,000 (KBSP_XTAL_MAIN_HZ)
slow_srcWhat the mux physically picked: KDRV_CLOCKS_SLOW_RC, _XTAL32K, _RC32K or _UNKNOWN
x32k_ok, x32k_mhz, x32k_ppmX3 produced edges; its frequency; its error against 32.768 kHz. X3 is counted whether or not the mux selected it
main_from_x32k_hzThe X1 implied if X3 were exact. Calculated, not measured
rc_slow_ok, rc_slow_mhzThe slow RC produced edges, and its frequency: the clock the board falls back to
cpu_hz, apb_hz, rtc_slow_hz, rtc_fast_hzDerived clocks; 0 when the chip did not answer

A source with no edges sets its _ok flag to false and its frequency to 0, and the driver calculates nothing from it. An old number from a previous pass would be worse than none. With the mux on the RC, the crystal's oscillator is powered down, so "not oscillating" means only that X3 is not running. With the mux on X3, which is how the board is built, it also means X3 is absent or dead, and the reading is conclusive.

The verdict

kdrv_clocks_test(), the clocks row of test component=all, applies two checks, both about X3, because X1 cannot be judged from the board.

CheckPasses when
The muxIt is on the crystal. The only check that names the silent fallback
The crystalIt measures 32.768 kHz within ±1 % (KBSP_XTAL_32K_TOL_MHZ, 327,680 mHz)

±1 % shows that the crystal is there and runs. It does not show the ±20 ppm of the part, which no board can judge, since the chip counts against X1. The window is 500 times wider than the specification and catches what a real board can have: no crystal fitted, one that does not start, the wrong part, or the internal RC running at four times the frequency. The log line says so, so that a pass is not read as "as exact as the maker promises".

The console

CommandAnswer
clocksAt KDRV_CLOCKS_CYCLES_FINE (3000 cycles, about 92 ms, about 0.27 ppm). One clk record each for x1 (nominal_hz), slow_mux (src), x3 (state, millihz, ppm; state=silent when it has no edges), x1_from_x3 (hz), rc_slow (state, millihz), cpu, apb, rtc_slow and rtc_fast (hz)
timeOne time record with state (known, approx or unset), utc and source
time set="2026-10-28T09:30:00Z"Sets the clock; >err code=3 msg=set for anything but the exact shape
time sync=onAsks the time server now; refused without a network
test component=clocksThe two checks above

Gotchas

Example code

Read the clocks and the wall time. The log shows which source the RTC runs on (xtal32k on a healthy board), X3's frequency near 32,768 Hz, and the UTC time with where it came from.

/*
 * clocks.c - 2026-10-28 - from Kode with love
 *
 * Reads the Kode Dot's crystals and wall clock once and logs them. The log
 * shows which source the RTC runs on (xtal32k on a healthy board), X3's
 * frequency near 32768 Hz, and the UTC time with where it came from, or
 * a note that the time is not set.
 */
#include "kode.h"
#include "kdrv_clocks.h"
 
#define TAG "clocks"
 
/* Bring the system up, then read and log the clocks. */
void app_main(void)
{
    kdrv_clocks_t clocks;
    char wall[21];
 
    kode_init();
 
    if (kdrv_clocks_read(&clocks, KDRV_CLOCKS_CYCLES_FAST) == KODE_OK) {
        KLOG_I(TAG, "RTC slow clock: %s", kdrv_clocks_slow_token(clocks.slow_src));
        KLOG_I(TAG, "X3 %u.%03u Hz, %+d ppm against X1",
               (unsigned)(clocks.x32k_mhz / 1000U),
               (unsigned)(clocks.x32k_mhz % 1000U), (int)clocks.x32k_ppm);
    }
 
    if (kdrv_clocks_wall_iso(wall, sizeof(wall)) == KODE_OK) {
        KLOG_I(TAG, "UTC %s, source %s", wall,
               kdrv_clocks_wall_source_token(kdrv_clocks_wall_source()));
    }
    else {
        KLOG_I(TAG, "the time is not set");
    }
}

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.

In the SDK