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

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

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

- **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 returns `KODE_ERROR` only in a build with no radio, and `KODE_NO_RESOURCES` if a semaphore or listener slot cannot be made.
- **Send.** `kdrv_ir_send()` copies the symbols after a `kir_send_t` header (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 reports `KIR_EVT_SENT` is 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 for `KIR_EVT_ARMED`. A `KIR_EVT_CAPTURE` event 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 `0` is 560 µs of mark and 560 µs of space.
- A `1` is 560 µs of mark and 1690 µs of space.
- There is no inversion check, so any pair of values survives, including `0x0000` and `0xFFFF`, 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

> **WARNING**
>
> **Every call that crosses the link answers `KODE_DENIED` while the link is down.** The link comes up on demand and not at boot: `radio_up`, a stored Wi-Fi or Bluetooth flag, or the C5 production row starts it. So `kdrv_ir_init()` succeeds with no link, `kdrv_ir_rx_take()` returns 0 and the rest refuse until the link is up. A firmware built without `KODEOS_RADIO` has no infrared at all.

> **WARNING**
>
> **`kdrv_ir_send()` is not thread-safe.** It reads its busy flag and sets it in two steps with no lock, so two callers on two cores can lay symbols over a frame still being sent. The receiver calls belong to AO_Ir alone. A firmware with its own codec should send through `kode_ir_send_raw()`, which needs none of this care, and call the driver directly only while nothing asks AO_Ir to send.

> **NOTE**
>
> **A presence probe throws away a capture.** `kdrv_ir_rx_present()` disarms the channel on the C5 and arms it again on the way out, so a frame arriving or waiting to be taken is lost. The two line readings are also a round trip to the C5, with up to 500 ms for the reply, so a production row or a bench script must allow for it.

> **NOTE**
>
> **A captured frame replays at 38 kHz whatever it was sent at.** The receiver removes the carrier, so the capture does not say which one the remote used, and a remote on another carrier replays at 38 kHz. The marks also come back a little long, by the gain control of the receiver.

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

```c
/*
 * 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`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/ir): the driver, the NEC codec and the wire format.
- [`kdrv_ir.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kdrv/ir/include/kdrv_ir.h): the calls, the limits and the rules about who may call what.
- [`components/kaos/ir`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/ir): AO_Ir, the owner (`sdk/kaos/ir`), which suppresses the echo of its own transmissions.
