Embedded Rust BSPs with uFerris & Xiao: LDR Support with ADC

This is the sixth 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 buzzer using PWM. This post adds the LDR, which is the first component that hands data back.
Series Past Posts
Introduction
In the last post, we added the buzzer using PWM. Every component we have added so far takes a command. led1_on drives a pin, buzz_on sets a duty, and returns nothing. sw5_pressed does return a value, but it is a boolean.
This post adds the LDR, a light-dependent resistor sitting in a divider on GPIO2. Reading it means running an analog-to-digital conversion, which raises a question the earlier components did not: what should the function return? A raw count, a voltage, or a light level in lux. The choice determines how much the BSP has to know about the rest of the board.
The ADC also adds a setup constraint we haven't seen yet. A single reading needs two separate objects, so this is the first component that doesn't map to a single thing to store.
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_stddevelopment in Rust, preferably usingesp-hal.Familiarity with ADC concepts, mainly resolution and reference voltage.
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 06-ldr-adc/:
git clone https://github.com/theembeddedrustacean/learn-bsp-rs
cd learn-bsp-rs/06-ldr-adc
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:
- µFerris Megalops Baseboard — the board this BSP is written for. Available from The Embedded Rustacean Store.
- 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 |
|---|---|---|
| LDR | GPIO2 | Analog input on ADC1. Read as a 12-bit conversion. |
GPIO2 is one of the pins the ESP32-C3 routes to ADC1. Not every pin can be read as an analog input, and which unit a pin belongs to is fixed in silicon. We use ADC1 here because the board puts the LDR on GPIO2.
👨🎨 Software Design
This post builds on the BSP from the previous post, where we added the buzzer. 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.
The Board Struct
Reading the LDR needs two objects. The ESP32-C3 ADC is a converter shared by several pins, so Adc is the converter itself, and AdcPin specifies which pin is enabled on it and carries that pin's attenuation setting. A conversion needs both: the driver to run it, and AdcPin to say which input to sample. Both types come from esp_hal::analog::adc.
The board struct keeps one member per component, so we pair the two in a small struct of their own first:
pub struct LdrAdc {
adc: Adc<'static, ADC1<'static>, Blocking>,
pin: AdcPin<GPIO2<'static>, ADC1<'static>>,
}
Blocking marks the driver as the blocking rather than async flavor. We also provide an implementation for reading a raw value.
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 board struct then gains a single 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,
}
Notice how the board struct holds one member per component, and nothing else. A peripheral that needs several objects to operate gets those objects wrapped in a type named after the component, and the board holds that type.
The alternative is to let Adc and AdcPin sit on UFerris as two separate fields. That compiles and works, but the struct stops being a list of what is on the board and becomes a list of what the HAL handed back. ldr_driver tells you the board has an LDR. ldr_adc and ldr_pin tells you nothing about the board.
The Board Initialization Function
We will update uferris_init to configure the ADC. The ADC has an ordering constraint. Pins are enabled on an AdcConfig, and the Adc is then built from that config:
Create an
AdcPinconfiguration usingAdcConfig.Instantiate the ADC1 peripheral.
Pair the two into an
LdrAdctype.
The Board Control Functions
We'll introduce one function that the BSP will support:
| Function | Behaviour |
|---|---|
read_ldr() |
Runs a conversion when called and returns the raw 12-bit count. |
Note that the function returns a raw count, not lux or millivolts. This is the design decision of the post. Optionally, you can add a read_ldr_lux() function that returns the value in lux or a read_ldr_mvolts() that returns the reading in millivolts. This means you need to incorporate conversion formulas in the function. That is left for the reader as an exercise.
Test Application Design
The test application steps are as follows:
Read the LDR.
Print the raw value over the serial port.
Turn LED 1 on when the reading is below the dark threshold; otherwise, turn it off.
Sound the buzzer for 50 ms, only on the transition into darkness.
Wait 500 ms (paces the sampling).
Go back to step 1.
The threshold is a constant in the application, not in the BSP, and the right value depends on your room. Run it, watch the printed numbers with the LDR uncovered and then covered, and pick a value between the two.
👨💻 Code Implementation
1️⃣ Bring in the ADC types. Everything from the previous post stays. We add the ADC types and the two peripheral types that appear in the struct:
use esp_hal::{
Blocking,
analog::adc::{Adc, AdcConfig, AdcPin, Attenuation},
peripherals::{ADC1, GPIO2, Peripherals},
time::Rate,
};
2️⃣ Define the ADC component struct. The struct holds the two objects a conversion needs, plus the read that uses them. Below is the code:
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)
}
}
Note that both fields are private, so the only way to get a number out of an LdrAdc is read_raw. Nothing outside the BSP can take the AdcPin and start a conversion of its own.
Note also that the read is non-blocking underneath. We use the nb::block! macro that retries until the value arrives. read_oneshot starts a conversion and returns an nb::Result. While the conversion is still in flight, it returnsWouldBlock, which is not an error; it means "not ready, ask again".
3️⃣ Populate the board struct. Add the component and its type as a single member to the board struct.
pub struct UFerris {
led1: Output<'static>,
sw_btn5: Input<'static>,
buzzer: Channel<'static, LowSpeed>,
ldr_driver: LdrAdc,
}
4️⃣ Populate the initialization function. In the init function, we now need to configure the ADC, after the LEDC setup from the previous post. Below is the code:
// The LDR pin has to be enabled on the config before the ADC is built.
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,
};
UFerris {
led1,
sw_btn5,
buzzer,
ldr_driver,
}
Attenuation is specific to analog inputs. The ADC's own input range is small, and attenuation scales the incoming signal down to fit it. _11dB is the widest setting the ESP32-C3 offers and covers roughly the full supply span. A narrower setting gives better resolution over a smaller voltage window, and clips once the input voltage swings past it.
Note that attenuation is a property of the pin, not of the converter. That is why it is passed to enable_pin and ends up baked into the AdcPin token.
5️⃣ Add the control function. In an impl block on UFerris, we add a board method that delegates to LdrAdc::read_raw:
// Read the LDR as a raw ADC count.
pub fn read_ldr(&mut self) -> u16 {
self.ldr_driver.read_raw()
}
🏃♂️ Test Code & Run
#![no_std]
#![no_main]
use esp_backtrace as _;
use esp_hal::{clock::CpuClock, main};
use esp_println::println;
use uferris::{UFerris, uferris_init};
esp_bootloader_esp_idf::esp_app_desc!();
// Reading below which the room counts as dark. Pick this from the values
// the application prints on your own board. If covering the LDR raises the
// reading instead of lowering it, flip the comparison in the loop.
const DARK_THRESHOLD: u16 = 1500;
#[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();
// Variable to keep track of the last state.
let mut was_dark = false;
loop {
let light = uferris.read_ldr();
let is_dark = light < DARK_THRESHOLD;
println!("LDR {light}");
// LED 1 stays on while the LDR is covered
if is_dark {
uferris.led1_on();
} else {
uferris.led1_off();
}
// Chirp once on the way into darkness
if is_dark && !was_dark {
uferris.buzz_on(50);
delay.delay_millis(50);
uferris.buzz_off();
}
was_dark = is_dark;
// Read twice a second
delay.delay_millis(500);
}
}
Flash it and open the monitor:
cargo run --release
The terminal prints a reading twice a second. Put a finger over the LDR and the number moves, LED 1 comes on, and the buzzer chirps once as it crosses the threshold:
LDR 2840
LDR 2793
LDR 1102
LDR 964
If the number barely moves between covered and uncovered, the threshold has nothing useful to sit between. Check that the attenuation is _11dB, because a narrower setting can leave the divider's whole swing above the top of the range, where every reading clips to the same value.
✅ Conclusion
We added the LDR, and with it, the BSP now reads as well as writes. Getting there took a component struct to hold the two objects a conversion needs and a decision about what units to return.
That completes the components wired directly to the chip. The next post moves on to I2C, where a single bus is shared by several devices and the board struct has to hand out access to it.
📱 Full Code
src/lib.rs
#![no_std]
use embedded_hal::pwm::SetDutyCycle;
use esp_hal::{
Blocking,
analog::adc::{Adc, AdcConfig, AdcPin, Attenuation},
gpio::{DriveMode, Input, InputConfig, Level, Output, OutputConfig},
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 reads its frequency from. The channel
static BUZZER_TIMER: StaticCell<Timer<'static, LowSpeed>> = StaticCell::new();
// Resonant frequency of the µFerris buzzer, in hertz.
const BUZZER_FREQ_HZ: u32 = 2700;
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)
}
}
/// A handle to the µFerris board and everything on it.
pub struct UFerris {
led1: Output<'static>,
sw_btn5: Input<'static>,
buzzer: Channel<'static, LowSpeed>,
ldr_driver: LdrAdc,
}
// Consume the raw chip peripherals and hand back a ready-to-use 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");
// The LDR pin has to be enabled on the config before the ADC is built.
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,
};
UFerris {
led1,
sw_btn5,
buzzer,
ldr_driver,
}
}
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 the given duty, as a percentage from 0 to 100.
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();
}
// Read the LDR as a raw ADC count.
// The value is a 12-bit conversion, so it ranges from 0 to 4095. It is
// not converted to lux or millivolts; see the crate docs for why.
pub fn read_ldr(&mut self) -> u16 {
self.ldr_driver.read_raw()
}
}
src/bin/main.rs
#![no_std]
#![no_main]
use esp_backtrace as _;
use esp_hal::{clock::CpuClock, main};
use esp_println::println;
use uferris::{UFerris, uferris_init};
esp_bootloader_esp_idf::esp_app_desc!();
const DARK_THRESHOLD: u16 = 1500;
#[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 to time the chirp and pace the sampling.
let delay = esp_hal::delay::Delay::new();
// Variable to keep track of the last state so we only chirp on a change.
let mut was_dark = false;
loop {
let light = uferris.read_ldr();
let is_dark = light < DARK_THRESHOLD;
println!("LDR {light}");
// LED 1 stays on for as long as the LDR is covered.
if is_dark {
uferris.led1_on();
} else {
uferris.led1_off();
}
// Chirp once on the way into darkness, not on every reading.
if is_dark && !was_dark {
uferris.buzz_on(50);
delay.delay_millis(50);
uferris.buzz_off();
}
was_dark = is_dark;
// Sample twice a second. The LDR does not move fast enough to need more.
delay.delay_millis(500);
}
}





