SDK
Infrared
The infrared emitters and receiver, which belong to the ESP32-C5: how the P4 asks it to send and capture frames, and the NEC codec above them.
What it is
Remote controls talk with invisible light. A Kode Dot has two SFH4045N infrared emitters that send it and one TSOP75338 receiver that listens. It can learn a button from a remote and send it back, or send a NEC frame it built itself.
The emitters and the receiver are wired to the ESP32-C5, not to the ESP32-P4. The P4 has no infrared pin and opens no RMT channel for them. The C5's firmware carries a small infrared service that owns its RMT peripheral, and the P4's driver is a client of that service: it asks, and the C5 does the timing.
The receiver is a fixed 38 kHz demodulator, so it strips the carrier and hands back only on and off times. A capture is a list of symbols, each a mark (carrier on) followed by a space (carrier off), in microseconds:
light of the emitter: ||||||||||||||| |||||||||||||||
signal of the receiver: ############### ###############
mark space markA capture has no carrier frequency, so a captured frame is replayed at 38 kHz. A frame built by a codec is exact, because the carrier belongs to the C5's own RMT channel.
How it is connected
flowchart LR subgraph p4["ESP32-P4"] ao["AO_Ir<br/>ir_worker"] --> drv["kdrv_ir"] end drv -- "kdrv_radio_peer_send" --> link["esp-hosted link"] link --> c5 subgraph c5g["ESP32-C5"] c5["kode_ir.c"] end c5 -- "IO24" --> tx["2 emitters<br/>SFH4045N"] rx["receiver<br/>TSOP75338"] -- "IO23" --> c5 c5 -. "events" .-> drv class ao ao class tx,rx chip
Who talks to whom
IO24 of the C5 drives the emitters (KBSP_C5_IO_IR_TX) and IO23 reads the receiver (KBSP_C5_IO_IR_RX). These are the C5's own numbers and not P4 pins, and sdk/tools/check_layers.sh forbids driver/rmt_* anywhere on the P4: a file that opened an RMT channel there would drive a pin the board does not route.
AO_Ir (sdk/kaos/ir, priority 18) owns the block (KBSP_RES_IR). It makes every call that crosses the link as a job on its worker, ir_worker (8192-byte stack, in PSRAM), because each request may wait behind whatever else the C5 is serving. The capture callback runs on esp-hosted's thread, so it posts an event and returns.
An app with a codec of its own sends any frame through kode_ir_send_raw(), which goes through AO_Ir.
The driver speaks over the link's peer-data seam (kdrv_radio_peer_send() and kdrv_radio_peer_listen(), see sdk/kdrv/c5), with one message id each way and a first byte that says which request or event it is. One id serves every verb, because the C5's handler table has three entries and the infrared service is only one of the things that may want one.
kir_wire.h lives in two repositories, in this driver and in the C5 firmware, and the two copies must be identical. KIR_WIRE_VERSION goes up with every change, and the P4 ignores any message that carries another version.
How a call works
- Start.
kdrv_ir_init()registers the event listener. It makes no request and waits for nothing, so it succeeds with the link down. If the link is already up it also arms the receiver. It returnsKODE_ERRORonly in a build with no radio, andKODE_NO_RESOURCESif a semaphore or listener slot cannot be made. - Send.
kdrv_ir_send()copies the symbols after akir_send_theader (carrier and count) and hands the request to the link. It returns when the request is queued, never when the frame has left. One transmission may be in flight. The driver tracks it with a deadline: the time the frame itself takes plus 1000 ms of grace (IR_SENT_GRACE_MS). A second call before the C5 reportsKIR_EVT_SENTis refused, except past the deadline, when it logs that it is sending anyway, so a lost event cannot silence the transmitter for the rest of the boot. - Zero durations. The RMT reads a zero duration as its own end marker. A zero space, which a codec uses to say the frame ends here, goes out as the longest space the peripheral can express, which is also the gap a receiver needs to see the frame end. A zero mark goes out as 1 µs. Anything longer than the RMT's duration field is clamped.
- Capture. The receiver takes one capture per arming.
kdrv_ir_rx_arm()sends the request and returns without waiting forKIR_EVT_ARMED. AKIR_EVT_CAPTUREevent copies its symbols into the driver's buffer and marks the receiver not armed.kdrv_ir_rx_take()copies the symbols out, truncating at your capacity and never wrapping, because half a frame that looks whole is worse than a missing one. The owner arms after it takes, not on a timer. - Line probes.
kdrv_ir_rx_idle()reads the receiver line at rest with the C5's pulls off; it proves only that the line is not stuck low, since a bare pin reads high like a working receiver.kdrv_ir_rx_present()tells the two apart: the C5 drives the line low for 1 ms, releases it to a bare input and reads it back. A fitted receiver recovers quickly with its own pull-up. A bare pin stays low. - A new link. The C5 has been reset whenever the link comes up, so no capture is in flight, nothing is armed and nothing is transmitting, whatever was true before. The link-up callback clears all three flags. Without it, a capture "in flight" across a reset would refuse every later arm.
The NEC codec
knec_encode() and knec_decode() speak kdrv_ir_symbol_t and nothing else, with no peripheral in them, so the host tests run them with a mocked kdrv_ir.h. A frame is a leader (a mark of 9000 µs and a space of 4500 µs), then 32 bits, then a stop mark: 34 symbols (KNEC_SYMBOLS).
- The 16-bit address and the 16-bit command go out least-significant bit first.
- A
0is 560 µs of mark and 560 µs of space. - A
1is 560 µs of mark and 1690 µs of space. - There is no inversion check, so any pair of values survives, including
0x0000and0xFFFF, which standard NEC would reject.
The decoder is asymmetric on purpose. The TSOP75338's gain control holds its output low past the end of a burst, so every received mark comes back longer and every space shorter. The decoder accepts a mark up to KNEC_MARK_EXCESS_US (200 µs) beyond its window and a space that far below.
The decoder never corrects a recording. The figure is sized for other people's remotes: the Kode Dot's emitter heard by its own receiver widens a mark by 11 to 38 µs.
The files
The files of the component:
| File | What it does |
|---|---|
include/kdrv_ir.h, src/kdrv_ir.c | The P4's end of the infrared service: init, send, arm, take, the two line probes, the link-up reset |
include/knec.h, src/knec.c | The NEC encoder and decoder. Pure C |
include/kir_wire.h | The wire format shared with the C5 firmware |
Specifications
The wire
All fields are little-endian and the structs are packed, with their sizes asserted.
| Thing | Value |
|---|---|
| Message ids | Request KIR_MSG_REQ 0x4B495201, event KIR_MSG_EVT 0x4B495281 |
| Version | KIR_WIRE_VERSION 1 |
| Symbols | Up to 256 per request or capture (KIR_MAX_SYMBOLS); a symbol is 4 bytes, mark_us then space_us as uint16_t |
| Requests | SEND 1 (kir_send_t, 8 bytes, then the symbols), ARM 2, IDLE 3, PRESENT 4 (kir_plain_t, 2 bytes each) |
| Events | SENT 1 and ARMED 4 (kir_status_t, 3 bytes), CAPTURE 2 (kir_capture_t, 4 bytes, then the symbols), LEVEL 3 (kir_level_t, 5 bytes) |
| Status bytes | KIR_OK 0, KIR_FAIL 1 (the peripheral refused), KIR_BAD_REQUEST 2 |
The block
| Thing | Value |
|---|---|
| Emitters | Two SFH4045N, driven from C5 IO24 |
| Receiver | TSOP75338, fixed 38 kHz demodulator, read on C5 IO23 |
| Carrier | 38000 Hz (KDRV_IR_CARRIER_HZ) for everything the board's own receiver can hear |
| Frame size | 256 symbols to send and to capture (KDRV_IR_TX_MAX_SYMBOLS, KDRV_IR_RX_MAX_SYMBOLS); a longer capture is truncated |
| One request | Up to 5000 ms for the C5's answer (KDRV_IR_RPC_WAIT_MS, esp-hosted's default) and 500 ms more for a line reading's reply (KDRV_IR_REPLY_MS): 5500 ms in all (KDRV_IR_CALL_MAX_MS) |
| Sent grace | 1000 ms past the frame's own time |
| NEC | 34 symbols; leader 9000 and 4500 µs; bit 560 µs mark, 560 or 1690 µs space; decoder mark excess 200 µs |
The driver
| Call | What it does |
|---|---|
kdrv_ir_init(cb, ctx) | Register the listener; arm if the link is up. cb may be NULL, in which case captures are collected and nobody is told |
kdrv_ir_send(sym, n, carrier_hz) | Queue a frame. KODE_OK when queued; KODE_DENIED for a bad argument, a transmission in flight, before init or with the link down |
kdrv_ir_rx_arm() | Ask for the next capture. KODE_OK when sent; KODE_ERROR if already armed; KODE_DENIED before init or with the link down |
kdrv_ir_rx_take(out, cap) | Copy out the waiting capture; returns the symbol count, 0 when none. AO_Ir alone calls it |
kdrv_ir_rx_idle(&high), _rx_present(&present) | A round trip to the C5 each. present costs any capture in flight |
knec_encode(addr, cmd, out, cap, &n) | KODE_DENIED when cap is under 34 or a pointer is NULL |
knec_decode(sym, n, &addr, &cmd) | True when the frame decoded; outputs written only then |
Gotchas
Example code
Point a remote at the Kode Dot, press a button, and this example records the frame and sends it back after a short pause. It waits up to ten seconds for a frame, logs how many symbols it heard, and replays them at 38 kHz. With the receiver working, the log reads like captured 68 symbols and the replay switches the device the remote controls.
/*
* ir_replay.c - 2026-10-28 - from Kode with love
*
* Waits up to ten seconds for a frame from an infrared remote, then sends
* the same frame back at 38 kHz. Point a remote at the Kode Dot and press a
* button. The log shows "captured 68 symbols" and the device the remote
* controls reacts to the replay.
*/
#include "kode.h"
#define TAG "ir_replay"
static kdrv_ir_symbol_t frame[KODE_IR_RAW_MAX_SYMBOLS];
/* Bring the system up, then capture and replay frames forever. */
void app_main(void)
{
size_t count = 0;
kode_init();
while (1) {
if (kode_ir_capture(frame, KODE_IR_RAW_MAX_SYMBOLS, &count,
10000) == KODE_OK && count > 0) {
KLOG_I(TAG, "captured %u symbols", (unsigned)count);
/* Pause for half a second, then replay the frame as heard. */
kode_delay_ms(500);
kode_ir_send_raw(frame, count, KDRV_IR_CARRIER_HZ);
}
}
}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/ir: the driver, the NEC codec and the wire format.kdrv_ir.h: the calls, the limits and the rules about who may call what.components/kaos/ir: AO_Ir, the owner (sdk/kaos/ir), which suppresses the echo of its own transmissions.