# TCA6408A expander

The eight-pin I/O expander that carries five buttons, two interrupt lines and the screen's power switch on a Kode Dot: pins, interrupt, registers and driver.

## What it is

The **TCA6408A** from Texas Instruments gives the P4 eight more pins over the I2C bus. The P4 reads all eight in one transaction or drives them, one byte at a time. On a Kode Dot those pins carry five of the six buttons, the touch controller's interrupt, the motion sensor's interrupt and the switch that powers the AMOLED panel.

Four parts of kodeOS need it: the buttons, the touch controller, the motion sensor and the display. So one owner talks to it, **AO_IoExp**, and the others ask. The chip also has a ninth line, `INT`. It goes low when an input differs from what the processor last read, so the firmware waits for a change instead of reading the chip over and over.

## How it is connected

```mermaid
flowchart LR
  subgraph board["On the Kode Dot"]
    btn["5 buttons<br/>P0 to P4"] --> tca["TCA6408A"]
    imu["QMI8658A<br/>INT1, P5"] --> tca
    tp["touch INT, P6"] --> tca
    tca -- "P7, DSP_PWR_EN" --> pnl["AMOLED supply"]
    p4["ESP32-P4"] -- "I2C, 0x20" --> tca
    tca -- "INT, GPIO6" --> p4
    ao["AO_IoExp"] --> p4
  end
  class tca,imu,pnl,p4 chip
  class ao ao
```

*The expander on the Kode Dot*

The chip is U24 and answers at `0x20` on the shared bus (`KBSP_I2C_ADDR_IOEXP`). Its `ADDR` pin is tied to ground; tied to the supply it would answer at `0x21`. If a board does not answer at `0x20`, try `0x21` before you decide the chip is dead. Both supplies are 3.3 V, and the bus runs at up to 400 kHz.

### The pins

Every input is active low. The chip has no pull-ups of its own, so each line carries a resistor or a driver.

| Pin | Net | What is on it | Pull-up | Direction |
| --- | --- | --- | --- | --- |
| P0 | `BUTTON_BOT` | SW2, the bottom button. The same net is the `QON` pin of the BQ25896 | The 200 kΩ inside the charger on `QON`; no resistor of its own | Input |
| P1 | `PAD_TOP` | SW3, pad up | R39, 10 kΩ | Input |
| P2 | `PAD_LEFT` | SW4 | R40, 10 kΩ | Input |
| P3 | `PAD_BOTTOM` | SW5, pad down | R41, 10 kΩ | Input |
| P4 | `PAD_RIGHT` | SW6 | R42, 10 kΩ | Input |
| P5 | `IMU_INT1` | `INT1` of the QMI8658A, driven push-pull by the chip | None | Input |
| P6 | `DSI_TP_INT` | The touch controller's interrupt, from the panel module | On the module | Input |
| P7 | `DSP_PWR_EN` | `VCI_EN`, the AMOLED supply enable, active high | R48, 47 kΩ pull-down | Output |

The sixth button, the top one, is not here: it is on GPIO35 (`sdk/kdrv/buttons`).

Two button combinations act in hardware, with no software involved:

- `BUTTON_BOT` and `PAD_LEFT` held together for about a second reset the P4: an OR gate (U12, 74LVC1G32) watches both nets through an RC network and pulls `CHIP_PU` low.
- `BUTTON_BOT` held for 16 to 23 s with no USB power-cycles the board through the charger.

### The owner

AO_IoExp claims `KBSP_RES_IOEXP`, runs at priority 17 and serves the chip in four ways:

1. **Boot.** `kdrv_tca6408a_init()` probes the address for 100 ms, reads the four registers and logs them. It writes nothing. AO_IoExp then makes every pin in `KBSP_IOEXP_DIR_INPUTS` (`0x7F`, P0 to P6) an input again, touching only the bits that are wrong. P7 is left as found, because the panel's owner decides it. It publishes the first reading and arms `INT`.
2. **On `INT`.** The interrupt handler posts one static event and returns. AO_IoExp reads the input register, which releases the line, publishes `IOEXP_INPUT_SIG` with all eight levels and the bits that changed since the last read, and re-arms the interrupt. If the read fails, it arms a one-shot (`KODE_IOEXP_RETRY_MS`, 500 ms) and looks again, instead of re-arming a line that is still low.
3. **On request.** `ioexp_read_request()` publishes the input byte now. `ioexp_field_request(who, op, field, value)` reads, writes or dumps one field, and the answer is posted to `who` as `IOEXP_FIELD_RSP_SIG`. AO_Display uses it to set P7: first the output bit `o7` to 1, then `c7` to 0.
4. **Self-test.** `test component=ioexp` checks that P0 to P6 face in, that the input register reads, and that the read released `INT`.

With no chip, AO_IoExp enters a dead state: it publishes readings with `ok` false and answers every field request with `KODE_ERROR`.

### The interrupt

**The interrupt is a level.** `INT` is open drain, active low. The chip pulls it low when an input differs from what the last read returned, and releases it when the processor reads the input register or when the pin returns to the state of that read. Output pins never pull it low, so P7 changes raise nothing. The driver arms GPIO6 as a low level, with no internal pull-up because R24 (10 kΩ) is on the board. A level waits to be served; an edge missed while the P4 is busy would be a lost button. The handler disables the interrupt, so it fires once per arming.

### Reset

The chip's `RESET` pin is tied to 3.3 V through R117, so only a power cycle resets it. After a reset of the P4 the registers hold what the last firmware left, usually `config` at `0x7F` with P7 still driving the panel. The boot log says `left by the last firmware` or `power-on defaults`.

### The driver

**The driver is one table** of 4 registers and 32 bit fields, read and written through `kdrv_tca6408a_get()` and `_set()`, the same shape as the charger's and the gauge's. A `set` refuses the input port, checks the range, does a masked read-modify-write and reads back, comparing only its own bits. The driver logs when the chip stops answering and when it returns, once each, because every interrupt comes through it.

### The wake from light sleep

`kdrv_tca6408a_sleep_wake(armed)` lets the INT line wake the P4 from light sleep, with `CONFIG_KODE_LOWPOWER`. Armed, a low INT wakes the chip, and the interrupt withdraws the wake before it calls the owner's callback: a line that stays low would otherwise wake the chip at once, every time. The owner reads the input register and calls `kdrv_tca6408a_int_rearm()`, which puts the wake back. A read that fails leaves the wake withdrawn until the owner arms it again, on purpose, because the alternative is a chip that wakes in a loop on a line it cannot release. Disarmed, the line is the plain interrupt again. The call is idempotent, and it answers `KODE_DENIED` when no interrupt was attached.

The wake lives in this driver and not in `khal_power_wake_arm()` because the driver owns the interrupt of the pin, and a pin has one interrupt owner. In OFF the same line is the one wake source of deep sleep (`sdk/kaos/lowpower`).

## Specifications

### The registers

Every transaction names one register with the command byte and moves one byte. The chip does not step to the next register on its own, so the driver reads the four registers in four transactions.

| Reg | Name | Reset | Meaning |
| --- | --- | --- | --- |
| `0x00` | Input port | the pins | What the eight pins read, whatever their direction, after any inversion. Read-only |
| `0x01` | Output port | `0xFF` | What each output pin drives. Has no effect on an input pin until it becomes an output |
| `0x02` | Polarity inversion | `0x00` | A 1 inverts that bit of the input port |
| `0x03` | Configuration | `0xFF` | A 1 makes the pin a high-impedance input, a 0 an output |

### The fields

| Field | Reg | Bits | Unit |
| --- | --- | --- | --- |
| `input` | `0x00` | 7:0 | bits, read-only |
| `output` | `0x01` | 7:0 | bits |
| `polarity` | `0x02` | 7:0 | bits |
| `config` | `0x03` | 7:0 | bits, 1 is an input |
| `i0` to `i7` | `0x00` | n | bool, read-only: what Pn reads |
| `o0` to `o7` | `0x01` | n | bool: what Pn drives |
| `n0` to `n7` | `0x02` | n | bool: invert Pn's reading |
| `c0` to `c7` | `0x03` | n | bool: Pn is an input (1) or an output (0) |

### The driver

| Call | What it does |
| --- | --- |
| `kdrv_tca6408a_init()` | Probes `0x20`, attaches the device, reads the four registers. Writes nothing. The chip has no identity register, so presence is an acknowledge plus four registers that answer |
| `kdrv_tca6408a_present()` | True once `init` succeeded |
| `kdrv_tca6408a_get(f, &v)` | One field. `v` is untouched on a failed read, so a dead bus never reads as `0xFF` |
| `kdrv_tca6408a_set(f, v)` | One field, masked, read back. `KODE_DENIED` for a read-only field or a value out of range, `KODE_ERROR` when the bus fails or the chip did not keep it |
| `kdrv_tca6408a_field_info(f)`, `_field_by_name()` | Name, unit and range of a field; what the console's `ioexp` prints |
| `kdrv_tca6408a_dump()` | Logs the four registers in hex and every pin bit |
| `kdrv_tca6408a_int_attach(cb, arg)` | Makes GPIO6 an input and arms a low-level interrupt. `cb` runs in interrupt context and may post one static event and nothing else |
| `kdrv_tca6408a_int_rearm()` | Enables the interrupt again after the input register was read, and puts the sleep wake back when it is armed |
| `kdrv_tca6408a_sleep_wake(armed)` | Arms or withdraws the light-sleep wake on `INT`. Idempotent. `KODE_DENIED` when no interrupt was attached, `KODE_ERROR` when the wake could not be set |
| `kdrv_tca6408a_int_idle()` | True when `INT` is high, meaning nothing has changed since the last read |

### The owner's requests

| Call or signal | What it does |
| --- | --- |
| `ioexp_read_request()` | Publishes `IOEXP_INPUT_SIG` now, changed or not |
| `ioexp_field_request(who, op, field, value)` | `op` is `FIELD_OP_GET`, `FIELD_OP_SET` or `FIELD_OP_DUMP`; `field` is a `kdrv_tca6408a_field_t`. A `SET` is followed by a `GET`, so the answer carries what the chip kept. `who` NULL is served and not answered |
| `IOEXP_INPUT_SIG` | `IoexpInputEvt`: `levels` (the input register), `changed` (bits that differ from the previous read), `ok` (false when the chip did not answer) |
| `IOEXP_FIELD_RSP_SIG` | `IoexpFieldEvt`, posted to the requester |
| Console | `ioexp field= value=` reads or writes a field, `ioexp` alone dumps, `ioexp_help` lists the fields |

## Gotchas

> **WARNING**
>
> **A reading of `0xFF` can be a dead bus.** At rest every input reads `0xFF`, which is also what a bus that answers nothing could decode into. The driver returns an error for a failed read and never a value, and AO_IoExp publishes `ok` false. Check `ok` before you decode `levels`.

> **WARNING**
>
> **Do not read the chip from your own code.** The input register has one `INT` behind it, and whoever reads it clears the interrupt. A second reader makes AO_IoExp miss changes. Subscribe to `IOEXP_INPUT_SIG` or call `ioexp_read_request()`.

> **NOTE**
>
> **P7 keeps its level across a reset.** The chip is never reset by the P4, so the panel stays powered while the P4 restarts. AO_IoExp leaves P7 alone at boot on purpose. Only a power cycle puts the chip back to `config = 0xFF`.

## Example code

Ask AO_IoExp to dump the expander. The `kdrv.tca6408a` lines show the four registers and the eight pins. With the panel on, `config` reads `0x7F`: P0 to P6 inputs, P7 the panel's supply.

```c
/*
 * expander_dump.c - 2026-10-28 - from Kode with love
 *
 * Asks AO_IoExp, the owner of the Kode Dot's TCA6408A expander, to log its
 * four registers and every pin. With the screen on, the serial log shows
 * config=0x7F (P0 to P6 are inputs, P7 drives the panel's supply) and the
 * button pins i0 to i4 at 1 while no button is pressed.
 */
#include "kode_ao.h"

/* Bring the system up, let the screen start, ask for the dump, then idle. */
void app_main(void)
{
    kode_init();
    kode_delay_ms(2000);

    /* A NULL requester is served and not answered; the dump goes to the log. */
    ioexp_field_request(NULL, FIELD_OP_DUMP, 0U, 0);

    while (1) {
        kode_delay_ms(1000);
    }
}
```

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/tca6408a`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/tca6408a): the driver and its header.
- [`kdrv_tca6408a.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kdrv/tca6408a/include/kdrv_tca6408a.h): the field enum and the interrupt calls.
- [`components/kaos/ioexp`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/ioexp): AO_IoExp, the owner.
