2026-06-09
The asker is designing a tiny LED-flash device modeled as a state machine (Idle / Flashing) driven by two commands (Flash / AbortFlash). The real question isn't the toy problem — it's where the seam goes between Embassy (async runtime, executors, Timer, Signal, GPIO futures) and the pure domain logic. They want unit-testable business rules on a host machine, with Embassy as a swappable shell.
Why this is interesting: Embedded Rust tends to grow Embassy types into every signature — async fn returning a future tied to a specific executor, Output<'d, AnyPin> in struct fields, Timer::after() sprinkled through logic. Once that happens, you can't compile the core for cfg(test) on x86, and the state machine becomes inseparable from PAC and HAL. This is the embedded version of the hexagonal-architecture / ports-and-adapters problem, but with extra constraints: no heap, no dyn Trait object-safety pain, and async traits that until recently required nightly.
A workable approach:
fn step(&mut self, event: Event, now: Instant) -> Vec<Effect, N> (using heapless). No futures, no GPIO, no Embassy. Events are ButtonPressed, TimerElapsed; effects are SetLed(bool), StartTimer(Duration), CancelTimer.Channel or select of button/timer futures, translates them into Events, calls step, and dispatches Effects back to hardware.trait Clock { async fn sleep(&self, d: Duration); }) with Rust 1.75+ AFIT, and implement them with Embassy on-device and with tokio or a fake clock in tests.Gotchas:
CancelTimer and StartTimer are emitted in the same step, the adapter must apply them in order.OutputPin behind dyn; embedded-hal traits exist for this, generics keep zero-cost.Signal in the core — it's a synchronization primitive, not a domain concept.