Skip to content

I2C & IO Expansion

The board uses a single I2C bus for communication between the ESP32-S3 and several onboard peripherals. SDA is on IO8 and SCL is on IO9. Three devices share this bus: the GT911 capacitive touch controller, the CH422G IO expander, and (on the Type B variant) the PCF85063A real-time clock. Each device has a unique address, so they coexist without conflict.

Understanding the I2C topology is important because the CH422G IO expander controls several critical board functions — LCD backlight, LCD reset, touch controller reset, SD card chip select, and USB mode switching all route through it. The IO expander is not just a convenience; it is a gatekeeper for multiple subsystems.

AddressDeviceFunction
0x20-0x27CH422GIO expander (8 register addresses)
0x30-0x3FReserved (do not use)
0x51PCF85063AReal-time clock (Type B only)
0x5DGT911Capacitive touch controller

The CH422G is an I2C-controlled IO expander that provides both open-collector outputs (OC0-OC3) and bidirectional IO pins. On this board, it manages several signals that would otherwise require dedicated GPIO pins from the ESP32-S3 — a practical necessity given how many peripherals are packed onto the board.

PinConnected ToDirectionFunction
EXIO1 (OC0)CTP_RSTOutputTouch controller reset (active low)
EXIO2 (OC1)LCD_BLOutputLCD backlight enable
EXIO3 (OC2)LCD_RSTOutputLCD reset (active low)
EXIO4 (OC3)SDCSOutputSD card chip select (active low)
EXIO5 (IO0)USB_SELOutputUSB switch control (FSUSB42)

Because the CH422G controls the LCD backlight, LCD reset, touch reset, and SD card CS, it must be initialized early in your application startup. A typical boot sequence looks like this:

  1. Initialize I2C bus on IO8 (SDA) and IO9 (SCL)

  2. Initialize the CH422G IO expander at its default address

  3. Release LCD reset (EXIO3 HIGH), then enable backlight (EXIO2 HIGH)

  4. Release touch controller reset (EXIO1 HIGH)

  5. Proceed with display, touch, and SD card initialization

The ESP32_IO_Expander library provides a clean abstraction over the CH422G’s register interface.

#include <ESP_IOExpander_Library.h>
ESP_IOExpander_CH422G *expander = new ESP_IOExpander_CH422G(
(i2c_port_t)0, ESP_IO_EXPANDER_I2C_CH422G_ADDRESS, 8, 9);
expander->init();
expander->begin();
// Release LCD reset
expander->pinMode(3, OUTPUT); // EXIO3 = LCD_RST
expander->digitalWrite(3, HIGH); // Release reset
// Enable backlight
expander->pinMode(2, OUTPUT); // EXIO2 = LCD_BL
expander->digitalWrite(2, HIGH); // Backlight ON
// Release touch controller reset
expander->pinMode(1, OUTPUT); // EXIO1 = CTP_RST
expander->digitalWrite(1, HIGH); // Release reset

The CH422G uses an unusual I2C protocol that trips up developers accustomed to the standard register-address-then-data pattern found on most I2C peripherals. Rather than writing a register address byte followed by a data byte to a single device address, the CH422G encodes the target register into the I2C address byte itself. Each “command” is simply a write (or read) to a different 7-bit address, with the data byte carrying the payload.

Four command addresses define the entire interface:

CommandI2C Address ByteDirectionFunction
Set System Parameter0x48WriteConfigure IO mode, output type, scan, sleep
Set General-purpose Output0x46WriteDrive OC0-OC3 and IO0-IO3 outputs
Read Bidirectional IO0x4DReadRead IO0-IO7 input states
Load Segment / Set Bidirectional IO0x70WriteSet IO0-IO7 output states

The Set System Parameter command (0x48) is the first thing you send after power-on. Its data byte controls four configuration bits:

BitNameDescription
0IO_OEIO output enable. 1 = IO pins drive outputs, 0 = IO pins are high-impedance inputs
1OD_ENOpen-drain enable for OC outputs. 1 = open-drain (requires external pull-up), 0 = push-pull
2A_SCANSegment display scan enable. Not used on this board; keep 0
3SLEEPSleep mode. 1 = low-power sleep, 0 = normal operation

Common parameter values on this board:

  • 0x01 — IO pins configured as outputs, push-pull OC mode, no scan, no sleep. This is the standard configuration when driving the USB mux select, backlight enable, and reset lines.
  • 0x00 — IO pins configured as inputs. Used on the Type B variant when reading the isolated digital input channels through IO4-IO7.

After power-on reset, all OC outputs default to HIGH (inactive for the active-low reset and chip-select lines on this board) and all IO pins default to input mode. The Set System Parameter command must be issued before any IO output operations take effect — skipping it means your digitalWrite calls to IO0-IO7 will silently do nothing.

The four OC pins (OC0-OC3, mapped to EXIO1-EXIO4 on the schematic) can operate in either push-pull or open-drain mode, controlled by the OD_EN bit. On this board they run in push-pull mode (OD_EN=0) because they need to actively drive reset lines and chip selects to defined logic levels. If you were connecting OC outputs to a shared bus or to a load with its own pull-up, open-drain mode would be the right choice — but for point-to-point control signals, push-pull is simpler and more reliable.

When working directly with ESP-IDF rather than the Arduino framework, you interact with the CH422G through the i2c_master_write_to_device API. The key insight is that the command byte (shifted right by one bit to form the 7-bit I2C address) selects which register you are writing to.

#include "driver/i2c.h"
// CH422G command addresses (not standard register offsets)
#define CH422G_SET_PARAM 0x48 // Set System Parameter
#define CH422G_SET_OUTPUT 0x46 // Set General-purpose Output
#define CH422G_READ_IO 0x4D // Read Bidirectional IO
// Initialize CH422G: IO output enabled, push-pull OC, normal mode
void ch422g_init(i2c_port_t port) {
uint8_t param = 0x01; // IO_OE=1, OD_EN=0, A_SCAN=0, SLEEP=0
i2c_master_write_to_device(port, CH422G_SET_PARAM >> 1,
&param, 1, pdMS_TO_TICKS(100));
}
// Set OC and IO outputs (bits 0-3 = OC0-OC3, bits 4-7 = IO0-IO3)
void ch422g_set_outputs(i2c_port_t port, uint8_t value) {
i2c_master_write_to_device(port, CH422G_SET_OUTPUT >> 1,
&value, 1, pdMS_TO_TICKS(100));
}

The right-shift by one (>> 1) converts the 8-bit address byte from the datasheet into the 7-bit address that the ESP-IDF I2C driver expects. The driver appends the R/W bit automatically, so 0x48 >> 1 becomes 0x24 on the wire, which the CH422G recognizes as the Set System Parameter command.

The Type B variant adds optocoupler-isolated digital I/O channels that are also managed through the CH422G. These provide galvanic isolation between the 3.3V logic domain and external circuits operating at higher voltages.

Digital Inputs (DI0, DI1)

Two optocoupler-isolated input channels accepting 5V to 36V signals. The optocouplers translate external voltages down to logic levels readable by the CH422G. Useful for reading signals from PLCs, relay contacts, or industrial sensors without risking the ESP32-S3.

Digital Outputs (DO0, DO1)

Two optocoupler-isolated output channels rated for 5V to 36V at up to 450mA per channel. Suitable for driving relays, indicator lights, solenoid valves, and other actuators. The isolation protects the board from voltage spikes and ground loops in the load circuit.

The PCF85063A provides a battery-backed real-time clock at I2C address 0x51. A CR927 coin cell holder on the board maintains timekeeping when main power is removed. The RTC supports alarm interrupt output and provides year, month, day, weekday, hours, minutes, and seconds registers.

This is a useful addition for data logging applications where timestamps matter. Rather than relying on NTP (which requires a network connection), the RTC provides an independent time reference that persists across power cycles.

#include <Wire.h>
#define PCF85063A_ADDR 0x51
void readTime() {
Wire.beginTransmission(PCF85063A_ADDR);
Wire.write(0x04); // Seconds register
Wire.endTransmission(false);
Wire.requestFrom(PCF85063A_ADDR, 7);
uint8_t seconds = bcdToDec(Wire.read() & 0x7F);
uint8_t minutes = bcdToDec(Wire.read() & 0x7F);
uint8_t hours = bcdToDec(Wire.read() & 0x3F);
uint8_t days = bcdToDec(Wire.read() & 0x3F);
// weekday, month, year follow...
Serial.printf("Time: %02d:%02d:%02d\n", hours, minutes, seconds);
}
uint8_t bcdToDec(uint8_t val) {
return ((val >> 4) * 10) + (val & 0x0F);
}

A bus scan is a good first diagnostic step when bringing up the board or troubleshooting communication issues. The following sketch probes every address on the bus and reports which devices respond.

#include <Wire.h>
void setup() {
Serial.begin(115200);
Wire.begin(8, 9); // SDA=IO8, SCL=IO9
Serial.println("Scanning I2C bus...");
int count = 0;
for (uint8_t addr = 1; addr < 127; addr++) {
Wire.beginTransmission(addr);
if (Wire.endTransmission() == 0) {
Serial.printf("Device found at 0x%02X\n", addr);
count++;
}
}
Serial.printf("Scan complete. %d device(s) found.\n", count);
}
void loop() {}

On a properly functioning Type B board, the scan should report devices at 0x20 (CH422G), 0x51 (PCF85063A RTC), and 0x5D (GT911 touch controller). The Type A variant will show 0x20 and 0x5D but not 0x51.