SDK
khal
The hardware services the Kode Dot's drivers share: I2C, console, time, GPIO, PWM, expansion header, SD card, NVS, PSRAM and identity, behind kode_status_t.
What it is
khal is the hardware abstraction layer of the SDK. It holds the services that several drivers share, and it is where ESP-IDF stops: above kdrv, no file includes esp_*, driver/*, freertos/*, soc/* or hal/*. The public headers speak kode_status_t for a result and opaque handles for an object. None uses esp_err_t.
Two kinds of code live in khal and nothing else:
- Code that the system creates exactly once and several drivers use: the I2C bus, the console transport, the LEDC, the heap.
- Code that could be done another way later while the layers above keep the same calls. The console is USB-Serial/JTAG today and could be a UART, and NVS could keep its values in a file.
A module is a header and a backend. A call goes from the caller to the header, to the backend file khal_<module>_esp.c, and then to ESP-IDF. A chip driver calls khal_i2c_write_reg(), and the ESP-IDF call that does the work happens inside khal_i2c_esp.c.
How it is connected
flowchart TB subgraph above["Layers that call khal"] kdrv["kdrv<br/>chip drivers"] kaos["kaos<br/>Active Objects"] kqp["kqp<br/>QP tick, workers"] end khal["khal<br/>16 modules"] subgraph below["What khal uses"] kbsp["kbsp<br/>pins, addresses"] klog["klog<br/>the log"] kdefs["kdefs<br/>kode_status_t"] idf["ESP-IDF drivers"] end kdrv --> khal kaos --> khal kqp --> khal khal --> kbsp khal --> klog khal --> kdefs khal --> idf class kaos ao
khal requires kdefs, klog and kbsp, and all three are public, so an app that requires kode also sees the khal headers and kbsp.
The ESP-IDF drivers are private requirements: esp_adc, esp_driver_gpio, esp_driver_i2c, esp_driver_ledc, esp_driver_sdmmc, esp_driver_tsens, esp_driver_uart, esp_driver_usb_serial_jtag, esp_hw_support, esp_psram, esp_system, spi_flash, esp_timer, fatfs, nvs_flash, sdmmc and vfs.
A module that drives a device is not thread-safe. It has no lock, and two tasks must not call it at the same time. That is safe because each device has one owner, and only the owner calls the module (sdk/design-rules). The one exception is PWM, whose LEDC serves two owners, so khal_pwm has a lock.
A header never includes an ESP-IDF header. khal_i2c.h forward-declares ESP-IDF's two handle structures and hands out pointers to them, so a compiler can read every khal header without ESP-IDF and the host tests run on a computer. One vendor library, the codec component, needs the raw bus, and khal_i2c_bus() gives it out.
kode_init() brings khal up in a fixed order:
kode_log_init()khal_heap_watch()khal_identity_init()khal_pwm_init()khal_exp_init()
The I2C bus is created by the first driver that needs it, in the initial transition of its Active Object. khal_nvs_init() runs on first use.
Two build details live in the component's CMakeLists.txt:
- With esp-hosted compiled in, the linker wraps
sdmmc_host_deinit, so tearing down the radio link releases only the radio's SDMMC slot and the microSD card keeps working. - On a two-core build, the linker keeps
port_start_app_hook, a hook that ESP-IDF references only weakly.
Specifications
The modules
One row per module, with the files that make it.
| Module | Files | What it does |
|---|---|---|
| i2c | i2c/include/khal_i2c.h, i2c/src/khal_i2c_esp.c | The one shared bus, I2C0, and a handle per device |
| console | console/include/khal_console.h, console/src/khal_console_esp.c | Console bytes in and out, the reader task, raw mode, and the dock port |
| time | time/include/khal_time.h, time/src/khal_time_esp.c | Time since boot, the wall clock, a delay and a periodic timer |
| sync | sync/include/khal_sync.h, sync/src/khal_sync_esp.c | A mutex and a binary semaphore |
| gpio | gpio/include/khal_gpio.h, gpio/src/khal_gpio_esp.c | Input pins, output pins and one-shot pin interrupts |
| pwm | pwm/include/khal_pwm.h, pwm/src/khal_pwm.c, pwm/src/khal_pwm_port.h, pwm/src/khal_pwm_esp.c | The LEDC by channel. khal_pwm.c is the portable table, khal_pwm_port.h the private seam it calls, khal_pwm_esp.c the ESP-IDF side |
| exp | exp/include/khal_exp.h, exp/src/khal_exp.c, exp/src/khal_exp_port.h, exp/src/khal_exp_esp.c | The expansion header J3 by position. Same three-file split as pwm; the port covers the ADC |
| clk | clk/include/khal_clk.h, clk/src/khal_clk_esp.c | Which clock the RTC runs on, and what a clock really measures |
| sdmmc | sdmmc/include/khal_sdmmc.h, sdmmc/src/khal_sdmmc_esp.c | The microSD: slot, FAT volume and raw sectors |
| system | system/include/khal_system.h, system/src/khal_system_esp.c | Restart, panic, random numbers, heap, tasks, the supervisor task, PSRAM allocation and the start hook |
| temp | temp/include/khal_temp.h, temp/src/khal_temp_esp.c | The die temperature |
| worker | worker/include/khal_worker.h, worker/src/khal_worker_esp.c | A task that runs one job at a time from a queue |
| nvs | nvs/include/khal_nvs.h, nvs/src/khal_nvs_esp.c | Small persistent key and value storage |
| identity | identity/include/khal_identity.h, identity/src/khal_identity_esp.c | The board's serial, derived from its C5's MAC |
| crc32 | crc32/include/khal_crc32.h, crc32/src/khal_crc32_esp.c | The zlib CRC-32, from the ROM |
| zlib | zlib/include/khal_zlib.h, zlib/src/khal_zlib_esp.c | One whole zlib stream inflated, with the ROM's decoder |
| force | force/khal_task_psram.h | Force-included into a library so that every xTaskCreate() in it takes its stack from PSRAM. Used by kui for LVGL |
The component also carries Kconfig, idf_component.yml and CMakeLists.txt. idf_component.yml pins espressif/esp_tinyusb ==2.2.0, fetched only with the dock console.
i2c
I2C0 on SDA GPIO4 and SCL GPIO5 at 400 kHz, with the internal pull-ups on and a glitch filter of 7 clock cycles. Every transaction times out after 100 ms.
The ESP-IDF driver serialises transactions, so two owners that talk to two chips need no coordination. A run of several transactions with one chip stays safe because its owner does the whole run inside one step.
| Call | What it does |
|---|---|
khal_i2c_init() | Starts the bus. Idempotent, takes no lock. KODE_ERROR when the controller will not start |
khal_i2c_bus() | The raw bus handle for a vendor component. NULL before a successful init |
khal_i2c_probe(addr, timeout_ms) | Whether anything acknowledges a 7-bit address. It probes twice and counts the address only if both answer, because one probe of an empty address can come back answered (espressif/esp-idf#13547) |
khal_i2c_add(addr, &dev) | Attaches a device at KBSP_I2C_FREQ_HZ |
khal_i2c_remove(dev) | Detaches and frees a handle. Owed on every path that leaves after a successful add. NULL is accepted |
khal_i2c_write_reg(dev, reg, val) | Writes one register |
khal_i2c_read_reg(dev, reg, buf, len) | Reads len bytes from reg |
khal_i2c_write_regs(dev, reg, buf, len) | Writes 1 to KHAL_I2C_BURST_MAX (64) bytes in one transaction. A FIFO register that does not auto-increment takes them all at one address. KODE_DENIED for a bad length |
console
The console is the native USB-Serial/JTAG of the USB-C port. With CONFIG_KHAL_CONSOLE_DOCK it is also a TinyUSB CDC-ACM device on the magnetic pads (GPIO26 and GPIO27), and nothing above khal tells the two ports apart. The backend also builds for a plain UART.
Writes go through stdout and share newlib's stream lock. Reads never go through stdin: a select() on a VFS descriptor walks the file-system table that unmounting a FAT volume frees. The reader asks the transport driver for its bytes.
The calls of the console module:
| Call | What it does |
|---|---|
khal_console_init() | Installs the transport driver and sets input to verbatim, so a binary payload keeps its CR bytes. With the dock option it starts the dock port |
khal_console_start_reader(on_byte, prio) | Starts the khal.rd task, a plain task and not an Active Object, which blocks on the console and calls on_byte for each byte. The callback posts an event and returns. KODE_ERROR for NULL, KODE_NO_RESOURCES when the task cannot be created |
khal_console_raw_begin(pump, on_armed) | Asks the reader to hand the port to pump instead of reporting bytes. KODE_DENIED for NULL or when raw mode is armed or requested |
khal_console_read_raw(buf, want, timeout_ms) | Reads up to want bytes. Short reads are normal. 0 on timeout, -1 for bad arguments or no transport |
khal_console_raw_end() | Returns the reader to reporting bytes |
khal_console_write(buf, len) | Writes to every port the console has |
The sizes and times the console module is built with:
| Constant | Value |
|---|---|
| RX ring of the driver | 8192 bytes, about 16 ms of a payload at the line rate of USB. A full ring drops bytes with no sign |
| TX ring of the driver | 512 bytes |
| Reader task stack | 4096 bytes |
| Reader timeout | 50 ms, the longest it waits before it looks for a raw-mode request |
| Dock write wait | CONFIG_KHAL_CONSOLE_DOCK_TX_WAIT_MS, default 20, range 0 to 200. A host that does not drain makes the write drop the rest |
Raw mode streams a firmware image in over the port that carried the command. One event per byte could not do it: a 500 KB image would be half a million events. The steps run in order:
- A caller requests raw mode.
- At its next pass the reader drains the pending input, promotes the request and calls
on_armed. - Only then does the board tell the host to start the payload.
- The reader calls
pump, which reads chunks and ends the mode itself.
With the dock option, raw mode reads the one port that spoke last. The reader does the drain and the caller does not, because two readers of the driver's single-consumer ring would hang the console for good.
time
| Call | What it does |
|---|---|
khal_time_us(), khal_time_ms() | Time since boot. Monotonic; the millisecond count wraps after about 49 days |
khal_delay_ms(ms) | Sleeps and yields the CPU. Never from an Active Object |
khal_time_wall_us() | Microseconds since the Unix epoch, UTC. At or near 0 on a board never told the time |
khal_time_wall_set(unix_us) | Sets the wall clock. It rides on settimeofday(), which carries the RTC correction, so a date survives a reset |
khal_time_wall_known() | True when the reading is later than KODE_CLOCK_KNOWN_AFTER_S (2026-01-01). A threshold decides, so no flag has to agree with the clock |
khal_timer_periodic(cb, arg, period_us) | A periodic callback in an esp_timer task, until the next reset. It runs in task context, above every Active Object except AO_Rfid. kqp drives the QP tick with it. KODE_NO_RESOURCES or KODE_ERROR when the timer cannot be had |
The board keeps UTC and no time zone. The constant lives in the header of klog because klog needs it for the timestamp and khal already depends on klog.
sync
A lock marks something that no Active Object owns, since an Active Object handles one event at a time. The mutex protects the few shared places:
- the internal FAT mount
- the BLE console ring
- the LEDC table in
khal_pwm - the answer slots of the C5's services
- the ring of the Kode channel's replies
- the busy flags of the blocking facade calls
The semaphore serves the screen flush and the waits of the blocking facade calls.
| Call | What it does |
|---|---|
khal_mutex_create(), khal_mutex_lock(m), khal_mutex_unlock(m) | A non-recursive mutex. create returns NULL if there is no memory. lock waits as long as it takes; there is no timed take |
khal_sem_create() | A binary semaphore, not signalled |
khal_sem_wait_timeout(s, ms) | The only wait, and it always has a limit. 0 takes a pending signal without waiting. False when the time ran out |
khal_sem_signal(s) | Signals from a task or from an interrupt, deciding by itself which with xPortCanYield(). It lives in IRAM, because the DSI interrupt calls it with the flash cache off |
gpio
| Call | What it does |
|---|---|
khal_gpio_input(pin, pull) | A pin as an input with KHAL_GPIO_PULL_NONE, _UP or _DOWN |
khal_gpio_output(pin, level) | A push-pull output, driven to level at once |
khal_gpio_write(pin, level), khal_gpio_level(pin) | Drive an output; read any pin |
khal_gpio_release(pin) | Detaches the interrupt and returns the pin to high impedance with both internal pulls off. Safe on a pin that was never configured |
khal_gpio_irq_attach(pin, kind, cb, arg) | Arms KHAL_GPIO_IRQ_LOW_LEVEL, _HIGH_LEVEL or _ANY_EDGE. The pin must already be an input. KODE_DENIED for a NULL callback or a pin the P4 lacks |
khal_gpio_irq_enable(pin) | Arms the interrupt again after a trigger was served |
A pin that does not exist answers KODE_DENIED, and a configuration the driver refuses answers KODE_ERROR.
The interrupt service is installed without the IRAM flag. While the cache is off, for example during an image write, ESP-IDF masks the interrupt, which arrives late and not lost: a level is still there and an edge is latched.
pwm
The LEDC has 8 channels (KHAL_PWM_CHANNELS) and 4 timers (KHAL_PWM_TIMERS). A timer sets frequency and resolution, a channel sets duty and drives one pin.
Two users need it, the expansion header and the 125 kHz RFID carrier, and one table hands out both. All four timers run from PLL_F80M (80 MHz), because ESP-IDF refuses a timer whose source differs from one in use.
| Call | What it does |
|---|---|
khal_pwm_init() | Creates the lock. A second call is a no-op |
khal_pwm_open(&cfg, &ch) | Starts a channel. Takes the lowest free channel. A shared request joins a timer at the same frequency and resolution, or takes a free one. own_timer always takes a free one. On a refusal nothing changes and ch is KHAL_PWM_NONE |
khal_pwm_set(ch, hz, duty_x10) | Changes frequency and duty, never another channel's. A new frequency joins a shared timer, retunes the channel's own timer if it is the only user, or moves to a free timer. KODE_NO_RESOURCES leaves the channel as it was |
khal_pwm_pause(ch), khal_pwm_resume(ch) | Stops and restarts the channel's own timer. Only for a channel opened with own_timer. They take no lock |
khal_pwm_close(ch) | Frees the channel, and its timer with its last user. The pin is left a plain GPIO driven low |
The duty is in tenths of a percent, 0 to KHAL_PWM_DUTY_MAX (1000). KHAL_PWM_BITS_WIDEST asks for the widest resolution at the frequency: the largest n with the frequency times 2^n at most 80 MHz, so 1000 Hz gets 16 bits and 125 kHz gets 9.
A refusal is one of three statuses:
KODE_DENIEDfor aNULLpointer, a handle that is not open, a zero frequency, a duty over 1000 or an unreachable resolution.KODE_NO_RESOURCESfor no channel or no timer.KODE_ERRORwhen the LEDC refused.
Each logs its reason at debug level on the tag khal.pwm.
exp
The expansion header by position, 1 to 14 (KHAL_EXP_POS_MIN to KHAL_EXP_POS_MAX), never by GPIO number. What each position can become is in KBSP_EXP[] (sdk/kbsp). A position is in exactly one mode.
The six modes:
| Mode | Meaning |
|---|---|
KHAL_EXP_FREE | High impedance, held by nobody |
KHAL_EXP_IN | Digital input, with or without a pull |
KHAL_EXP_OUT | Digital output |
KHAL_EXP_ADC | Analog input on ADC1 |
KHAL_EXP_PWM | LEDC output, from khal_pwm |
KHAL_EXP_LENT | Handed to a peripheral driver under a tag |
The calls of the exp module:
| Call | What it does |
|---|---|
khal_exp_init() | Puts every position FREE and high impedance, closing its mode and voiding its loans. Idempotent but not harmless on a table in use: the owner calls it at its own start only |
khal_exp_input(pos, pull), khal_exp_output(pos, level), khal_exp_write(pos, level), khal_exp_level(pos) | Digital use. input on an OUT position, or output on an ADC position, changes the mode. level answers false in any other mode |
khal_exp_adc(pos), khal_exp_adc_read(pos, &raw, &mv, &cal) | ADC1 one-shot, 12 bits, 12 dB attenuation. With three non-NULL pointers all three outputs are written on every path, refusals included. Without the chip's calibration scheme, mv scales the count to a nominal 3100 mV full scale. The scheme is created per channel |
khal_exp_pwm(pos, hz, duty_x10) | A PWM output, or a retune. hz 1 to 40000, duty 0 to 1000. A request that does not fit answers KODE_NO_RESOURCES and the position keeps its mode |
khal_exp_irq_attach(pos, kind, cb, arg), khal_exp_irq_enable(pos) | khal_gpio's contract, on an IN position |
khal_exp_release(pos) | Back to FREE. KODE_DENIED when lent |
khal_exp_lend(pos[], n, tag, gpio[]) | Lends positions to a peripheral driver, all or none, writing back the GPIO numbers. Every position must be FREE |
khal_exp_return(tag) | Frees every position and controller lent under tag and returns the pins to high impedance. A tag that holds nothing is not an error |
khal_exp_ctrl(bus, tag, &id) | Takes the lowest free controller of that kind from KBSP_EXP_CTRL and writes IDF's number. Only the books: the caller installs ESP-IDF's driver. KODE_NO_RESOURCES when all are held |
khal_exp_ctrl_info(idx, &out), khal_exp_info(pos, &out) | What exp and ps print |
Eight positions can be PWM at once, and four distinct frequencies, because the LEDC has eight channels and four timers. While the RFID field is up, its carrier holds one channel and one timer of its own, which leaves seven and three.
Every refusal logs its reason once at debug level on the tag khal.exp. khal_exp cannot tell who calls it. KBSP_RES_EXP is claimed by the console or by the facade, and that claim stops two owners from using one table.
clk
| Call | What it does |
|---|---|
khal_clk_main_hz() | The nominal frequency of the main crystal, the reference of every measurement. 0 if the chip reports none |
khal_clk_slow_src() | Which source the RTC slow-clock mux is physically switched to: RC, the 32.768 kHz crystal, the 32 kHz RC, or unknown |
khal_clk_measure(src, cycles, &milli_hz) | Counts the crystal or the slow RC against the main crystal. A busy wait of about 31 µs per cycle at 32.768 kHz, so 1024 cycles holds the core for about 31 ms. KODE_TIMEOUT when the source produces no edges |
khal_clk_tree_hz(clk, &hz) | Reads the CPU, APB, RTC slow or LP fast clock in hertz |
The module has no call that asks whether the 32 kHz crystal is enabled. ESP-IDF's version reads a bit of a power domain, so it returns true with the crystal desoldered.
If the crystal does not start at boot, ESP-IDF tries three times, then switches to the internal RC with one warning before the console exists and no flag. The mux read and the measurement together show it. Everything is counted against the main crystal, so an error in that crystal cannot be seen from the board (sdk/kdrv/clocks).
sdmmc
The P4 has one SDMMC controller with two slots. Slot 0 is the microSD card and slot 1 is the link to the ESP32-C5.
| Call | What it does |
|---|---|
khal_sdmmc_mount(width, max_khz, &card) | Powers the IO rail (LDO channel KBSP_SD_LDO_CHAN, 10 ms to settle), identifies the card and mounts its FAT volume at /sdcard. Never formats. KODE_DENIED when something is mounted or attached |
khal_sdmmc_unmount(), khal_sdmmc_mounted(), khal_sdmmc_mount_point() | Unmount (safe with nothing mounted), query, and the path |
khal_sdmmc_attach(width, max_khz, &card), khal_sdmmc_detach(), khal_sdmmc_attached() | The card identified with no volume on it, for a computer that owns what is on it. A card cannot be mounted and attached at once |
khal_sdmmc_set_hosted(on), khal_sdmmc_hosted() | Whether the next mount reuses the controller esp-hosted owns. True at boot whenever esp-hosted is compiled in. Call with the card unmounted |
khal_sdmmc_info(&total, &free) | Size of the volume and free bytes. Asked every time |
khal_sdmmc_format() | A fresh empty FAT volume. See the gotcha |
khal_sdmmc_read_sectors(first, count, dst), khal_sdmmc_write_sectors(first, count, src) | Whole sectors, past the filesystem. A write on a mounted card overwrites what the filesystem holds without telling it |
The volume keeps at most 5 files open and is made with 64 KB clusters.
khal_sdmmc_card_t is a copy of the card's CID, CSD, SCR and SSR registers and of the link as negotiated, taken once per mount, because ESP-IDF's sdmmc_card_t is a live handle and giving it out would put driver/sdmmc_host.h above khal.
The card's own supply is a pin that kdrv_storage switches before every mount (KBSP_PIN_SD_PWR). This module powers only the IO domain.
system
| Call | What it does |
|---|---|
khal_restart() | A clean reset through esp_restart(). otadata and the boot request stay as they were |
khal_panic(why) | Stops through the panic handler, leaving a core dump with why. Q_onError and hcf end here |
khal_random() | 32 bits from the hardware generator. Not cryptography: it is fully seeded only once the radio or the SAR ADC runs |
khal_panic_print(fmt, ...) | Prints through the ROM with no lock, for a QP assert and an interrupt. Only %s %d %u %x %c |
khal_heap_free(), khal_psram_free(), khal_psram_largest() | Free bytes in the general heap (mostly PSRAM), in PSRAM, and the largest PSRAM block. Ask the last before a big allocation |
khal_heap_internal_free(), khal_heap_internal_largest(), khal_heap_internal_low() | The internal DMA-capable pool: free, largest block, and the lowest since boot (the sum of each region's own low mark) |
khal_heap_watch(), khal_heap_alloc_failures() | Counts every allocation the heap refuses and prints the first 4 with khal_panic_print(): size, capabilities and task. ps shows alloc_fail |
khal_task_list(out, max, &total) | A snapshot of the scheduler's tasks. Room for 32; a board with more gets no entries and total says why. The snapshot runs with interrupts off |
khal_task_stack_free(), khal_task_name() | The calling task's unused stack and its name. Both are safe where a snapshot is not; the name is safe from a critical section |
khal_watch_start(pass, period_ms) | Starts the supervisor task kqp.watch at the highest application priority, with a static 3072-byte stack in internal RAM, and subscribes it to the task watchdog set to panic. KODE_DENIED for a second call, NULL, a period under one tick or a build without the task watchdog |
khal_chip_info(&info) | Model, silicon revision (302 reads 3 and 2), flash size and PSRAM size, for the boot banner |
khal_alloc_psram(align, size), khal_realloc_psram(p, size), khal_free(p) | PSRAM only, zeroed on allocation, never a fallback to internal RAM. The realloc follows realloc() and does not zero new bytes |
khal has no allocator that tries internal RAM first. The 640 KB of internal RAM are shared with every DMA buffer, interrupt and task stack, and an allocator that took it when it happened to fit would leave the board with none for the radio's HCI commands. A build that does not carry the board's memory policy stops with an #error in khal_system_esp.c (sdk/kbsp).
The start hook. The hook port_start_app_hook() stalls core 1 while core 0 starts the scheduler, then a one-shot task core1_go releases it.
Without it, core 1's wait loop races the stack fills of the new tasks and can raise a store fault on core 0 before app_main. A boot that faults never confirms a new OS slot. On one build 25 of 80 boots faulted without the hook and none of 97 with it.
worker
| Call | What it does |
|---|---|
khal_worker_create(&w, name, stack_bytes, prio, depth, job_size, run, psram_stack) | A task pinned to no core and a queue that copies job_size bytes per job, created once and never released. psram_stack puts the stack, queue and job copy in PSRAM, and the control block stays internal. KODE_DENIED for a NULL argument or a zero size or depth |
khal_worker_submit(w, job) | Copies a job into the queue. Never blocks. KODE_NO_RESOURCES when the queue is full |
khal_worker_stack_free(w) | The stack never used, at the lowest point so far |
khal_worker_is_self(w) | Whether the caller is the worker's own task |
This is the task half. The Active Object half, KqpWorker, is in sdk/kqp. A worker knows nothing about events and decides nothing.
nvs
Small values in the nvs partition, behind one interface: strings, one byte, 64-bit values and blobs. Namespaces and keys have at most 15 characters. Every setter commits before it returns, and set_blob exists for records whose fields must agree, because one commit either lands or does not.
| Call | What it does |
|---|---|
khal_nvs_init() | Idempotent, and every other call runs it on first use. A partition of another NVS version or out of pages is erased whole rather than failing the boot |
khal_nvs_get_str, _get_u8, _get_u64, _get_blob | Read. KODE_DENIED when the key or the namespace is not there. That is the answer "nothing was stored" |
khal_nvs_set_str, _set_u8, _set_u64, _set_blob | Write and commit. KODE_ERROR when the store refuses |
khal_nvs_erase(ns, key) | Missing is success |
khal_nvs_key(ns, idx, out, cap) | The name of stored key number idx, so a store can be listed. No order is promised, each key appears once across 0 to N-1 while nothing is written, and KODE_DENIED past the last. Listing a namespace costs O(n²). A name that does not fit is refused, never cut |
identity
The serial is computed and never stored. It comes from the base MAC of the ESP32-C5, because the identity key and the certificate live on that chip. A C5 replaced in a repair gives the board a new serial. The MAC is cached in NVS (namespace kident, key c5mac) because the C5 is not always reachable.
| Call | What it does |
|---|---|
khal_identity_init() | Names the board from the cached MAC. KODE_ERROR when none was ever cached |
khal_identity_set_mac(mac) | Called by the owner of the C5 when the link comes up. Another MAC renames the board. KODE_DENIED for NULL or six zero bytes (an unburned eFuse); KODE_IO_ERROR when the cache could not be written, and the name lasts this boot |
khal_identity_serial(), khal_identity_pretty() | KODE plus 10 symbols plus 1 check symbol, and the printed form KODE-XXXX-XXXX-XXX. Empty string when unknown |
khal_identity_valid(), khal_identity_mac(mac) | Whether the board has a name, and the MAC behind it |
khal_identity_encode(mac, out, cap) | Pure encoder, public so a test can check it against a fixed answer |
The encoding: the 6 MAC bytes, most significant first, are left-padded to 50 bits and read 5 bits at a time as 10 symbols of Crockford Base32 (no I, L, O or U), so the first symbol is always 0 to 7. One check symbol follows, Luhn mod 32 over the ten. It catches every single-symbol error and every swap of neighbours except 0 with Z.
KHAL_ID_LEN is 16 and KHAL_ID_PRETTY_LEN is 19, both with the terminator, and both pinned by _Static_assert to the length of KBSP_ID_PREFIX.
The C5 firmware builds the same serial from the same bytes for its certificate request, so the two encoders must stay identical. The serial identifies a board and proves nothing: it is advertised and printed, so anyone can claim it.
temp, crc32 and zlib
| Call | What it does |
|---|---|
khal_temp_read(&deci_c) | Die temperature in tenths of a degree Celsius, so 415 is 41.5 °C. The first call installs and starts the sensor, set up for -10 to 80 °C. It reads the chip, not the air. Takes no lock; AO_Console is its only caller in the SDK |
khal_crc32(crc, buf, len) | The reflected CRC-32 of zlib and Ethernet, from the ROM. Chainable: the result seeds the next chunk, starting from KHAL_CRC32_INIT (0). Over a whole payload it equals Python's zlib.crc32(data). NULL or empty input returns crc unchanged |
khal_zlib_decompress(src, src_len, dst, dst_cap, &out_len) | Inflates one whole zlib stream with the ROM's tinfl_decompress(). KODE_OK also means the Adler-32 matched. KODE_ERROR for a damaged or cut stream, trailing bytes or a bad checksum. KODE_DENIED for NULL, a zero length, or a result bigger than dst_cap. KODE_NO_RESOURCES when PSRAM has no room for the decoder's state, about 11 KB, taken and freed inside the call |
The decoder runs with HAS_MORE_INPUT, so a stream cut short stops and does not decode zeros. AO_AppMgr, AO_Radio and AO_Storage checksum their transfers with khal_crc32(). kdrv_apps.c calls the ROM routine itself with the seed UINT32_MAX, and the ROM inverts the seed, so its values differ from this module's for the same bytes.
Gotchas
Example code
Log the CRC-32 of the standard test text, then the free PSRAM and the state of the internal pool. The CRC reads 0xCBF43926, the known check value of CRC-32.
/*
* khal_probe.c - 2026-10-28 - from Kode with love
*
* Calls three khal services from an app: the CRC-32 of "123456789", the free
* PSRAM, and the internal RAM pool. On a Kode Dot the log shows the check
* value 0xCBF43926 of CRC-32, then two lines of byte counts for the PSRAM
* and the internal pool.
*/
#include "kode.h"
#include "khal_crc32.h"
#include "khal_system.h"
#define PROBE_TAG "probe"
/* Bring the system up, then log what khal reports. */
void app_main(void)
{
static const char sample[] = "123456789";
kode_init();
uint32_t crc = khal_crc32(KHAL_CRC32_INIT, sample, sizeof(sample) - 1U);
KLOG_N(PROBE_TAG, "crc32 of %s is 0x%08X", sample, (unsigned)crc);
KLOG_N(PROBE_TAG, "free PSRAM %u bytes, largest block %u bytes",
(unsigned)khal_psram_free(), (unsigned)khal_psram_largest());
KLOG_N(PROBE_TAG, "internal pool: %u free, %u at its lowest",
(unsigned)khal_heap_internal_free(), (unsigned)khal_heap_internal_low());
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/khal: one folder per module, each withinclude/andsrc/.khal_exp.h: the expansion header by position.khal_system_esp.c: the heap, tasks, supervisor and start hook.