Skip to main content

Command Palette

Search for a command to run...

Embedded Rust BSPs with uFerris & Xiao: I/O Expander Support with I2C

Updated
•24 min read•View as Markdown
O
I am an 📟 Embedded Engineer with years of experience in both industry 🏭 and academia 🏫. Passionate Mentor 👨‍💼 and Instructor 👨‍🏫. Rustacean 🦀

This is the ninth post in the µFerris & Xiao BSP series, where we build a Board Support Package from scratch, one peripheral at a time. In the previous post, we added the real-time clock and shared the I2C bus. This post adds the I/O expander, the second device on the bus, and with it two more LEDs, four push buttons, and two slide switches.

Series Past Posts

  1. Device Setup & Getting Started

  2. Getting started with µferris-bsp

  3. Intro to BSPs

  4. LED & Button Support with GPIO

  5. Buzzer Support with PWM

  6. LDR Support with ADC

  7. I2C Support

  8. RTC Support with I2C

Introduction

The bus scan in the seventh post found two devices on the µFerris. The last post took the first, the clock. This post takes the second, a TCA6424 I/O expander at address 0x22. An I/O expander is a set of GPIO pins that live on the far side of an I2C bus. This one has 24 of them in three ports of eight, and the µFerris uses them for everything that would otherwise not fit on the XIAO: LEDs 2 and 3, push buttons SW1 to SW4, the two slide switches, and the seven-segment display.

This post covers the LEDs and the switches. The display sits on the same chip and gets its own post next.

The new problem is state. The clock was a set of registers we read and write as a block. The expander's outputs are shared between components: the LEDs and the display digit selects share one port. Setting one pin must not disturb the others, and reading the port back before every write doubles the traffic. Instead, we'll keep a copy of the output registers in the BSP. Let's get started.

📚 Knowledge Prerequisites

To understand the content of this post, you need the following:

  • Basic knowledge of coding in Rust.

  • Basic understanding of embedded systems development concepts.

  • Familiarity with no_std development in Rust, preferably using esp-hal.

  • Familiarity with I2C register access and bus sharing, as covered in the previous two posts.

  • Familiarity with bit masks.

  • Familiarity with the µFerris platform and flashing a XIAO module.

💾 Software Setup

All the code presented in this post is available in the µFerris & XIAO series repo. Every post in the series has its own self-contained Cargo project in a numbered folder. The code for this post lives in 09-io-expander-i2c/:

git clone https://github.com/theembeddedrustacean/learn-bsp-rs
cd learn-bsp-rs/09-io-expander-i2c
cargo run --release

The examples revolve around the ESP32-C3. If you want to follow along with a different device, please refer to the xiao-generate post for more detail.

🛠 Hardware Setup

The required hardware includes:

  • Seeed Studio XIAO ESP32-C3 — the controller used throughout this phase of the series. Available from the SeeedStudio Store.
  • USB-C cable for power, flashing, and serial output.

🔌 Connections

No connections are required. Every component the BSP will eventually drive is prewired; you only need to assemble the board. If you haven't assembled the board yet, this post walks through it.

The component added in this post is connected on the µFerris as follows:

Device XIAO Pin Notes
TCA6424 I/O expander GPIO6 (SDA), GPIO7 (SCL) I2C address 0x22. Shares the bus with the RTC and the Qwiic connector.

The expander's own pins are wired to the components below. The display pins are listed for completeness and are not used in this post.

Port Pin Component Direction
0 P00, P01 SW7 position 1, position 2 Input
0 P04 to P07 SW4, SW3, SW2, SW1 Input
1 P10 to P13 Display digit 1 to 4 select Output
1 P14, P15 LED2, LED3 Output
1 P16, P17 SW6 position 2, position 1 Input
2 P20 to P27 Display segments Output

The push buttons are pulled up and read low when pressed. Each slide switch has two contacts, one per position, and the closed one reads low. With the knob mid-travel neither contact is closed. The LEDs light when their pin is high.

👨‍🎨 Software Design

This post builds on the BSP from the previous post, where we shared the I2C bus. This post assumes you understand that code. Recall the BSP code is composed of three parts; The Board struct, The Board Initialization Function, and The Board Control Functions. Each post adds to all three.

Before updating the BSP, let's look at what it takes to operate the I/O expander. The µFerris carries a TCA6424, a 24-bit I/O expander. Its 24 pins are grouped into three 8-bit ports (Port 0, Port 1, and Port 2). On the µFerris, the expander pins are connected as follows:

Port Bits Connected to Direction
0 0 and 1 SW7 contacts Input
0 4 to 7 SW4, SW3, SW2, SW1 Input
1 0 to 3 Seven-segment digit selects Output
1 4 and 5 LED2, LED3 Output
1 6 and 7 SW6 contacts Input
2 0 to 7 Seven-segment segments Output

A set of registers controls each port. To access a register, the TCA6424 expects a command byte. The lower bits of the command byte select the register, and bit 7 enables auto-increment. With auto-increment enabled, the device moves to the next register after every byte, so one transaction can cover all three ports. The TCA6424 datasheet lists several registers, of which we need the following:

Register Command Command with auto-increment Function
Input Port 0 to 2 0x00 to 0x02 0x80 to 0x82 Reads the level on each pin of the port
Output Port 0 to 2 0x04 to 0x06 0x84 to 0x86 Sets the level driven on each output pin of the port
Configuration Port 0 to 2 0x0C to 0x0E 0x8C to 0x8E Sets each pin of the port as input (1) or output (0)

The TCA6424 communicates over I2C, and we will read from and write to it. In order to achieve what we want, here are the main points we need to know:

  • The device address is 0x22.

  • Every transaction starts with a command byte from the table above. We always use the auto-increment version.

  • On reset, all pins are inputs. To set the direction of all 24 pins, we write the command 0x8C followed by three bytes, one per port.

  • To drive the outputs, we write the command 0x84 followed by three bytes, one per port. A write sets all eight pins of a port at once. There is no way to change a single pin on its own.

  • To read the inputs of a port, we write the command of its input register (0x80 to 0x82), then read one byte back.

  • The push buttons are active low. A pressed button reads 0.

  • Each slide switch has two contacts, one per position. The contact of the current position reads 0. If neither reads 0, the switch is between positions.

The Board Struct

The expander is a component, so it gets a struct holding a bus handle, like the clock:

// The I/O expander and a copy of its output registers.
pub struct IoExpander {
    i2c: I2cDevice,
    outputs: [u8; 3],
}

The outputs member is a copy of the three output registers, one u8 per port. outputs[0] holds Output Port 0, outputs[1] Output Port 1, and outputs[2] Output Port 2. We need the copy because, as mentioned earlier, a write sets all eight pins of a port at once. To turn on LED2 without changing LED3 or the digit selects on the same port, we need the current value of the other bits. We could read the output register back before every write, but that costs an extra transaction. Instead, we change the bit in the copy and write all three bytes back with one write to 0x84. Port 0 has no outputs, but keeping its byte lets that write start at Output Port 0 and cover all three ports. The copy stays correct because the handle is private to the struct, so nothing else writes the chip.

The board struct gains the expander as a member. This is the form of the updated struct:

pub struct UFerris {
    led1: Output<'static>,
    sw_btn5: Input<'static>,
    buzzer: Channel<'static, LowSpeed>,
    ldr_driver: LdrAdc,
    rtc: Rtc,
    expander: IoExpander,
    i2c: I2cDevice,
}

Notice how the board struct still holds one member per component, and the clock and expander each own a handle onto the same bus.

The Board Initialization Function

We will update uferris_init to build and configure the expander. The steps are as follows:

  1. Share the bus as before.

  2. Create one more RefCellDevice handle.

  3. Build the expander.

  4. Init and configure the expander.

The Board Control Functions

We'll introduce ten functions that the BSP will support:

Function Behaviour
led2_on(), led2_off() Turn LED2 on or off.
led3_on(), led3_off() Turn LED3 on or off.
sw1_pressed() to sw4_pressed() Return true while the push button is held.
sw6_position(), sw7_position() Return the slide switch position as Up, Down, or Undefined.

The slide switch functions return a SwPos rather than a bool, because a slide switch has three possible readings:

// Position of a slide switch.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SwPos {
    Up,
    Down,
    // Neither contact closed
    Undefined,
}

Up and Down mean the contact for that position reads 0. Undefined means neither does, which happens while the switch is between positions.

Note that every function in the table returns a Result, because each one is an I2C transaction. sw5_pressed from post 4 does not, because that button is on a chip pin. Note also that the reads are not debounced. Optionally, you can add a function that reads all four push buttons in one transaction, since they share a port. That is left for the reader as an exercise.

Test Application Design

The test application steps are as follows:

  1. Read SW1 and drive LED2 to match. Read SW2 and drive LED3 to match.

  2. Read SW3 and drive LED1 to match. LED1 is on a chip pin from post 4, so this exercises both paths side by side.

  3. Read SW4 and drive the buzzer to match.

  4. Read both slide switches and print their positions if either has changed.

  5. Wait 20 ms (paces the polling).

  6. Go back to step 1.

👨‍💻 Code Implementation

1️⃣ Define the expander registers. The address, the three command bytes we use, the direction of every port, and a mask per pin. Below is the code:

// I2C address of the I/O expander.
const EXPANDER_ADDR: u8 = 0x22;

// TCA6424 command bytes, auto-increment bit set.
const IN_PORT0: u8 = 0x80;
const OUT_PORT0: u8 = 0x84;
const CONFIG_PORT0: u8 = 0x8C;

// Port directions, 1 = input, 0 = output.
const PORT0_DIR: u8 = 0xFF; // SW1 to SW4 and the two SW7 contacts
const PORT1_DIR: u8 = 0xC0; // The two SW6 contacts; the rest drive LEDs
const PORT2_DIR: u8 = 0x00; // All outputs

// Port 0 inputs, all active low.
const SW7_POS1: u8 = 1 << 0;
const SW7_POS2: u8 = 1 << 1;
const SW4: u8 = 1 << 4;
const SW3: u8 = 1 << 5;
const SW2: u8 = 1 << 6;
const SW1: u8 = 1 << 7;

// Port 1 pins.
const LED2: u8 = 1 << 4;
const LED3: u8 = 1 << 5;
const SW6_POS2: u8 = 1 << 6;
const SW6_POS1: u8 = 1 << 7;

Note that Port 2 is entirely display segments, so it has no masks yet.

2️⃣ Define the expander component. The switch position type and the struct that holds the handle and the output copy. Below is the code:

// Position of a slide switch.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SwPos {
    Up,
    Down,
    // Neither contact closed
    Undefined,
}

// The I/O expander and a copy of its output registers.
pub struct IoExpander {
    i2c: I2cDevice,
    outputs: [u8; 3],
}

3️⃣ Implement the expander. Six private functions. init writes the directions and the outputs. write_outputs pushes the copy to the chip. set_output changes bits in the copy and pushes it. read_input reads one port, and the last two decode a reading. Below is the code:

// Set pin directions and drive all outputs low.
fn init(&mut self) -> Result<(), I2cError> {
    self.i2c.write(
        EXPANDER_ADDR,
        &[CONFIG_PORT0, PORT0_DIR, PORT1_DIR, PORT2_DIR],
    )?;
    self.write_outputs()
}

// Write the output copy to all three ports.
fn write_outputs(&mut self) -> Result<(), I2cError> {
    let [port0, port1, port2] = self.outputs;
    self.i2c
        .write(EXPANDER_ADDR, &[OUT_PORT0, port0, port1, port2])
}

// Set or clear output pins on one port.
fn set_output(&mut self, port: usize, mask: u8, high: bool) -> Result<(), I2cError> {
    if high {
        self.outputs[port] |= mask;
    } else {
        self.outputs[port] &= !mask;
    }
    self.write_outputs()
}

// Read one input port.
fn read_input(&mut self, port: u8) -> Result<u8, I2cError> {
    let mut buf = [0u8];
    self.i2c
        .write_read(EXPANDER_ADDR, &[IN_PORT0 + port], &mut buf)?;
    Ok(buf[0])
}

// Check if an active-low port 0 input is low.
fn port0_low(&mut self, mask: u8) -> Result<bool, I2cError> {
    Ok(self.read_input(0)? & mask == 0)
}

// Decode a slide switch from its two contacts.
fn slide_position(reading: u8, pos1: u8, pos2: u8) -> SwPos {
    if reading & pos1 == 0 {
        SwPos::Down
    } else if reading & pos2 == 0 {
        SwPos::Up
    } else {
        SwPos::Undefined
    }
}

Note that the switch functions read the chip every time. No input copy can go stale.

4️⃣ Populate the board struct. Add the expander as a member. Below is the code:

pub struct UFerris {
    led1: Output<'static>,
    sw_btn5: Input<'static>,
    buzzer: Channel<'static, LowSpeed>,
    ldr_driver: LdrAdc,
    rtc: Rtc,
    expander: IoExpander,
    i2c: I2cDevice,
}

5️⃣ Populate the initialization function. The bus is shared as before; the expander takes its handle, and init runs before the board is returned. Below is the code:

// One handle per device on the bus
let rtc = Rtc {
    i2c: RefCellDevice::new(i2c_bus),
};
let mut expander = IoExpander {
    i2c: RefCellDevice::new(i2c_bus),
    outputs: [0; 3],
};
let i2c = RefCellDevice::new(i2c_bus);

// Configure the expander pins
expander.init().expect("I/O expander not responding");

UFerris {
    led1,
    sw_btn5,
    buzzer,
    ldr_driver,
    rtc,
    expander,
    i2c,
}

The expect here is deliberate. If the expander does not answer, half the board's components are unreachable, and it is better to know at boot than on the first button press.

6️⃣ Add the control functions. In the impl block on UFerris, we add the ten board methods. Each is one line into the component. Below is the code:

// Turn LED 2 on.
pub fn led2_on(&mut self) -> Result<(), I2cError> {
    self.expander.set_output(1, LED2, true)
}

// Turn LED 2 off.
pub fn led2_off(&mut self) -> Result<(), I2cError> {
    self.expander.set_output(1, LED2, false)
}

// Turn LED 3 on.
pub fn led3_on(&mut self) -> Result<(), I2cError> {
    self.expander.set_output(1, LED3, true)
}

// Turn LED 3 off.
pub fn led3_off(&mut self) -> Result<(), I2cError> {
    self.expander.set_output(1, LED3, false)
}

// Read switch 1.
pub fn sw1_pressed(&mut self) -> Result<bool, I2cError> {
    self.expander.port0_low(SW1)
}

// Read switch 2.
pub fn sw2_pressed(&mut self) -> Result<bool, I2cError> {
    self.expander.port0_low(SW2)
}

// Read switch 3.
pub fn sw3_pressed(&mut self) -> Result<bool, I2cError> {
    self.expander.port0_low(SW3)
}

// Read switch 4.
pub fn sw4_pressed(&mut self) -> Result<bool, I2cError> {
    self.expander.port0_low(SW4)
}

// Read the position of slide switch 6.
pub fn sw6_position(&mut self) -> Result<SwPos, I2cError> {
    let port1 = self.expander.read_input(1)?;
    Ok(IoExpander::slide_position(port1, SW6_POS1, SW6_POS2))
}

// Read the position of slide switch 7.
pub fn sw7_position(&mut self) -> Result<SwPos, I2cError> {
    let port0 = self.expander.read_input(0)?;
    Ok(IoExpander::slide_position(port0, SW7_POS1, SW7_POS2))
}

🏃‍♂️ Test Code & Run

#![no_std]
#![no_main]

use esp_backtrace as _;
use esp_hal::{clock::CpuClock, main};
use esp_println::println;
use uferris::{SwPos, UFerris, uferris_init};

esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // Configure the device and hand the peripherals to the BSP
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);
    let mut uferris: UFerris = uferris_init(peripherals);

    // Instantiate a delay provider
    let delay = esp_hal::delay::Delay::new();

    // Last printed slide switch positions
    let mut last_sw6 = SwPos::Undefined;
    let mut last_sw7 = SwPos::Undefined;

    loop {
        // LED2 and LED3 follow SW1 and SW2
        if uferris.sw1_pressed().unwrap_or(false) {
            uferris.led2_on().ok();
        } else {
            uferris.led2_off().ok();
        }
        if uferris.sw2_pressed().unwrap_or(false) {
            uferris.led3_on().ok();
        } else {
            uferris.led3_off().ok();
        }

        // LED 1 on the chip follows SW3 on the expander
        if uferris.sw3_pressed().unwrap_or(false) {
            uferris.led1_on();
        } else {
            uferris.led1_off();
        }

        // The buzzer follows SW4 on the expander
        if uferris.sw4_pressed().unwrap_or(false) {
            uferris.buzz_on(50);
        } else {
            uferris.buzz_off();
        }

        // Print the slide switches whenever either one moves
        let sw6 = uferris.sw6_position().unwrap_or(SwPos::Undefined);
        let sw7 = uferris.sw7_position().unwrap_or(SwPos::Undefined);
        if sw6 != last_sw6 || sw7 != last_sw7 {
            println!("SW6 {:?}, SW7 {:?}", sw6, sw7);
            last_sw6 = sw6;
            last_sw7 = sw7;
        }

        // Poll every 20 ms
        delay.delay_millis(20);
    }
}

Flash it and open the monitor:

cargo run --release

Hold SW1 and LED2 lights. Hold SW2 and LED3 lights. SW3 lights LED1, and SW4 sounds the buzzer. Move either slide switch and the new positions print:

SW6 Up, SW7 Down
SW6 Up, SW7 Up
SW6 Down, SW7 Up

If the application panics at boot with "I/O expander not responding", the chip is not answering on the bus. Run the scan from post 07 and check that 0x22 still shows up.

✅ Conclusion

We added the I/O expander, and the BSP now reaches the two LEDs, four push buttons, and two slide switches that hang off it. The component holds a copy of its output registers, so every change is one write, and the bus sharing from the last post gave it a handle with one more line.

The next post adds the seven-segment display, which sits on the same chip and needs the output copy to refresh a digit in one transaction.

📱 Full Code

src/lib.rs

#![no_std]

use core::cell::RefCell;
use embedded_hal::i2c::I2c as _;
use embedded_hal::pwm::SetDutyCycle;
use embedded_hal_bus::i2c::RefCellDevice;
use esp_hal::{
    Blocking,
    analog::adc::{Adc, AdcConfig, AdcPin, Attenuation},
    gpio::{DriveMode, Input, InputConfig, Level, Output, OutputConfig},
    i2c::master::{Config as I2cConfig, Error as I2cError, I2c},
    ledc::{
        LSGlobalClkSource, Ledc, LowSpeed,
        channel::{self, Channel, ChannelIFace},
        timer::{self, Timer, TimerIFace},
    },
    peripherals::{ADC1, GPIO2, Peripherals},
    time::Rate,
};
use static_cell::StaticCell;

// The LEDC timer the buzzer channel runs on.
static BUZZER_TIMER: StaticCell<Timer<'static, LowSpeed>> = StaticCell::new();

// Resonant frequency of the µFerris buzzer, in hertz.
const BUZZER_FREQ_HZ: u32 = 2700;

// The shared I2C driver.
static I2C_BUS: StaticCell<RefCell<I2c<'static, Blocking>>> = StaticCell::new();

// One handle onto the shared I2C bus.
type I2cDevice = RefCellDevice<'static, I2c<'static, Blocking>>;

// I2C bus clock in kHz (standard mode).
const I2C_FREQ_KHZ: u32 = 100;

// I2C address of the real-time clock.
const RTC_ADDR: u8 = 0x68;

// Date and time, 24-hour clock.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DateTime {
    pub year: u16,
    pub month: u8,
    pub day: u8,
    pub hour: u8,
    pub minute: u8,
    pub second: u8,
}

// The real-time clock.
pub struct Rtc {
    i2c: I2cDevice,
}

impl Rtc {
    // Write the seven time registers, starting at register 0.
    pub fn set_time(&mut self, time: DateTime) -> Result<(), I2cError> {
        let bytes = [
            0x00,
            to_bcd(time.second),
            to_bcd(time.minute),
            to_bcd(time.hour),
            1,
            to_bcd(time.day),
            to_bcd(time.month),
            to_bcd((time.year % 100) as u8),
        ];
        self.i2c.write(RTC_ADDR, &bytes)
    }

    // Read the seven time registers, starting at register 0.
    pub fn read_time(&mut self) -> Result<DateTime, I2cError> {
        let mut buf = [0u8; 7];
        self.i2c.write_read(RTC_ADDR, &[0x00], &mut buf)?;
        Ok(DateTime {
            second: from_bcd(buf[0] & 0x7F),
            minute: from_bcd(buf[1] & 0x7F),
            hour: from_bcd(buf[2] & 0x3F),
            day: from_bcd(buf[4] & 0x3F),
            month: from_bcd(buf[5] & 0x1F),
            year: 2000 + u16::from(from_bcd(buf[6])),
        })
    }
}

// Pack a two-digit value into binary-coded decimal.
fn to_bcd(value: u8) -> u8 {
    ((value / 10) << 4) | (value % 10)
}

// Unpack a binary-coded decimal byte into a two-digit value.
fn from_bcd(value: u8) -> u8 {
    10 * (value >> 4) + (value & 0x0F)
}

// I2C address of the I/O expander.
const EXPANDER_ADDR: u8 = 0x22;

// TCA6424 command bytes, auto-increment bit set.
const IN_PORT0: u8 = 0x80;
const OUT_PORT0: u8 = 0x84;
const CONFIG_PORT0: u8 = 0x8C;

// Port directions, 1 = input, 0 = output.
const PORT0_DIR: u8 = 0xFF; // SW1 to SW4 and the two SW7 contacts
const PORT1_DIR: u8 = 0xC0; // The two SW6 contacts; the rest drive LEDs
const PORT2_DIR: u8 = 0x00; // All outputs

// Port 0 inputs, all active low.
const SW7_POS1: u8 = 1 << 0;
const SW7_POS2: u8 = 1 << 1;
const SW4: u8 = 1 << 4;
const SW3: u8 = 1 << 5;
const SW2: u8 = 1 << 6;
const SW1: u8 = 1 << 7;

// Port 1 pins.
const LED2: u8 = 1 << 4;
const LED3: u8 = 1 << 5;
const SW6_POS2: u8 = 1 << 6;
const SW6_POS1: u8 = 1 << 7;

// Position of a slide switch.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SwPos {
    Up,
    Down,
    // Neither contact closed
    Undefined,
}

// The I/O expander and a copy of its output registers.
pub struct IoExpander {
    i2c: I2cDevice,
    outputs: [u8; 3],
}

impl IoExpander {
    // Set pin directions and drive all outputs low.
    fn init(&mut self) -> Result<(), I2cError> {
        self.i2c.write(
            EXPANDER_ADDR,
            &[CONFIG_PORT0, PORT0_DIR, PORT1_DIR, PORT2_DIR],
        )?;
        self.write_outputs()
    }

    // Write the output copy to all three ports.
    fn write_outputs(&mut self) -> Result<(), I2cError> {
        let [port0, port1, port2] = self.outputs;
        self.i2c
            .write(EXPANDER_ADDR, &[OUT_PORT0, port0, port1, port2])
    }

    // Set or clear output pins on one port.
    fn set_output(&mut self, port: usize, mask: u8, high: bool) -> Result<(), I2cError> {
        if high {
            self.outputs[port] |= mask;
        } else {
            self.outputs[port] &= !mask;
        }
        self.write_outputs()
    }

    // Read one input port.
    fn read_input(&mut self, port: u8) -> Result<u8, I2cError> {
        let mut buf = [0u8];
        self.i2c
            .write_read(EXPANDER_ADDR, &[IN_PORT0 + port], &mut buf)?;
        Ok(buf[0])
    }

    // Check if an active-low port 0 input is low.
    fn port0_low(&mut self, mask: u8) -> Result<bool, I2cError> {
        Ok(self.read_input(0)? & mask == 0)
    }

    // Decode a slide switch from its two contacts.
    fn slide_position(reading: u8, pos1: u8, pos2: u8) -> SwPos {
        if reading & pos1 == 0 {
            SwPos::Down
        } else if reading & pos2 == 0 {
            SwPos::Up
        } else {
            SwPos::Undefined
        }
    }
}

// The LDR ADC driver and pin.
pub struct LdrAdc {
    adc: Adc<'static, ADC1<'static>, Blocking>,
    pin: AdcPin<GPIO2<'static>, ADC1<'static>>,
}

impl LdrAdc {
    // Run a conversion and return the raw count.
    pub fn read_raw(&mut self) -> u16 {
        nb::block!(self.adc.read_oneshot(&mut self.pin)).unwrap_or(0)
    }
}

// The µFerris board.
pub struct UFerris {
    led1: Output<'static>,
    sw_btn5: Input<'static>,
    buzzer: Channel<'static, LowSpeed>,
    ldr_driver: LdrAdc,
    rtc: Rtc,
    expander: IoExpander,
    i2c: I2cDevice,
}

// Initialize the board.
pub fn uferris_init(peripherals: Peripherals) -> UFerris {

    // Instantiate LED 1
    let led1 = Output::new(peripherals.GPIO3, Level::Low, OutputConfig::default());

    // Instantiate SW5
    let sw_btn5 = Input::new(peripherals.GPIO5, InputConfig::default());

    // Instantiate the LEDC peripheral
    let mut ledc = Ledc::new(peripherals.LEDC);
    ledc.set_global_slow_clock(LSGlobalClkSource::APBClk);

    // Configure the timer attached to the LEDC
    let mut buzzer_timer = ledc.timer::<LowSpeed>(timer::Number::Timer0);
    buzzer_timer
        .configure(timer::config::Config {
            duty: timer::config::Duty::Duty14Bit,
            clock_source: timer::LSClockSource::APBClk,
            frequency: Rate::from_hz(BUZZER_FREQ_HZ),
        })
        .expect("buzzer timer configuration failed");

    // Promote the timer to 'static
    let buzzer_timer = BUZZER_TIMER.init(buzzer_timer);

    // Configure the LEDC Channel and attach pin.
    let mut buzzer = ledc.channel(channel::Number::Channel0, peripherals.GPIO4);
    buzzer
        .configure(channel::config::Config {
            timer: buzzer_timer,
            duty_pct: 0,
            drive_mode: DriveMode::PushPull,
        })
        .expect("buzzer channel configuration failed");

    // Enable the LDR pin, then build the ADC
    let mut adc_config = AdcConfig::new();
    let ldr_pin = adc_config.enable_pin(peripherals.GPIO2, Attenuation::_11dB);
    let adc1 = Adc::new(peripherals.ADC1, adc_config);
    let ldr_driver = LdrAdc {
        adc: adc1,
        pin: ldr_pin,
    };

    // Instantiate the I2C peripheral and attach its pins
    let i2c = I2c::new(
        peripherals.I2C0,
        I2cConfig::default().with_frequency(Rate::from_khz(I2C_FREQ_KHZ)),
    )
    .expect("I2C configuration failed")
    .with_sda(peripherals.GPIO6)
    .with_scl(peripherals.GPIO7);

    // Promote the bus to 'static so it can be shared
    let i2c_bus = I2C_BUS.init(RefCell::new(i2c));

    // One handle per device on the bus
    let rtc = Rtc {
        i2c: RefCellDevice::new(i2c_bus),
    };
    let mut expander = IoExpander {
        i2c: RefCellDevice::new(i2c_bus),
        outputs: [0; 3],
    };
    let i2c = RefCellDevice::new(i2c_bus);

    // Configure the expander pins
    expander.init().expect("I/O expander not responding");

    UFerris {
        led1,
        sw_btn5,
        buzzer,
        ldr_driver,
        rtc,
        expander,
        i2c,
    }
}

impl UFerris {
    // Turn LED 1 on.
    pub fn led1_on(&mut self) {
        self.led1.set_high();
    }

    // Turn LED 1 off.
    pub fn led1_off(&mut self) {
        self.led1.set_low();
    }

    // Read switch 5.
    pub fn sw5_pressed(&mut self) -> bool {
        self.sw_btn5.is_low()
    }

    // Sound the buzzer at a duty from 0 to 100 percent.
    pub fn buzz_on(&mut self, duty_percent: u8) {
        let _ = self.buzzer.set_duty_cycle_percent(duty_percent);
    }

    // Silence the buzzer.
    pub fn buzz_off(&mut self) {
        let _ = self.buzzer.set_duty_cycle_fully_off();
    }

    // Turn LED 2 on.
    pub fn led2_on(&mut self) -> Result<(), I2cError> {
        self.expander.set_output(1, LED2, true)
    }

    // Turn LED 2 off.
    pub fn led2_off(&mut self) -> Result<(), I2cError> {
        self.expander.set_output(1, LED2, false)
    }

    // Turn LED 3 on.
    pub fn led3_on(&mut self) -> Result<(), I2cError> {
        self.expander.set_output(1, LED3, true)
    }

    // Turn LED 3 off.
    pub fn led3_off(&mut self) -> Result<(), I2cError> {
        self.expander.set_output(1, LED3, false)
    }

    // Read switch 1.
    pub fn sw1_pressed(&mut self) -> Result<bool, I2cError> {
        self.expander.port0_low(SW1)
    }

    // Read switch 2.
    pub fn sw2_pressed(&mut self) -> Result<bool, I2cError> {
        self.expander.port0_low(SW2)
    }

    // Read switch 3.
    pub fn sw3_pressed(&mut self) -> Result<bool, I2cError> {
        self.expander.port0_low(SW3)
    }

    // Read switch 4.
    pub fn sw4_pressed(&mut self) -> Result<bool, I2cError> {
        self.expander.port0_low(SW4)
    }

    // Read the position of slide switch 6.
    pub fn sw6_position(&mut self) -> Result<SwPos, I2cError> {
        let port1 = self.expander.read_input(1)?;
        Ok(IoExpander::slide_position(port1, SW6_POS1, SW6_POS2))
    }

    // Read the position of slide switch 7.
    pub fn sw7_position(&mut self) -> Result<SwPos, I2cError> {
        let port0 = self.expander.read_input(0)?;
        Ok(IoExpander::slide_position(port0, SW7_POS1, SW7_POS2))
    }

    // Set the clock.
    pub fn rtc_set_time(&mut self, time: DateTime) -> Result<(), I2cError> {
        self.rtc.set_time(time)
    }

    // Read the clock.
    pub fn rtc_read_time(&mut self) -> Result<DateTime, I2cError> {
        self.rtc.read_time()
    }

    // Read the LDR as a raw 12-bit ADC count.
    pub fn read_ldr(&mut self) -> u16 {
        self.ldr_driver.read_raw()
    }

    // Write bytes to a device. An empty write probes the address.
    pub fn i2c_write(&mut self, address: u8, bytes: &[u8]) -> Result<(), I2cError> {
        self.i2c.write(address, bytes)
    }

    // Read from a device until the buffer is full.
    pub fn i2c_read(&mut self, address: u8, buffer: &mut [u8]) -> Result<(), I2cError> {
        self.i2c.read(address, buffer)
    }

    // Write then read in one transaction.
    pub fn i2c_write_read(
        &mut self,
        address: u8,
        bytes: &[u8],
        buffer: &mut [u8],
    ) -> Result<(), I2cError> {
        self.i2c.write_read(address, bytes, buffer)
    }
}

src/bin/main.rs

#![no_std]
#![no_main]

use esp_backtrace as _;
use esp_hal::{clock::CpuClock, main};
use esp_println::println;
use uferris::{SwPos, UFerris, uferris_init};

esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    // Configure the device and hand the peripherals to the BSP
    let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
    let peripherals = esp_hal::init(config);
    let mut uferris: UFerris = uferris_init(peripherals);

    // Instantiate a delay provider
    let delay = esp_hal::delay::Delay::new();

    // Last printed slide switch positions
    let mut last_sw6 = SwPos::Undefined;
    let mut last_sw7 = SwPos::Undefined;

    loop {
        // LED2 and LED3 follow SW1 and SW2
        if uferris.sw1_pressed().unwrap_or(false) {
            uferris.led2_on().ok();
        } else {
            uferris.led2_off().ok();
        }
        if uferris.sw2_pressed().unwrap_or(false) {
            uferris.led3_on().ok();
        } else {
            uferris.led3_off().ok();
        }

        // LED 1 on the chip follows SW3 on the expander
        if uferris.sw3_pressed().unwrap_or(false) {
            uferris.led1_on();
        } else {
            uferris.led1_off();
        }

        // The buzzer follows SW4 on the expander
        if uferris.sw4_pressed().unwrap_or(false) {
            uferris.buzz_on(50);
        } else {
            uferris.buzz_off();
        }

        // Print the slide switches whenever either one moves
        let sw6 = uferris.sw6_position().unwrap_or(SwPos::Undefined);
        let sw7 = uferris.sw7_position().unwrap_or(SwPos::Undefined);
        if sw6 != last_sw6 || sw7 != last_sw7 {
            println!("SW6 {:?}, SW7 {:?}", sw6, sw7);
            last_sw6 = sw6;
            last_sw7 = sw7;
        }

        // Poll every 20 ms
        delay.delay_millis(20);
    }
}