# 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

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

| Signal | Event | Direction | Meaning |
| --- | --- | --- | --- |
| `IR_RX_RDY_SIG` | one static event | in, posted by the link thread | A capture is waiting |
| `IR_SEND_REQ_SIG` | `IrSendEvt`: `addr`, `cmd` | in, posted | One NEC frame |
| `IR_SEND_RAW_REQ_SIG` | `IrSendRawEvt`: `sym`, `n`, `carrier_hz`, `rc`, `done`, `gen`, `ack` | in, posted | A raw frame lent by pointer |
| `IR_CAPTURE_REQ_SIG` | `IrCaptureEvt`: `out`, `cap`, `n`, `done`, `ms`, `gen`, `ack` | in, posted | Fill the buffer with the next frame |
| `IR_LAST_REQ_SIG` | `IrLastEvt` | in, posted | The last frame heard |
| `IR_INFO_REQ_SIG` | `IrInfoReqEvt`: `who` | in, posted | The console's `ir` |
| `IR_TEST_REQ_SIG` | none | in, posted by AO_Autotest | The row |
| `IR_JOB_DONE_SIG` | `KqpWorkerDoneEvt` | in, posted by the worker | A call to the C5 returned |
| `IR_ECHO_SIG`, `IR_CAPTURE_TO_SIG`, `IR_TEST_TO_SIG`, `IR_JOB_TO_SIG` | none | self, one-shot | The four deadlines |
| `IR_RX_SIG` | `IrRxEvt`: `addr`, `cmd`, `symbols`, `nec` | out, published | A frame heard. Never a frame the echo window or the row consumed |
| `IR_INFO_RSP_SIG` | `IrInfoEvt` | out, posted to the asker | The status |
| `TEST_RESULT_SIG` | named `ir` | out, published | The verdict |

### Facade, numbers and verbs

| Thing | Value |
| --- | --- |
| `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` |
| Row | 3 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 tail | 120 ms (`KODE_IR_ECHO_TAIL_MS`) |
| Console | `ir`, `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, Kconfig | None |

## Gotchas

> **WARNING**
>
> **Nothing is heard or sent, and every call says `KODE_DENIED`.** The block is behind the C5 link, and the link is down after each boot unless the firmware brings it up. Bring up the radio first (`radio_up`, or a firmware that keeps the link) and ask again.

> **WARNING**
>
> **Your own sends never show up in `IR_RX_SIG`.** That is the suppression window working: for the frame time plus 120 ms after a send, every capture is consumed. A real remote pressed in that window is missed. `kode_ir_last()` still reports it, since it is the last frame the receiver produced.

> **NOTE**
>
> **`kode_ir_capture()` returns `KODE_TIMEOUT`, and the receiver is fine.** `KODE_TIMEOUT` means AO_Ir did not answer in time, so the system and not the receiver is at fault. A wait that ends with no frame returns `KODE_OK` with `n` = 0.

> **NOTE**
>
> **The row owns the receiver while it runs.** A capture or a subscriber of `IR_RX_SIG` gets nothing from its transmissions, by design.

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

```c
/*
 * 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

- [`components/kaos/ir`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/ir): `ir_ao.c`, the worker, the echo window and the row.
- [`kode.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kode/include/kode.h): the `kode_ir_*` calls.
- [`components/kdrv/ir`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/ir): the driver and the NEC codec; see `sdk/kdrv/ir`.
