# 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

```mermaid
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
```

*Where khal sits*

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:

1. `kode_log_init()`
2. `khal_heap_watch()`
3. `khal_identity_init()`
4. `khal_pwm_init()`
5. `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:

1. A caller requests raw mode.
2. At its next pass the reader drains the pending input, promotes the request and calls `on_armed`.
3. Only then does the board tell the host to start the payload.
4. 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_DENIED` for a `NULL` pointer, a handle that is not open, a zero frequency, a duty over 1000 or an unreachable resolution.
- `KODE_NO_RESOURCES` for no channel or no timer.
- `KODE_ERROR` when 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

> **WARNING**
>
> **Two tasks calling one module corrupt it.** Nothing in a khal device module is thread-safe, and nothing needs to be while each device has one owner. An app that adds its own chip to the I2C bus is the one owner of that chip. Do not call `khal_i2c_*` for a chip that kodeOS's Active Objects own.

> **WARNING**
>
> **NVS refuses a task whose stack is in PSRAM.** Every NVS call turns the flash cache off, and such a task cannot run through that. The call returns `KODE_DENIED` with one error line naming the call and the task: `khal_nvs_set_u8 refused: task 'x' has its stack in PSRAM and NVS reaches the flash`.
>
> A getter returns the same status for a key that is not there, so a refused read looks like a missing key and the default applies. Only the log line tells the two apart. The same rule applies to a worker created with `psram_stack` true.

> **WARNING**
>
> **A pin interrupt fires once, then waits for you.** The handler disables the interrupt before it calls your callback, and only `khal_gpio_irq_enable()` arms it again. Read whatever clears the line first, then re-arm: a level that is still there fires again at once, and nothing is lost or looped. The callback runs in interrupt context and may only post a static event with `kqp_post_from_isr()`.

> **DANGER**
>
> **After a failed `khal_sdmmc_format()` nothing is mounted.** ESP-IDF's format detaches the volume, makes the file system, and mounts again. When the last step fails it frees the card but still returns OK, so a caller would hold a pointer to a card that does not exist. khal asks the filesystem whether the volume came back and drops the card on any failure. Mount again before you use the card.

## 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.

```c
/*
 * 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`](https://github.com/kodediy/kode-sdk/tree/main/components/khal): one folder per module, each with `include/` and `src/`.
- [`khal_exp.h`](https://github.com/kodediy/kode-sdk/blob/main/components/khal/exp/include/khal_exp.h): the expansion header by position.
- [`khal_system_esp.c`](https://github.com/kodediy/kode-sdk/blob/main/components/khal/system/src/khal_system_esp.c): the heap, tasks, supervisor and start hook.
