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           mark

A 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
From the P4 to the emitters

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

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 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:

FileWhat it does
include/kdrv_ir.h, src/kdrv_ir.cThe P4's end of the infrared service: init, send, arm, take, the two line probes, the link-up reset
include/knec.h, src/knec.cThe NEC encoder and decoder. Pure C
include/kir_wire.hThe wire format shared with the C5 firmware

Specifications

The wire

All fields are little-endian and the structs are packed, with their sizes asserted.

ThingValue
Message idsRequest KIR_MSG_REQ 0x4B495201, event KIR_MSG_EVT 0x4B495281
VersionKIR_WIRE_VERSION 1
SymbolsUp to 256 per request or capture (KIR_MAX_SYMBOLS); a symbol is 4 bytes, mark_us then space_us as uint16_t
RequestsSEND 1 (kir_send_t, 8 bytes, then the symbols), ARM 2, IDLE 3, PRESENT 4 (kir_plain_t, 2 bytes each)
EventsSENT 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 bytesKIR_OK 0, KIR_FAIL 1 (the peripheral refused), KIR_BAD_REQUEST 2

The block

ThingValue
EmittersTwo SFH4045N, driven from C5 IO24
ReceiverTSOP75338, fixed 38 kHz demodulator, read on C5 IO23
Carrier38000 Hz (KDRV_IR_CARRIER_HZ) for everything the board's own receiver can hear
Frame size256 symbols to send and to capture (KDRV_IR_TX_MAX_SYMBOLS, KDRV_IR_RX_MAX_SYMBOLS); a longer capture is truncated
One requestUp 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 grace1000 ms past the frame's own time
NEC34 symbols; leader 9000 and 4500 µs; bit 560 µs mark, 560 or 1690 µs space; decoder mark excess 200 µs

The driver

CallWhat 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