Skip to content

Conversation

@RobertZ2011
Copy link
Contributor

@RobertZ2011 RobertZ2011 commented Oct 15, 2025

Proof of concept for calling async trait functions directly. This example introduces two device traits (power::Device and io_expander::Device) - which are basic traits for a power policy device and an IO expander device, two simple service implementations that use these traits, and a mock device that implements both device traits. Both service implementations directly call async trait functions without using device IDs or messaging as intermediates.

Notes

  • Events move from the device to each service through per-device channels. This is an implementation detail and everything should be generic over sender and receiver traits.
  • The example does not use StaticCell and everything is declared with normal let _ = ... statements. This is only possible if everything is in the same embassy task. Using multiple embassy tasks will require 'static.
  • The use of an external object for device -> service events breaks circular references between the two. It is possible to have these references so that the device could call directly into the service (e.g. to notify of an event) but this requires that both have the same lifetime. The only lifetime that accomplishes this is 'static.
  • The service implementations have a maximum number of devices that they can support. This is because we need space to store the futures when we select among the event receivers. In theory we could work around this with a channel but this results in a situation where the event has to contain a reference to the device (so we know which device it came from). But the device also has to have a reference to the channel in order to send an event. This results a circular reference that requires 'static to work. Additionally this requires the use of dyn to break a recursive type that arises between the channel and the device.
  • Since we only accept a single device/receiver type there's a corresponding EventSender trait. This is to ensure that device implementations don't come up with their own bespoke EventReceiver types which would all be incompatible with each other.
  • This example doesn't attempt to do any rigorous enforcement of initialization order at compile time, outside of what is provided by using variables. But it should be amenable to the various approaches we've discussed.

@RobertZ2011 RobertZ2011 self-assigned this Oct 15, 2025
fn set_level(&mut self, pin: u8, value: bool) -> impl Future<Output = ()>;
}

pub struct Sender<'channel> {
Copy link
Contributor Author

@RobertZ2011 RobertZ2011 Oct 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The corresponding traits could be made more generic to make these implementations generic over the message type.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered that but to what end? The generic service impl would just no-op? The only purpose I could foresee would be vendor-defined messaging, but in that case, should the vendor simply add a side channel to their own types that implements the sender and receiver traits?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The receiver and sender traits in this example are specialized versions of generic EventReceiver<T> and EventSender<T>. This would allow doing blanket implementations like impl<T> EventReceiver<T> for DynPublisher<T> to simplify development. I would also argue that the message type is the real API here. So different services/devices can share EventReceiver<ConcreteType> instead of creating their own incompatible traits. Though I think we'd also want an Event trait, even if it's just a marker trait.


pub async fn wait_next(&mut self) -> (&'device Mutex<NoopRawMutex, D>, Event) {
let futures =
heapless::Vec::<_, MAX_SUPPORTED_DEVICES>::from_iter(self.devices.iter_mut().map(|(r, _)| r.wait_next()));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Futures can end up arbitrarily large so I'm worried that this could blow the stack and we wouldn't know until runtime. Thoughts on possibly introducing a macro/wrapper type that uses size_of to check the total size of the vec at compile time?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a different risk than when calling any other generic future?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, though I think the risk is more acute here because of the multiplication. But I guess that's a broader discussion to have.

Copy link

@asasine asasine left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good so far!

What do you think of expanding this example to showcase the vendor customization process? I think we can essentially bifurcate into an ODP module and a vendor module. Provide the traits, service impls, and any "default" impls in the ODP mod, provide new types that reuse what's possible but extend behavior in the vendor mod, and a main function that uses the ODP tasks with the vendor types.

Comment on lines +46 to +87
pub struct Sender<'channel> {
publisher: DynImmediatePublisher<'channel, Event>,
}

impl<'channel> Sender<'channel> {
pub fn new(publisher: DynImmediatePublisher<'channel, Event>) -> Self {
Self { publisher }
}
}

pub struct Receiver<'channel> {
subscriber: DynSubscriber<'channel, Event>,
}

impl<'channel> Receiver<'channel> {
pub fn new(subscriber: DynSubscriber<'channel, Event>) -> Self {
Self { subscriber }
}
}

impl EventReceiver for Receiver<'_> {
async fn wait_next(&mut self) -> Event {
loop {
match self.subscriber.next_message().await {
WaitResult::Message(msg) => return msg,
WaitResult::Lagged(n) => {
warn!("Receiver lagged by {n} messages");
}
}
}
}
}

impl EventSender for Sender<'_> {
fn on_plug(&self, power_mw: i32) {
self.publisher.publish_immediate(Event::Plug(NewContract { power_mw }));
}

fn on_unplug(&self) {
self.publisher.publish_immediate(Event::Unplug);
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you actually need a new type, just to implement the trait on the inner itself

Suggested change
pub struct Sender<'channel> {
publisher: DynImmediatePublisher<'channel, Event>,
}
impl<'channel> Sender<'channel> {
pub fn new(publisher: DynImmediatePublisher<'channel, Event>) -> Self {
Self { publisher }
}
}
pub struct Receiver<'channel> {
subscriber: DynSubscriber<'channel, Event>,
}
impl<'channel> Receiver<'channel> {
pub fn new(subscriber: DynSubscriber<'channel, Event>) -> Self {
Self { subscriber }
}
}
impl EventReceiver for Receiver<'_> {
async fn wait_next(&mut self) -> Event {
loop {
match self.subscriber.next_message().await {
WaitResult::Message(msg) => return msg,
WaitResult::Lagged(n) => {
warn!("Receiver lagged by {n} messages");
}
}
}
}
}
impl EventSender for Sender<'_> {
fn on_plug(&self, power_mw: i32) {
self.publisher.publish_immediate(Event::Plug(NewContract { power_mw }));
}
fn on_unplug(&self) {
self.publisher.publish_immediate(Event::Unplug);
}
}
impl EventReceiver for &DynSubscriber<'_, Event> {
async fn wait_next(&mut self) -> Event {
loop {
match self.next_message().await {
WaitResult::Message(msg) => return msg,
WaitResult::Lagged(n) => {
warn!("Receiver lagged by {n} messages");
}
}
}
}
}
impl EventSender for DynImmediatePublisher<'_, Event> {
fn on_plug(&self, power_mw: i32) {
self.publish_immediate(Event::Plug(NewContract { power_mw }));
}
fn on_unplug(&self) {
self.publish_immediate(Event::Unplug);
}
}

devices: &'storage mut [(R, &'device Mutex<NoopRawMutex, D>)],
}

const MAX_SUPPORTED_DEVICES: usize = 4;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that ServiceImplementation should be generic on const N: usize

fn set_level(&mut self, pin: u8, value: bool) -> impl Future<Output = ()>;
}

pub struct Sender<'channel> {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered that but to what end? The generic service impl would just no-op? The only purpose I could foresee would be vendor-defined messaging, but in that case, should the vendor simply add a side channel to their own types that implements the sender and receiver traits?


pub async fn wait_next(&mut self) -> (&'device Mutex<NoopRawMutex, D>, Event) {
let futures =
heapless::Vec::<_, MAX_SUPPORTED_DEVICES>::from_iter(self.devices.iter_mut().map(|(r, _)| r.wait_next()));
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a different risk than when calling any other generic future?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants