Tommy's Blog

Acceptance Testing embedded-hal Drivers

A while ago I was working on some Rust embedded-hal drivers for the PiicoDev range of open hardware from Core Electronics. PiicoDev already has excellent support in MicroPython and there are existing embedded-hal drivers for individual pieces of PiicoDev hardware (albeit incidentally; by targeting whichever IC is at the heart of the device e.g. vl53l1x-uld). Nevertheless, I was interested in creating a holistic set of embedded-hal drivers that targeted PiicoDev specifically.

Moving from MicroPython to Rust's embedded-hal, it makes little sense to get too attached to a MicroPython API. There's only so well the API can realistically be replicated in Rust. Choosing to disregard the API of the existing MicroPython drivers was liberating: it allowed me to focus on creating my own API that could best utilize the Rust environment. I feel like I made some interesting API choices but these choices are not really what I want to talk about here.

A problem came up working on these drivers: how do I decide when these are ready to be released? One potential criterion for whether my new drivers pass muster:

Can these new embedded-hal drivers be used for all of the same things as the existing MicroPython drivers?

This definition is too broad since we can't anticipate the uses of this driver for everyone. The official drivers come with a number of examples though, so perhaps a more realistic definition could be:

Can these new embedded-hal drivers be used to recreate all of the same examples as the existing MicroPython drivers?

A nice bonus here is that by using this as some kind of acceptance criterion, it follows that the examples need to be ported (and code examples are usually a pretty indispensable resource for API consumers).

Laser Distance Sensor VL53L1X (p7)

I've picked just a single PiicoDev device to focus on here: Laser Distance Sensor VL53L1X. This device is identified as p7 in PiicoDev URLs. p7 is a convenient shorthand to refer to this device and is what I'll be sticking to. The official driver includes a single, simple example for p7:

CE-PiicoDev-VL53L1X-MicroPython-Module/main.py
from PiicoDev_VL53L1X import PiicoDev_VL53L1X
from time import sleep

distSensor = PiicoDev_VL53L1X()

while True:
    dist = distSensor.read() # read the distance in millimetres
    print(str(dist) + " mm") # convert the number to a string and print
    sleep(0.1)

Running the MicroPython Example with Thonny

Thonny eases some of the pain points common to getting started with MicroPython:

The Raspberry Pi Foundation covers using a Pico with Thonny in Getting started with the Raspberry Pi Pico. Core Electronics also provides some instructions for How to Setup a Raspberry Pi Pico and Code with Thonny. These instructions are a bit more relevant to the particular hardware I'm using. And some further instructions from Core Electronic is all that is needed to get the above p7 example running in Thonny. Summarising these instructions:

  1. Install an appropriate MicroPython interpreter on the Pico.
    Thonny options
  2. Copy the PiicoDev Unified Library to the Pico file system.
  3. Copy the PiicoDev_VL53L1X.py to the Pico file system.
  4. Run the above example.
    Thonny - main.py @ 1:1

Running the MicroPython Example with mpremote

Thonny is great for interactive operation of a MicroPython interpreter but for running tests something less interactive is ideal. MicroPython makes available a handy tool called mpremote for talking with the interpreter from a host's command line. Steps 2-4 of Running the MicroPython Example with Thonny can be automated through mpremote.

In other words: mpremote can be used for basically all of what Thonny can be used for except for installing a MicroPython interpreter when one isn't already installed. Without Thonny to find the right MicroPython interpreter for a particular development board, it is necessary to carefully select the right one at MicroPython Downloads.
Some popular options:

Running the example with mpremote:

  1. Hold the BOOTSEL button whilst connecting the Pico to the host. This puts Pico in USB mass storage device mode, ready for the interpreter to be copied to it.
  2. Copy the downloaded MicroPython UF2 file to the mounted Pico.
  3. Wait a second or two for the interpreter to become available.
  4. Copy the PiicoDev shared library code to the Pico
    mpremote cp CE-PiicoDev-Unified/min/PiicoDev_Unified.py :
  5. Copy the PiicoDev p7 specific driver code to the Pico
    mpremote cp CE-PiicoDev-VL53L1X-MicroPython-Module/min/PiicoDev_VL53L1X.py :
  6. Run the p7 example
    mpremote run CE-PiicoDev-VL53L1X-MicroPython-Module/main.py

Condensing this into a script:

install-and-run.sh
# connect to Pico with BOOTSEL held
# assumes that it has been mounted to DEVBOARD_MOUNTPOINT
set -e
DOWNLOADED_INTERPRETER=$1
DEVBOARD_MOUNTPOINT=$2
echo Copying MicroPython interpreter to devboard
cp -X -v $DOWNLOADED_INTERPRETER $DEVBOARD_MOUNTPOINT
echo Waiting for interpreter to come up
sleep 5
echo Copying libraries to devboard
mpremote cp CE-PiicoDev-Unified/min/PiicoDev_Unified.py \
  CE-PiicoDev-VL53L1X-MicroPython-Module/min/PiicoDev_VL53L1X.py :
echo Running example
mpremote run CE-PiicoDev-VL53L1X-MicroPython-Module/main.py

The example runs indefinitely but can be disconnected from with Ctrl+X. This automates basically all of the steps involved in running this example except

Installing MicroPython Without Holding BOOTSEL

Being familiar with running code on the Pico with probe-rs, needing to connect the Pico with the BOOTSEL button held just to install the interpreter does not seem strictly necessary. The rp2040-project-template is what originally got me onto using probe-rs for programming the Pico in a Rust environment. It is a great starting point if you're somewhat familiar with Rust but have never used it on the Pico before.

Using probe-rs with the Raspberry Pi Pico requires a probe. Two easy options for getting your hands on a probe:

I don't own one of the purpose-built probes but I do have a spare Pico. So I've used that as my probe.

A Raspberry Pi Pico connected as a probe to a target Pi Pico

MicroPython binaries are distributed as UF2. UF2 is a pretty handy format. It is designed expressly for ease-of-use. A device makes itself available as mass storage (for the Pico this is when a BOOTSEL button is held during connection) and any UF2 file copied to this storage is treated as a new binary to run. probe-rs does not accept UF2 files for programming (but will for version 0.21.0). This is only a minor problem though because there is an official tool to convert any UF2 file to a plain-old Bin format that is acceptable to probe-rs.

% uf2/utils/uf2conv.py DOWNLOADED_INTERPRETER
--- UF2 File Header Info ---
Family ID is RP2040, hex value is 0xe48bff56
Target Address is 0x10000000
All block flag values consistent, 0x2000
----------------------------
Converted to bin, output size: 700416, start address: 0x10000000
Wrote 700416 bytes to flash.bin

The output here is important. It gives us:

This information can be fed fairly directly into probe-rs

% probe-rs download --protocol swd --chip RP2040 --format bin \
> --base-address 0x10000000 --disable-progressbars flash.bin
    Finished in 44.151s

Now the interpreter is installed and running on the target Pico without having to reconnect it with BOOTSEL held every time a re-installation is required.

Installing MicroPython and Running the Example Without Holding BOOTSEL

A separate connection is still needed by mpremote to control the MicroPython interpreter (for important things like running the example). That is not actually a problem though: using a USB connection to the probe to install the MicroPython interpreter does not preclude a concurrent USB connection directly to the target. In other words, probe-rs can have its connection for installing MicroPython and mpremote can have its connection for communicating with the interpreter. This is how I ended up with a monstrous testing apparatus

A target Raspberry Pi Pico connected to a probe and a host

Having connected the host to the target and the probe, manual intervention is no longer necessary to install the interpreter and run an example:

install-and-run-no-bootsel.sh
set -e
DOWNLOADED_INTERPRETER=$1
git clone --depth 1 https://github.com/microsoft/uf2.git uf2
uf2/utils/uf2conv.py $DOWNLOADED_INTERPRETER
echo Installing MicroPython interpreter
probe-rs download --protocol swd --chip RP2040 \
    --format bin --base-address 0x10000000 flash.bin
echo Waiting for interpreter to come up
sleep 5
echo Copying libraries to devboard
mpremote cp CE-PiicoDev-Unified/min/PiicoDev_Unified.py \
  CE-PiicoDev-VL53L1X-MicroPython-Module/min/PiicoDev_VL53L1X.py :
echo Running example
mpremote run CE-PiicoDev-VL53L1X-MicroPython-Module/main.py

Running A Rust Example

Coming back to the problem of validating Rust drivers by running example code, let's look at what the main loop of a Rust example might look like:

examples/p7.rs
loop {
    // read the distance in millimetres
    let dist = dist_sensor.read().unwrap();
    // convert the number to a string and print
    println!("{} m", dist.to_f64() / 1000.0_f64);
    delay.delay_us(100_000);
}

probe-rs provides a mechanism called Real-Time Transfer that carries program output from the target to the host in real-time over the same connection used to program the target. Readings from p7 will be printed every decisecond indefinitely (until interrupted with Ctrl+C):

% probe-rs run --protocol swd --chip rp2040 --no-location  \
> target/thumbv6m-none-eabi/debug/examples/p7 2>/dev/null
1.623 m
1.623 m
1.628 m
1.626 m
1.626 m

Validating Rust Example Against MicroPython Example

Whether running the Rust example or the MicroPython example, the program output ends up in the same place: STDOUT. Thus, a process to validate the Rust example against the MicroPython:

  1. Collect output from the MicroPython example
  2. Collect output from the Rust example
  3. Compare the output collected from the Rust example with the output collected from the MicroPython example

With just a little bit of work this process can be adapted for Rust; even integrating with the default test runner. The details of reaching out to the target and running the different examples are hidden away in a support module for the test here:

tests/p7.rs
mod support;

fn parse_line(line: &str) -> i16 {
    let (value, unit) = line.rsplit_once(' ').unwrap();
    assert_eq!(unit, "mm");
    value.parse().unwrap()
}

#[test]
fn p7_test() {
    let mut micropython_example = support::Example::run_micropython(
        "mp/CE-PiicoDev-VL53L1X-MicroPython-Module/main.py",
        vec![
            "mp/CE-PiicoDev-Unified/min/PiicoDev_Unified.py",
            "mp/CE-PiicoDev-VL53L1X-MicroPython-Module/min/PiicoDev_VL53L1X.py",
        ],
    );
    let micropython_output: Vec<i16> = micropython_example
        .output().take(10).map(|l| parse_line(&l)).collect();

    let mut example = support::Example::run("p7");
    let output: Vec<i16> = example
        .output().take(10).map(|l| parse_line(&l)).collect();

    assert!(output.iter().zip(micropython_output)
        .all(|(mm, mp_mm)| (mm - mp_mm).abs() < 10));
}
Running the test:
% cargo test
    Finished test [optimized + debuginfo] target(s) in 0.36s
     Running tests/p7.rs (target/debug/deps/p7-0c2d20189102e932)

running 1 test
test p7_test ... FAILED

failures:

---- p7_test stdout ----
thread 'p7_test' panicked at 'assertion failed: `(left == right)`
  left: `"m"`,
 right: `"mm"`', tests/p7.rs:6:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    p7_test

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 40.22s

error: test failed, to rerun pass `--test p7`

Oh no! The test has failed! The wrong units have been used. Correcting the example:

examples/p7.rs
loop {
    // read the distance in millimetres
    let dist = dist_sensor.read().unwrap();
    // convert the number to a string and print
    println!("{} mm", dist);
    delay.delay_us(100_000);
}
Running the test again (having corrected the mistake)
% cargo test
    Finished test [optimized + debuginfo] target(s) in 0.36s
     Running tests/p7.rs (target/debug/deps/p7-0c2d20189102e932)

running 1 test
test p7_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 56.51s

     Running tests/support.rs (target/debug/deps/support-797f4721340a568e)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

A Problematic Approach?

So I've been able to use a Rust example corresponding to a MicroPython example as some kind of proxy for whether or not my Rust driver is a suitable replacement for the MicroPython driver. There are notably some problems with the approach I've taken:

There are probably more issues to consider than just these. Overall, the setup and reproducibility is not perfectly certain. Web developers often talk about tests being flaky. It's reasonable to expect some flakiness in my driver acceptance tests. I don't expect to be running these tests much though (only to determine whether or not a new version of a driver crate is suitable for release). Just how burdensome will it be for me to deal with flaky driver acceptance tests? I feel like this is something to be borne out by usage.

Improvements

There are definitely some issues with what I've come up with here. Some improvements I'm keen to try:

Thank You for Proofreading

Plug

I would love to work full-time on embedded and/or Rust projects. If you know of any interesting work opportunities in this area please drop me a line.