Skip to main content

I/O Expanders

Adding inputs and outputs to an ESP32 with MCP23017 and PCF8574 I²C expanders and the CD4067 multiplexer, and wiring the I²C bus they share.

Which Expander?

A full panel quickly needs more inputs and outputs than an ESP32 has free pins. An expander hosts buttons, switches, LEDs or analog inputs on its own pins, and talks to the ESP32 over a few shared lines. Each part wired to it is addressed by the expander plus a channel — which of the expander's pins it sits on.

ChipPinsConnectionBest for
MCP2301716 digital in/outI²C, 0x20–0x27Mixed button and LED panels
PCF8574 / PCF8574A8 quasi-bidirectionalI²C, 0x20–0x27 / 0x38–0x3FCheap button banks, sinking LEDs
CD406716 analog or digital1 signal pin + 4 select linesMany potentiometers on one ADC pin
74HC165 / 74HC5958 in / 8 out per chip, chainableData, clock, latch Long input or LED chains — see Shift Registers
The I²C Bus
ESP32 GPIO 21 GPIO 22 SDA SCL 3.3 V 4.7 kΩ ×2 MCP23017 0x20 PCF8574 0x21 LCD / OLED 0x27 / 0x3C

I²C uses two wires — SDA (data) and SCL (clock) — shared by every device on it. A controller has one I²C bus : all MCP23017 and PCF8574 expanders and every LCD or OLED hang off the same two pins, and each device is told apart by its address . On a classic ESP32 the usual pins are GPIO 21 (SDA) and GPIO 22 (SCL), but any two output-capable pins will do.

DeviceAddressesSet by
MCP230170x20–0x27A0–A2 pins
PCF85740x20–0x27A0–A2 pins or jumpers
PCF8574A0x38–0x3FA0–A2 pins or jumpers
LCD backpack (PCF8574)usually 0x27 or 0x3FSolder jumpers on the backpack
SSD1306 / SH1106 OLED0x3C or 0x3DResistor jumper on the back
Bus speedUse when
100 kHz (standard)Long wires, many devices, or an unreliable bus
400 kHz (fast)Short wires on one panel; faster input scanning and screen updates

Character LCDs and OLEDs share this bus — see Text Displays . Run this scanner to see which addresses answer:

// I²C scanner — lists every device address on the bus
#include <Wire.h>

void setup() {
    Serial.begin(115200);
    Wire.begin(21, 22);               // SDA, SCL
    for (uint8_t addr = 0x08; addr < 0x78; addr++) {
        Wire.beginTransmission(addr);
        if (Wire.endTransmission() == 0) {
            Serial.printf("Found device at 0x%02X\n", addr);
        }
    }
}

void loop() {}
MCP23017 — 16 Pins over I²C
MCP23017 SDA SDA (13) SCL SCL (12) 3.3 V VDD (9) · RESET (18) GND VSS (10) GND/3.3 V A0–A2 (15–17) GPA0–7 port A → channels 0–7 GPB0–7 port B → channels 8–15 button: channel → GND LED: channel → 330 Ω → LED → GND

The MCP23017 gives you 16 digital pins in two ports of eight. Each pin can be an input with an internal pull-up (buttons, switches, encoders) or an output (LEDs, up to ~25 mA per pin). Tie A0–A2 to GND or 3.3 V to pick one of eight addresses, 0x20–0x27 — so up to eight chips, 128 pins, on one bus. Tie RESET high, or the chip stays in reset.

Chip pinsChannelShown as
GPA0–GPA7 (port A)0–7MCP23017 0x20 · A0 … A7
GPB0–GPB7 (port B)8–15MCP23017 0x20 · B0 … B7
// MCP23017 at 0x20: button on GPA0, LED on GPB0 (Adafruit_MCP23X17 library)
#include <Adafruit_MCP23X17.h>

Adafruit_MCP23X17 mcp;

void setup() {
    Serial.begin(115200);
    Wire.begin(21, 22);
    mcp.begin_I2C(0x20);
    mcp.pinMode(0, INPUT_PULLUP);     // GPA0 = channel 0
    mcp.pinMode(8, OUTPUT);           // GPB0 = channel 8
}

void loop() {
    bool pressed = mcp.digitalRead(0) == LOW;
    mcp.digitalWrite(8, pressed ? HIGH : LOW);
    delay(10);
}

On a CockpitConnect controller you don't write this: add the MCP23017 with its address in the Flow Builder, then put a Button, Switch or LED on one of its channels. The Flow Builder shows it as, for example, "MCP23017 0x20 · B3", and the controller scans the chip and debounces each input for you.

PCF8574 / PCF8574A — 8 Pins over I²C

The PCF8574 is the cheapest way to add 8 pins . Its pins are quasi-bidirectional : there is no direction register. A pin written HIGH is held up by a weak internal pull-up (about 100 µA) and can be read as an input; a pin written LOW sinks current hard. The PCF8574 answers on 0x20–0x27 and the PCF8574A on 0x38–0x3F, so you can fit up to sixteen on one bus.

UseWiringNotes
Button / switchP0–P7 → contact → GNDPin reads LOW while closed; no resistor needed
LED3.3 V → 330 Ω → LED → P0–P7Wire the LED to sink: it lights when the pin goes LOW
Bright or many LEDsP0–P7 → transistor or ULN2003The weak pull-up can't source enough current to light an LED
// PCF8574 at 0x21: read 8 buttons in one transfer
#include <Wire.h>

void setup() {
    Serial.begin(115200);
    Wire.begin(21, 22);
    Wire.beginTransmission(0x21);
    Wire.write(0xFF);                 // all pins HIGH = inputs with weak pull-up
    Wire.endTransmission();
}

void loop() {
    Wire.requestFrom(0x21, 1);
    uint8_t pins = Wire.read();       // bit = 0 means that button is pressed
    Serial.println(pins, BIN);
    delay(50);
}
CD4067 — 16-Channel Multiplexer
CD4067 GPIO 34 SIG (1) GPIO 25–27, 14 S0–S3 GND / GPIO EN (15), active LOW 3.3 V VDD (24) GND VSS (12) C0–C15 16 pot wipers or button contacts

The CD4067 is an analog switch: the four select lines S0–S3 connect one of its 16 channels C0–C15 to the single SIG pin. Because it passes analog voltages, it is the way to read many potentiometers or slide pots through one ADC1 pin — ADC2 can't be read while WiFi is on. It works for buttons and switches too. The signal pin is what identifies the mux; the select lines can be shared with a second CD4067 on a different signal pin.

CD4067 pinConnect to
SIG (1)An ADC1 pin (GPIO 32–39) for analog, any input pin for digital
S0–S3 (10, 11, 14, 13)Four output-capable GPIOs
EN (15)GND (always on), or a GPIO to switch the mux off — optional
VDD (24) / VSS (12)3.3 V / GND — not 5 V, or the signal can exceed the ESP32's pin voltage
C0–C15Pot wipers (pot ends to 3.3 V and GND), or contacts to GND with pull-ups
// CD4067: read 16 potentiometers through one ADC1 pin
const int SIG_PIN    = 34;            // ADC1 — ADC2 is unusable while WiFi is on
const int SEL_PINS[] = { 25, 26, 27, 14 };

void selectChannel(uint8_t channel) {
    for (int i = 0; i < 4; i++) {
        digitalWrite(SEL_PINS[i], (channel >> i) & 1);
    }
}

void setup() {
    Serial.begin(115200);
    for (int i = 0; i < 4; i++) {
        pinMode(SEL_PINS[i], OUTPUT);
    }
}

void loop() {
    for (uint8_t ch = 0; ch < 16; ch++) {
        selectChannel(ch);
        delayMicroseconds(10);        // let the mux settle
        Serial.printf("C%u = %d\n", ch, analogRead(SIG_PIN));
    }
    delay(100);
}

On a CockpitConnect controller you don't write this: add the CD4067 in the Flow Builder and put Analog Inputs or Buttons on its channels. The controller steps through the channels, and calibrates and filters each analog channel like a pin of its own.

Cockpit Applications
ApplicationExpanderNotes
Overhead panel (30+ switches)2–3 × MCP23017One bus, addresses 0x20, 0x21, 0x22
MCP / FCU buttons with LEDsMCP23017Buttons on port A, their lights on port B
Simple button bankPCF8574Eight momentary buttons for the price of a coffee
Throttle quadrant (throttles, prop, mixture, trim)CD4067All levers through one ADC1 pin
Lighting knobs on a panelCD4067Panel, flood and instrument dimmers as Analog Inputs