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
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.
| X1 | X3 | |
|---|---|---|
| Frequency | 40 MHz | 32.768 kHz |
| CL | 15 pF | 12.5 pF (SC-16S) |
| P4 pads | 99 and 100 | 104 and 1, the dedicated XTAL_32K_N and XTAL_32K_P |
| Load capacitors, revision DVT | C35 and C40, 27 pF | C59 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.
- AO_Power calls
kdrv_clocks_wall_keep(false)on each 2 s poll, andkdrv_clocks_wall_keep(true)immediately before the write that opens the battery switch. The write goes to the NVS namespacekclock, keywall, as one u64 of seconds. - 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 sourcesaved, and the log saysthe clock lost its power; restored to <date>, approximate until the network or a person sets it. A clock that came up known keeps running. - 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 issaved. The console'stimesaysstate=approx. A time server ortime setends it. - 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.
| Mean | Specification | Spread, part to part | |
|---|---|---|---|
| X1 | +35.0 ppm | ±10 ppm | 9.1 ppm |
| X3 | +221.4 ppm | ±20 ppm | 39.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
| Call | What 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.
| Field | Meaning |
|---|---|
main_hz | X1's nominal frequency, 40,000,000 (KBSP_XTAL_MAIN_HZ) |
slow_src | What the mux physically picked: KDRV_CLOCKS_SLOW_RC, _XTAL32K, _RC32K or _UNKNOWN |
x32k_ok, x32k_mhz, x32k_ppm | X3 produced edges; its frequency; its error against 32.768 kHz. X3 is counted whether or not the mux selected it |
main_from_x32k_hz | The X1 implied if X3 were exact. Calculated, not measured |
rc_slow_ok, rc_slow_mhz | The slow RC produced edges, and its frequency: the clock the board falls back to |
cpu_hz, apb_hz, rtc_slow_hz, rtc_fast_hz | Derived 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.
| Check | Passes when |
|---|---|
| The mux | It is on the crystal. The only check that names the silent fallback |
| The crystal | It 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
| Command | Answer |
|---|---|
clocks | At 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) |
time | One 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=on | Asks the time server now; refused without a network |
test component=clocks | The 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
components/kdrv/clocks: the driver,kdrv_clocks.c, and its header.kdrv_clocks.h: the pass structure, the verdict and the wall-clock calls.components/khal/clk:khal_clk, the mux and the counter underneath.