SDK
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
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 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_BOTandPAD_LEFTheld together for about a second reset the P4: an OR gate (U12, 74LVC1G32) watches both nets through an RC network and pullsCHIP_PUlow.BUTTON_BOTheld 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:
- 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 inKBSP_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 armsINT. - On
INT. The interrupt handler posts one static event and returns. AO_IoExp reads the input register, which releases the line, publishesIOEXP_INPUT_SIGwith 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. - 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 towhoasIOEXP_FIELD_RSP_SIG. AO_Display uses it to set P7: first the output bito7to 1, thenc7to 0. - Self-test.
test component=ioexpchecks that P0 to P6 face in, that the input register reads, and that the read releasedINT.
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
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.
/*
* 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: the driver and its header.kdrv_tca6408a.h: the field enum and the interrupt calls.components/kaos/ioexp: AO_IoExp, the owner.