SDK

AO_Ir

Owns the infrared block of a Kode Dot: NEC and raw sends through the C5, frames heard as IR_RX_SIG, echo suppression and the nonce self-test.

What it is

AO_Ir sends and receives infrared for a Kode Dot. The block is two SFH4045N emitters and one TSOP75338 receiver. AO_Ir sends a NEC frame on request, sends a raw frame of any protocol, and publishes every frame the receiver hears as IR_RX_SIG. An app that wants one keypress from a remote asks for the next frame or subscribes to the signal.

Both infrared lines belong to the ESP32-C5, not the P4: IR_TX is C5 pin IO24 and IR_RX is IO23, and the C5's firmware drives them with its RMT. The P4 reaches this infrared service over the esp-hosted link (sdk/kdrv/ir, sdk/kdrv/c5). So infrared needs KODEOS_RADIO and a link that is up.

The two devices share one enclosure, so the receiver hears the emitter. Every frame the Kode Dot sends comes back a moment later. AO_Ir ignores the receiver for a short suppression window after each send, otherwise an app that sends remote-control frames would see its own keypresses arrive as input.

How it is connected

flowchart LR
subgraph p4["On the P4"]
  ao["AO_Ir<br/>priority 18"] -- "kqp_worker_submit()" --> wk["ir_worker"]
end
subgraph c5box["On the C5"]
  c5["Infrared service"]
end
app["kode_ir_*()<br/>AO_Console"] -- "IR_*_REQ_SIG" --> ao
wk -- "kdrv_ir_send(), rx_arm()" --> c5
c5 --> led["SFH4045N x2<br/>emitters"]
tsop["TSOP75338<br/>receiver"] -- "IR_RX" --> c5
c5 -. "IR_RX_RDY_SIG" .-> ao
ao -- "IR_RX_SIG" --> subs["Subscribers"]
class ao ao
class c5,led,tsop chip
class app,subs app
AO_Ir, its worker and the C5

AO_Ir claims KBSP_RES_IR ("infrared (TSOP75338 + 2x SFH4045N)"), the whole block. It runs at priority 18 (KODE_PRIO_IR) with a 16-event queue and a 3072-byte stack. kode_init() starts it after AO_LedMgr. No Kconfig option turns it off.

Level 18 is set by arithmetic and not urgency: levels below 18 are ordered by urgency, QF_MAX_ACTIVE is 25, and a new level can only be opened at the top. AO_Ir can sit there because no step of it waits.

One state and a flag

Ir_ready is the only state. A flag up records whether kdrv_ir_init() succeeded; it succeeds whether or not the link is up, and every later call answers KODE_DENIED until the link is.

A failed start (a firmware with KODEOS_RADIO off, or no free event slot) logs the infrared block did not come up; nothing is heard and nothing can be sent, and every request is answered from the flag.

The worker

ir_worker has an 8192-byte stack in PSRAM (IR_WORKER_STACK) and a queue of one job. It runs one call to the C5 at a time: arm the receiver, send a frame, the presence probe of the row, or a line reading. AO_Ir reads the status in IR_JOB_DONE_SIG.

While a call is out, a request that needs the C5 (a send, a capture to arm for, the row, a status answer) waits on a defer queue of 8 (KODE_IR_DEFER_DEPTH) and is taken again in order. Beyond 8, a frame is not sent, a capture is answered empty, a status answer goes without the line reading and a second row is ignored.

The call has a deadline, IR_JOB_TO_SIG, of the driver's longest call (5.5 s) plus 500 ms (KODE_IR_JOB_MARGIN_MS). When it fires AO_Ir logs a call to the C5 did not return and tells whoever waits that it failed. The guard stays up until the call returns, so the worker is never handed a second call and the buffers it reads are never written under it.

What arrives

The receiver is on the C5, so a capture arrives on the link's thread. on_capture() posts a static IR_RX_RDY_SIG and returns. heard() then runs in AO_Ir's step. It takes the capture (an empty take ends it), re-arms the receiver at once, decodes, records the frame as the last one heard, and offers it to three parties in order:

  1. The production row, which outlives the echo window.
  2. The echo check.
  3. A pending ir_capture_request() and IR_RX_SIG.

A readiness event can arrive with nothing behind it, because the row's presence probe disables and re-arms the channel. That is ordinary and heard() does not re-arm. The last frame heard is what the receiver produced, even if the echo window or the row consumed it.

What leaves

ir_send_request() encodes a NEC frame with knec_encode() and passes it to kdrv_ir_send(). ir_send_raw_request() carries up to 256 symbols (KDRV_IR_TX_MAX_SYMBOLS) lent by pointer, since a full frame is a kilobyte that only the jumbo pool holds, plus a carrier.

The worker reads the lent frame, so AO_Ir answers only once the call has returned, even past its deadline. A raw request carries its post time and how long its requester will wait for the call to start. Held longer, it is answered KODE_TIMEOUT and never sent, so a frame never goes out after its requester was told it did not.

The suppression window

It opens only when the frame reached the peripheral; if the driver refuses, there is no echo and no window. It lasts the frame's transmit time plus KODE_IR_ECHO_TAIL_MS (120 ms). A frame of short bits takes about 50 ms and one of long bits up to 86 ms.

The tail is long because the receiver is on the C5: the capture arrives after the closing space (32.8 ms) and needs two trips over the link. A shorter window would publish the board's own keypresses; a longer one would hide a real remote.

The production row

test component=ir has two checks in a fixed order, so the verdict names a part.

  1. Check 1 calls kdrv_ir_rx_present(), a round trip to the C5 that transmits nothing. A receiver that does not recover by itself fails the row at once, and the log names the receiver as the part to replace. With the link down it also fails here, and the log names the link.
  2. Check 2 runs only if check 1 passes. It sends a NEC frame with the fixed address 0xA55A and a random 16-bit command from khal_random(), and requires that exact frame back. The nonce means a lamp, a nearby remote or the unit on the next bench cannot fake a pass.

Up to 3 attempts run (KODE_IR_TEST_TRIES), each with a new nonce, and the log shows every attempt. Only the deadline of an attempt, 500 ms (KODE_IR_TEST_ECHO_MS), ends it: a frame that is not the current nonce says nothing about the emitter or receiver, and a retry transmits at once while the frame it replaces may still be in the air.

The row keeps a short history of its nonces to tell its own late echo from someone else's frame, and only the second gets a warning. While the row runs it owns the receiver: nothing reaches IR_RX_SIG or a pending capture, whose deadline still answers it.

Status

ir_info_request() (for the console, answered by IR_INFO_RSP_SIG) and ir_last_request() (for kode_ir_last(), which lends a pointer and a semaphore) answer from state AO_Ir holds, in every state. ir_capture_request() waits: it holds the request until a frame arrives or its deadline expires, and a second request replaces the first, which is answered with no frame.

Timers

Four one-shots, none armed at rest: IR_ECHO_SIG (the window), IR_CAPTURE_TO_SIG, IR_TEST_TO_SIG and IR_JOB_TO_SIG. A disarm cannot tell a timer never armed from one that expired with its event still queued, so each has a generation counter (kqp_timer_stale()), and each handler drops one stale event per count before it treats an event as real. Without them a window would close the moment it opens.

Specifications

Signals

SignalEventDirectionMeaning
IR_RX_RDY_SIGone static eventin, posted by the link threadA capture is waiting
IR_SEND_REQ_SIGIrSendEvt: addr, cmdin, postedOne NEC frame
IR_SEND_RAW_REQ_SIGIrSendRawEvt: sym, n, carrier_hz, rc, done, gen, ackin, postedA raw frame lent by pointer
IR_CAPTURE_REQ_SIGIrCaptureEvt: out, cap, n, done, ms, gen, ackin, postedFill the buffer with the next frame
IR_LAST_REQ_SIGIrLastEvtin, postedThe last frame heard
IR_INFO_REQ_SIGIrInfoReqEvt: whoin, postedThe console's ir
IR_TEST_REQ_SIGnonein, posted by AO_AutotestThe row
IR_JOB_DONE_SIGKqpWorkerDoneEvtin, posted by the workerA call to the C5 returned
IR_ECHO_SIG, IR_CAPTURE_TO_SIG, IR_TEST_TO_SIG, IR_JOB_TO_SIGnoneself, one-shotThe four deadlines
IR_RX_SIGIrRxEvt: addr, cmd, symbols, necout, publishedA frame heard. Never a frame the echo window or the row consumed
IR_INFO_RSP_SIGIrInfoEvtout, posted to the askerThe status
TEST_RESULT_SIGnamed irout, publishedThe verdict

Facade, numbers and verbs

ThingValue
kode_ir_send_nec(addr, cmd)Posts and returns KODE_OK; safe from an AO. A dry pool drops it inside AO_Ir with a log line
kode_ir_send_raw(sym, n, carrier_hz)Blocks; n is 1 to 256; 38000 Hz for most remotes and for what the board's receiver hears. KODE_DENIED, KODE_NO_RESOURCES, KODE_TIMEOUT as documented in kode.h
kode_ir_capture(out, cap, &n, ms)Blocks up to ms (at most 600000, KODE_IR_TIMER_MS_MAX); never from an AO; at most 256 symbols; one call at a time, a second gets KODE_DENIED
kode_ir_last(&frame)Blocks only for a round trip. kdrv_ir_frame_t: addr, cmd, symbols, nec
Row3 tries of 500 ms, console budget 3.5 s (KODE_IR_TEST_BUDGET_MS). Measured on a Kode Dot: the row passed on the first attempt every time, the echo arriving 78 to 84 ms after the send, the whole row taking about 0.2 s inside test component=all
Echo tail120 ms (KODE_IR_ECHO_TAIL_MS)
Consoleir, ir_help, ir_send, ir_rx, test component=ir. Run radio_up first, in the same session, or the row fails at once and names the link
Stored settings, KconfigNone

Gotchas

Example code

This sends one NEC frame, address 0x00FF and command 0x1C, every 3 s, and logs the last frame the receiver heard. Point a TV remote at the Kode Dot and press a key to see its frame. The log level must be i: config key="log" value=i. The radio link must be up.

/*
 * ir_nec.c - 2026-10-28 - from Kode with love
 *
 * Sends a NEC frame (address 0x00FF, command 0x1C) every 3 s through AO_Ir
 * and logs the last frame the receiver heard. Press a key on a remote aimed
 * at the Kode Dot and the console shows its address and command.
 */
#include "kode.h"
 
/* Bring the system up, then send a frame and report what was heard. */
void app_main(void)
{
    kode_init();
 
    while (1) {
        kdrv_ir_frame_t frame;
 
        kode_ir_send_nec(0x00FF, 0x1C);
        kode_delay_ms(500);
        if (kode_ir_last(&frame) == KODE_OK) {
            KLOG_I("ir_nec", "heard addr=%04X cmd=%04X nec=%d",
                   (unsigned)frame.addr, (unsigned)frame.cmd, (int)frame.nec);
        }
        kode_delay_ms(2500);
    }
}

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.

In the SDK