Spike: Gpio pins as a statically typed heterogeneous lists using frunk
Using frunk, and the following macro I managed to put together...
(You could think of it as a tuple, idexable by type)
macro_rules! create_pins {
($head:expr, $($tail:expr),+) => {
HCons<GpioPin<Unknown, $head>, create_pins!($($tail),*)>
};
($single:expr) => {
HCons<GpioPin<Unknown, $single>, HNil>
};
}
macro_rules! create_pins_head {
($head:expr, $tail:expr) => {
HCons<GpioPin<Unknown, $head>, $tail>
};
}
Using the macro like so:
type HetPinList = create_pins!(0, 1, 2, 3);
...expands to:
type HetPinList = HCons<GpioPin<Unknown, 0>,
HCons<GpioPin<Unknown, 1>,
HCons<GpioPin<Unknown, 2>,
HCons<GpioPin<Unknown, 3>,
HNil>>>>;
...which provides this handy interface
...so lets use it in our IO struct:
/// General Purpose Input/Output driver
pub struct IO<T: HList>
{
_io_mux: IO_MUX,
pins: T
}
impl<T: HList> IO<T> {
/// Construct the initial list of pins
pub fn new(gpio: GPIO, io_mux: IO_MUX) -> !/*IO<create_pins!(1, 2, 3)>*/ {
let pins = todo!(); // construct the actual pins
let io = IO {
_io_mux: io_mux,
pins,
};
io
}
}
impl<T: HList + Plucker<GpioPin<Unknown, { PIN_NO }>, HList>> IO<T> {
/// Claim a pin
pub fn pin<const PIN_NO: u8>(&mut self) -> GpioPin<Unknown, PIN_NO> {
let (pin, rest) = self.pins.pluck();
self.pins = rest;
pin
}
}
note: the pins field is now private, replaced by a special getter, and instead of let pin18 = io.gpio18, you do let pin18 = io.pin::<18>()... though this depends on IO::pin taking &mut self, which might be difficult.
another example: The ability to construct a USB like so, as the type system should be able to infer that the pins need to be pins 18, 19, and 20 (esp32s3):
let usb = USB::new(
peripherals.USB0,
io.pluck(),
io.pluck(),
io.pluck(),
&mut system.peripheral_clock_control,
);
That's unlikely, though, as the pin-list is a generic, changing it's monomorphised type each time you remove a pin
Possible pros that I see so far:
- The possibility of compile-time, integer indexing to select pins
- "auto-indexing" where the type system can infer which pin to take, such as when picking pins for
Usb::new(each pin-arg can use only one pin) - It almost feels like GpioPins are a perfect use-case for Heterogeneous lists: Too different to be in an array without the overhead of dynamic dispatch (array of trait objects), and yet GpioPins are a series of the same thing, just with seperate, well defined, roles.
Possible cons:
- A very FP-based design. Those not familiar with FP style lists might have a tough time getting to grips with this
- Performance traps: Could this cause an explosion in the code size or compile time due to the huge(?i think?) leverage of compile-time generics
I'll continue investigating this...
We also have type-erasure for gpio pins: https://github.com/esp-rs/esp-hal/blob/422eb2118676d4c1c938fa6cb2b8211fb177c232/esp32c3-hal/examples/blinky_erased_pins.rs#L68-L76
...some developments which I'll put in a PR shortly, but it essentially means that you can rely on the type-system to
- the IO struct encapsulates which pins are available at compile time using the type system
- where the type-system constrains which pin can be used, the type-system will auto-magically select that pin
- (To be confirmed): gets optimised away at compile time to not affect code-size, or run-time
The benefits to how code is written:
- no partial-move problems: you can use helper methods to initialize pins
- no conflicts with the borrow-checker: the IO struct can hand over ownership of individual pins (updating its type as it does so)
to illustrate, the blinky example could be re-written as so:
// Initialize a blinky-pin using pin#4
let io = IO::new(peripherals.GPIO, peripherals.IO_MUX);
let (blinker, io) = Blinker::<4>::initialize(io);
// One job of the clock system is to manage short delays...
let mut delay = Delay::new(&clocks);
blinker.blink_loop(&mut delay, 500u16);
...with the impl-detail defined as such:
struct Blinker<const PIN: u8> {
pin: GpioPin<Output<PushPull>, PIN>
}
impl Blinker {
fn initialize<T, U>(io: IO<T>) -> (Self, IO<U>)
where T: HList, U: HList
{
let (led, io): ( GpioPin<Unknown, PIN>, _) = io.pins.pluck();
let mut led = led.into_push_pull_output();
led.set_high().unwrap()
(led, io)
}
fn toggle(&mut self) {
self.pin.toggle().unwrap();
}
fn blink_loop(self, delay: &mut Delay, rest_period: u16) -> ! {
loop {
self.toggle().unwrap();
delay.delay_ms(rest_period);
}
}
}
This is a very basic e.g., will be working on a more complex one that should hopefully leverage its advantages in more detail.
Progress report:
This syntax is close to viable. Struggling with managing the behavior marker traits (this comes out as needing the TogglableOutPutPin), but the core principals are proving viable:
// Initialize a blinky-pin using pin#4
let io = IO::new(peripherals.GPIO, peripherals.IO_MUX);
let (pin, io) = io.init_pin(|pin| {
let pin = pin.into_push_pull_output();
pin.set_high().unwrap();
pin
});
let blinker = Blinker::<4>{pin};
// One job of the clock system is to manage short delays...
let mut delay = Delay::new(&clocks);
let _ = blinker.blink_loop(&mut delay, 500u16);
...the next goal is to be able to have something like this (single-line initialization):
let io = IO::new(peripherals.GPIO, peripherals.IO_MUX);
let (blinker, io) = io.init_pin(|pin| {
let pin = pin.into_push_pull_output();
pin.set_high().unwrap();
Blinker::<4>{pin}
});
// One job of the clock system is to manage short delays...
let mut delay = Delay::new(&clocks);
let _ = blinker.blink_loop(&mut delay, 500u16);
unreachable!()
Or a means to very easily define the construction on (in this e.g.) Blinky in terms of "taking an io, plucking a pin, initializing said pin, then returning Self alongside the remaning IO". That can be done, but the generics start looking a bit crazy and unweildy.
This works:
let io = IO::new(peripherals.GPIO, peripherals.IO_MUX);
let (blinker, _io) = Blinker::initialize(io);
// One job of the clock system is to manage short delays...
let mut delay = Delay::new(&clocks);
let _ = blinker.blink_loop(&mut delay, 500u16);
unreachable!()
with this:
struct Blinker {
pin: GpioPin<Output<PushPull>, 4>
}
impl Blinker
{
fn initialize<T, Remaining>(io: IO<T>) -> (Self, IO<T::Remainder>)
where
T: Plucker<GpioPin<Unknown, 4>, Remaining>
{
let (pin, io) = io.pluck_pin();
let mut pin = pin.into_push_pull_output();
pin.set_high().unwrap();
(Self{pin}, io)
}
fn toggle(&mut self) {
self.pin.toggle().unwrap();
}
fn blink_loop(mut self, delay: &mut Delay, rest_period: u16) -> ! {
loop {
self.toggle();
delay.delay_ms(rest_period);
}
}
}
...if you want to be generic on the pin for the `Blinker struct, it's a pain, because you need to tell the initializer all the trait constraints that apply:
struct Blinker<const PIN: u8> {
pin: GpioPin<Output<PushPull>, PIN>
}
impl<const PIN: u8> Blinker<PIN>
{
fn initialize<T, Remaining>(io: IO<T>) -> (Self, IO<T::Remainder>)
where T: Plucker<GpioPin<Unknown, PIN>, Remaining>,
GpioPin<Unknown, PIN>: GpioProperties,
GpioPin<Output<PushPull>, PIN>: GpioProperties,
<GpioPin<Output<PushPull>, PIN> as GpioProperties>::PinType: IsOutputPin,
<GpioPin<Unknown, PIN> as GpioProperties>::PinType: IsOutputPin,
{
... but that works :/
#748
progress update:
In my personal project, for setting up a mouse wheel, I used to have this:
struct WheelEncoder {
encoder_a: GpioPin<Input<PullUp>, 35>,
encoder_b: GpioPin<Input<PullUp>, 36>,
_gnd: GpioPin<Output<PushPull>, 0>,
value: u8,
state: bool,
prev_state: bool,
scroll_val: i8,
}
// in top-level IO-ownership scope (usually main function):
let encoder_a = io.pins.gpio35.into_pull_up_input();
let encoder_b = io.pins.gpio36.into_pull_up_input();
let mut wheel_gnd = io.pins.gpio0.into_push_pull_output();
let _ = wheel_gnd.set_low();
let mut wheel = WheelEncoder::new(encoder_a, encoder_b, wheel_gnd);
...now it's just this, and it can be wherever you want it:
let (mut wheel, _io) = WheelEncoder::hl_new(io, 0, true, true, 0);
To do this, I've fleshed out a derive macro with two key features:
- list construction can be opted out per field
- pin-initialization can be encoded directly into the struct definition
...the ergonomics/design of the derive-macro is still very pre-release. Next progress report should include a way to specify a method to initialize the struct: e.g. "call Self::new(x, y, z)"
#[derive(frunk::ListBuild)]
struct WheelEncoder {
#[list_build_ignore]
value: u8,
#[list_build_ignore]
state: bool,
// into pull-up input
#[plucker(GpioPin<Unknown, 35>, map=pin_a.into_pull_up_input())]
pin_a: GpioPin<Input<PullUp>, 35>,
// into pull-up input
#[plucker(GpioPin<Unknown, 36>, map=pin_b.into_pull_up_input())]
pin_b: GpioPin<Input<PullUp>, 36>,
// into output, set to low.
#[plucker(GpioPin<Unknown, 0>, map={ let res = pin_gnd.into_push_pull_output(); res.set_low(); res })]
pin_gnd: GpioPin<Output<PushPull>, 0>,
#[list_build_ignore]
prev_state: bool,
#[list_build_ignore]
scroll_val: i8,
}
commit in which this works: https://github.com/Ben-PH/frunk-contrib/tree/eb45b29d988e764db1eb7c71fe0aaf4ed7fef7b1
Thanks for investigating this @Ben-PH! I think we're moving in a direction where we want fewer generics and less magic where possible in esp-hal, which I believe this kind of approach would move us in the opposite direction. Therefore I will close this for now.