The goal is to build a proof-of-concept for a multi-ToF bare metal firmware platform that continuously provides a sensor stream for in-flight analysis. The design will initially aim to support eight ToF devices simultaneously but will support any number. The actual number only affects the total sampling rate.
Design
The target is a Raspberry Pi, an RP2040 ARM dual-core Cortex-M0+.
Figure 1 depicts a single VL53L5CX wired to one host. Multiple devices share the same I2C bus but have exclusive GPIO-based reset and LPn connections.
Figure 1: Simplified deployment schematic for VL53L5CX
VL53
The interface is fast-mode 1Mbit s-1 I2C with an interrupt output pin. The device has a shifted address 5216 on the shared I2C bus.
ST provide a low-level driver for the device: Ultra Lite Driver (ULD) for VL53L5CX multi-zone sensor. See Figure 2. The schematic depicts the stateful information retained by the device implied by its interface methods.
Figure 2: VL53L5CX class schematic for VL53L5CX software component
The choice of Raspberry Pi does present an ‘impedance mismatch’ between the ST driver and the target. The driver supplies a default interface to an STM32 host with its ST-style I2C integrated peripherals. This implies the existence of a shim between the Ultra Lite driver and the Pico SDK drivers used for the underlying hardware. ST anticipate this requirement by including a ‘platform’ abstraction. See Figure 3. The abstraction resolves a set of externally-resolved C functions that accept a Platform pointer as the first argument.
Figure 3: Class schematic for VL53L5CX platform abstraction
By the principle of maximum reuse, our development process will start by mapping the Platform abstraction to the Raspberry Pi’s Pico SDK interface for I2C. The reuse principle also acts as an upgrade buffer in-between ST development and our own; ST can upgrade their hardware and its driver and the application remains agnostic unless the interfaces themselves change. The application gains a measure of upgrade compatibility using this approach.
Implementation Details
The design requires an object-oriented wrapper for multiple devices with a low-level platform-oriented abstraction for connecting the board-support layer to the underlying I2C peripheral hardware.
Implementing the Platform Abstraction
Shimming between the ST driver and the lower-level I2C
transport functions requires additional structure members for the
VL53L5CX_Platform
abstraction, along with appropriate TwoWire
implementations for the RdByte
family of interface functions (see
Figure 3).
typedef struct {
// Forward-declare as a structure. The actual implementation is a class.
// Acts as a simple workaround for merging C with C++ implementation sources.
struct TwoWire *two_wire;
uint16_t address;
int i2c_rst_pin;
int lpn_pin;
} VL53L5CX_Platform;
Implementing the VL53 Wrapper
#include <Wire.h>
extern "C" {
#include "vl53l5cx_api.h"
}
namespace st {
class VL53L5CX {
public:
VL53L5CX(TwoWire *two_wire, pin_size_t lpn_pin, pin_size_t i2c_rst_pin = -1) {
configuration_.platform.two_wire = two_wire;
configuration_.platform.i2c_rst_pin = i2c_rst_pin;
}
virtual ~VL53L5CX() = default;
void on() { lpn_pin(HIGH); }
void off() { lpn_pin(LOW); }
void reset() {
off();
on();
off();
}
uint8_t init() { return vl53l5cx_init(&configuration_); }
uint8_t set_i2c_address(uint16_t i2c_address) {
if (configuration_.platform.address == i2c_address)
return 0x00U;
configuration_.platform.address = i2c_address;
return vl53l5cx_set_i2c_address(&configuration_, i2c_address);
}
uint8_t get_ranging_data(VL53L5CX_ResultsData *results) {
return vl53l5cx_get_ranging_data(&configuration_, results);
}
protected:
void lpn_pin(PinStatus pin_status) {
if (configuration_.platform.lpn_pin > 0) {
digitalWrite(configuration_.platform.lpn_pin, pin_status);
delayMicroseconds(lpn_delay_us);
}
}
private:
VL53L5CX_Configuration configuration_ = {
.platform = {.address = VL53L5CX_DEFAULT_I2C_ADDRESS}};
unsigned long lpn_delay_us = 1000UL;
};
} // namespace st
Of course, the class is missing various accessors, e.g. for the LPn delay which defaults to 1000µs as well as methods for starting and stopping the ranging process each of which simply reflects access to the ST driver (see Figure 2).
Usage
Setting Up
In order to apply the code to a multi-ToF application, we will instantiate multiple wrappers sharing the same bus but with their own GPIO pin connections.
#include <vector>
static std::vector<st::VL53L5CX> devices;
void setup() {
devices.push_back(st::VL53L5CX(&Wire, D14, D2));
devices.push_back(st::VL53L5CX(&Wire, D15, D3));
devices.push_back(st::VL53L5CX(&Wire, D16, D4));
devices.push_back(st::VL53L5CX(&Wire, D17, D5));
devices.push_back(st::VL53L5CX(&Wire, D18, D6));
devices.push_back(st::VL53L5CX(&Wire, D19, D7));
devices.push_back(st::VL53L5CX(&Wire, D20, D8));
uint16_t i2c_address = VL53L5CX_DEFAULT_I2C_ADDRESS;
for (auto device : devices) {
device.on();
device.init();
device.set_i2c_address(i2c_address += 0x02U);
}
}
This set-up stage configures the devices and initialises them. It ignores success or failure, though in practice the firmware will detect and fail gracefully if devices fail to initialise, e.g. by retrying and finally notifying the user with an alarm signal.
Setting up powers on each device in turn, one by one. After initialising the device at its default address, the set-up code then sets the device’s new address to some other value other than the default because the next iterated device will power up with the default address. All the devices will thereafter possess a unique I2C bus address.
Master Mode
Finally, we can perform a simple transform operation to unify status-result pairs for all devices. The host operates in master mode. Calls to the platform layer trigger master-mode I2C transfers.
#include <algorithm>
void loop() {
std::vector<std::pair<uint8_t, VL53L5CX_ResultsData>> ranging_data;
std::transform(devices.begin(), devices.end(),
std::back_inserter(ranging_data), [](auto device) {
VL53L5CX_ResultsData results;
return std::make_pair(device.get_ranging_data(&results),
results);
});
}
This gives a set of ranging results along with their statuses and completes the telemetry acquisition step. From this point forwards, the Cortex cores can process the results in parallel. The cores must transfer in series because the devices share the same bus. Thereafter, the host can utilise all its available compute resources to maximise throughput.
Conclusions
ST’s drivers make the effort of developing multi-ToF applications reasonably straightforward.
Future Directions
The design could apply a vectorised differential sampling algorithm. When moving in a particular direction, attention primarily goes towards the direction of movement. The software could imitate real life by increasing the rate of sampling for those sensors pointing in the direction of motion.
Other secondary goals could include: making the best use of the communication bus. As listed above, the code does not make use of interrupt-drive I2C; it uses the default interface which runs the bus by polling. Ideally, it should make use of interrupts triggered by the integrated peripheral hardware for better use of computing power and thereby battery power.
The firmware could also improve by making effective use of the multi-core capabilities of the ARM dual-core since the post-processing of the raw data will require more than a little compute-bound resource to digest and consume. The interface itself has input-bound constraints but the core throughput should never bind the system by introducing compute limitations that narrow the optical bandwidth further than its intrinsic capabilities.