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

PinNetWhat is on itPull-upDirection
P0BUTTON_BOTSW2, the bottom button. The same net is the QON pin of the BQ25896The 200 kΩ inside the charger on QON; no resistor of its ownInput
P1PAD_TOPSW3, pad upR39, 10 kΩInput
P2PAD_LEFTSW4R40, 10 kΩInput
P3PAD_BOTTOMSW5, pad downR41, 10 kΩInput
P4PAD_RIGHTSW6R42, 10 kΩInput
P5IMU_INT1INT1 of the QMI8658A, driven push-pull by the chipNoneInput
P6DSI_TP_INTThe touch controller's interrupt, from the panel moduleOn the moduleInput
P7DSP_PWR_ENVCI_EN, the AMOLED supply enable, active highR48, 47 kΩ pull-downOutput

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:

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.

RegNameResetMeaning
0x00Input portthe pinsWhat the eight pins read, whatever their direction, after any inversion. Read-only
0x01Output port0xFFWhat each output pin drives. Has no effect on an input pin until it becomes an output
0x02Polarity inversion0x00A 1 inverts that bit of the input port
0x03Configuration0xFFA 1 makes the pin a high-impedance input, a 0 an output

The fields

FieldRegBitsUnit
input0x007:0bits, read-only
output0x017:0bits
polarity0x027:0bits
config0x037:0bits, 1 is an input
i0 to i70x00nbool, read-only: what Pn reads
o0 to o70x01nbool: what Pn drives
n0 to n70x02nbool: invert Pn's reading
c0 to c70x03nbool: Pn is an input (1) or an output (0)

The driver

CallWhat 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 signalWhat 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_SIGIoexpInputEvt: levels (the input register), changed (bits that differ from the previous read), ok (false when the chip did not answer)
IOEXP_FIELD_RSP_SIGIoexpFieldEvt, posted to the requester
Consoleioexp 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