Need help with type mapping

Note: everything is #[nostd] with no allocator so no Boxing etc.

We're trying to design a generic "display interface" for embedded device display drivers. The role of the display interface is to provide a unified way to "send data" over a hardware interface such as SPI, I2C, or direct pins so that display drivers can work with it instead of being forced to implement specifically for each combination.

The display interface can send data in different "native to hardware" formats, starting from basic u8, through u16 to more specific things like a u9 9 bit "words".

We've currently arrived at an abstraction that uses an associated type with the DI (display interface trait) to specify the data format. So you can do something like where IFACE: DisplayInterface<u8> inside a driver that's meant to use it as such.

This works nicely but the problem is that it forces implementation of the entire driver for a given data format/DI combination. The problem is shown on the playground. You can see in the example that the "non-specific" Driver cannot call a send method that's specific to Driver with a I: Interface<Width = u8>

What would be the best way to design the types here such that drivers can be easily written "generically" (as in single code base for 99% of it) with only specific pieces related to the data format conversion being required.

Also, would it be possible to have this done such that the compiler would reject an incompatible Interface -> Driver combination? Say if the Driver only implements with Interface<Width = u8> but someone tries to pair it up with an interface with u16. Right now this works but it forces the driver to be implemented as specific 100%.

1 Like

You could make a trait for Drivers

pub trait Driver<Width> {
    fn do_some_and_send<I: Interface<Width = Width>>(&self, iface: &I);
}

This way you can remain generic over the width and interface, but you don't need to be boxed into a specific interface.

One thing I forgot to mention, the data format "conversion" needs to be done by the driver crate (think of each of these as separate crates, so DI in one, driver in another, user uses both).

This is because the driver knows how to do it for their device. E.g. say u8 to u16 will use padding in some cases, but specific to if it's data or command etc.

Hmm so if you make a trait for the Driver like you mention how would the actual sending work? (for example in that example)

Say you have a &[u8] data and you need to send it via the interface, which happens to be &[u8] as well in this case for simplicity (as it is in there now), how do you call self.iface.send(data) from the generic driver code?

Maybe something like this. (I've never used/programmed drivers/interfaces directly so I don't know the details of how that works, if I'm missing something important, please let me know). Maybe the trait isn't necessary, you could probably just have the generic method.

edit: (This Driver trait kind of reminds me of Extend for some reason)

One question, does a driver have to know the details of a specific interface? Or can it work with any interface of a given width?

I think this works, so instead of internalizing the interface into the driver type, it'd be passed in and any datatype needed would be implemented separately.

I think that should work.

The driver doesn't need to know interface specifics, that's the idea behind the interface in the first place. The driver knows the target hardware, and technically also all possible data transfer channels (e.g. SPI/I2C/individual pins) and their configurations (e.g. SPI::u8 vs SPI::u16) but it doesn't really "care" so much. It only cares wrt. internal data format -> interface actually used format conversion.

So to answer the question, it doesn't need to know the specific, it doesn't care if an interface is SPI or I2C, but it does care if the interface data format is u8 or u16.

1 Like

Hmm now that I think about it, I'm not sure if it's good enough this way. I can't use a public send_to method from outside the driver. The usage of the interface needs to come from within the driver because the driver is utilized indirectly.

the user doesn't do something like Driver.draw but uses another crate which does something like Primitive::draw(&mut dyn DriverTrait) (the DriverTrait here is unrelated) which then calls the "action initiator" in the driver itself.

In other words the method which starts things in motion in the driver is not changeable here and does not take an interface for an argument. The interface would have to be given to the driver on creation.

Would you be able to take the interface by reference and store a trait object?

struct Driver<'a, Width> {
    iface: &'a dyn Interface<Width = Width>,
}

This way the driver remains agnostic to interface. If not, you could have something like

struct Driver<I> {
    iface: I,
    raw: RawDriver,
}

trait RawDriverImpl<Width: Copy> {
    fn send_to<I: Interface<Width = Width>>(&self, iface: &I);
}

struct RawDriver { ... } // was previously named CustomDriver in playground

impl RawDriverImpl<...> for RawDriver { ... }

impl<I: Interface> Driver<I> where RawDriver: RawDriverImpl<I::Width> {
    pub fn new(iface: I) -> Self {
        Self { iface, raw: ... }
    }

    fn send(&self) {
        self.raw.send_to(&self.iface)
    }
}

Then you can only use a Driver with the specific interface, but if you need to use a different interface you can use the RawDriver.

Well the main thing I'm trying to accomplish is to "limit" the specific codebase.

In other words the main driver struct should be able to do 99% of the code and then have the 1% where it actually talks to specific types of interfaces (as in widths) separate so we don't need to re-make the whole thing for each width.

I was able to get this far on the playground, but it won't do with the final use case, because in the final use case the "action initiator" (do_something()) is actually implemented as a impl OtherCratesTrait for Driver which I guess means we'd have to multiply that by amount of Width types?

(I may have overcomplicated things)
(on a minor note: Copy implies Sized, and Sized is the default, so you don't need to explicitly write it out)

Oh, I think I misunderstood what you wanted. Could you use some common data type that you can convert to any width? Then your driver could do 99% of it's work using this data type and then as the final step, just translate from that to the widths. Without knowing how the Driver actually sends data, I don't think I can help more.

I'm thinking of something like

pub struct Writer<W> { buffer: &'a mut [W] }

impl<'a, W: Copy> Writer<'a, W> {
    pub fn new(buffer: &'a mut [W]) -> Self { Self { buffer } }

    pub fn write_bytes(&mut self, &[u8]) { /* copy over bytes */ }

    pub fn complete(&self) -> &[W] { &self.buffer }
}

(This may not be the best interface for Write, it's just to illustrate the idea). Then you can write your driver like,

struct Driver<I>
where
    I: Interface
{
    pub fn do_something(&mut self) {
        let mut buffer = /* initialize the widths */;
        let mut writer = Writer::new(&mut buffer);

        // maybe something else that actually does something
        writer.write_bytes(&[8u8]);
        
        self.iface.send(writer.complete());
    }
}

This way you only need to know how to create the widths, and what to write. The Writer should handle translation.

Hi @RustyYato, thanks for helping out here, much appreciated.

Maybe it's good to take a step back to what we want to achieve here:
We have three players in the game,

  1. The application, this implements the
    • hardware initialisation, including communication
    • initialisation of the display interface
    • setup and configuration of the display
    • drawing operations
  2. the display interface, this abstracts over the communication (e.g. different serial and parallel busses) and some additional specifcs (like some handling of additional signaling)
  3. the display driver, initialises the display and turns drawing commands from the application into display specific data by calling into the display interface driver.

We do have working examples for all of the above here:

  1. Application using a 8-bit parallel interface: parallel_st7789.rs · GitHub
    Same application using SPI interface: spi_st7789.rs · GitHub
  2. The display driver: GitHub - almindor/st7789: Rust library for displays using the ST7735 driver
  3. The display interface: GitHub - therealprof/display-interface: Rust crates providing a generic interface for display drivers and some default implementations (GPIO, SPI and I2C) (the display interface drivers used in the example are in the workspace members)

The two examples both use the 8-bit versions of the display interface. However the display also supports a 16-bit parallel interface which we'd like to support. And that's where we need to solve the problem because at the moment thanks to the generic DI everything needs to be passed through everywhere so we essentially would need two separate implementation which we'd like to get rid of somehow.

One first work in that direction is to replace the generic width by an associated type: WIP: use associated type for WIDTH by almindor · Pull Request #2 · therealprof/display-interface · GitHub

However it doesn't quite help us all the way through.

1 Like

Thank you for the more detailed problem description, unfortunately I don't have enough time to dedicate to this in order to effectively help you.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.