Hierarchical statechart codegen for Rust with: - during: activities for state-scoped concurrent I/O - field-list borrowing (split-borrow inside the run loop) - embassy-first async with tokio support - STATE_CHART const for tooling/inspection - examples: microwave, microwave_tui, embassy_full, during_radio
30 KiB
hsmc
Change Request: Hierarchical State-Scoped Context
with the during: Activity Model
| Status | DRAFT — Checkpoint for review |
|---|---|
| Scope | Proc-macro codegen, runtime run-loop, DevUX surface |
| Affects | statechart! macro, Machine::run(), handler signatures |
| Audience | hsmc core contributors, firmware team |
| First Principle The DevUX needs to be EXTREMELY SIMPLE and CLEAR. Users get perfect, near "zero cost abstraction" level performance, while ALSO getting a beautiful DevUX that produces the "read a statechart like plain English" interface. Every design decision in this document is evaluated against this principle first. |
|---|
1. Executive Summary
This change request proposes two additions to hsmc:
-
State-scoped context: any state may declare a context: struct that is constructed on state entry and dropped on state exit. Each level of the hierarchy owns its own data independently.
-
The during: keyword: a state-scoped asynchronous activity that repeatedly produces events while the state is active. During functions receive exclusive access to their state’s local context. Handlers receive exclusive access to the root context and read-only access to all other active contexts on the state path.
Together these features enable the "one-line task body" goal for all three dogfood consumers:
let m = Radio::new(ctx);
m.run().await; // handles state-scoped I/O, timers, everything
The design uses no interior mutability visible to the user, no runtime locks, no RefCell, and no panic paths. Under the hood, the macro uses UnsafeCell with a soundness proof that is trivial on a single-threaded cooperative executor: only one thing runs at a time, and the macro controls every access point.
2. DevUX Analysis: Why This Matters More Than the Mechanics
Before diving into implementation, it is worth dwelling on why this particular shape was chosen. The mechanics exist to serve the developer experience, not the other way around.
2.1 What the User Writes Today (Before)
A firmware engineer writing the LoRa radio task currently produces something like this:
#[embassy_executor::task]
async fn radio_task(lora: LoRaDriver, commands: Receiver<RadioInput>) {
let ctx \= RadioCtx { lora, config: None, rx\_count: 0, .. };
let mut m \= Radio::new\_local(ctx);
loop {
match m.current\_state() {
State::Idle \=\> {
let cmd \= commands.receive().await;
push(\&mut m, cmd).await; // hand-rolled drain helper
}
State::Receiving \=\> {
match select(lora.rx(), commands.receive()).await {
Either::First(pkt) \=\> {
push(\&mut m, RadioInput::PacketReceived { .. }).await;
}
Either::Second(cmd) \=\> {
push(\&mut m, cmd).await;
}
}
}
// ... more arms ...
}
}
}
This is 30+ lines of imperative control flow that duplicates what the statechart already knows. Every state that involves I/O requires a match arm. The user must remember to drain events. The declarative statechart becomes a lie — the real behavior lives in the imperative loop.
2.2 What the User Writes After
The same task, with this change request applied:
#[embassy_executor::task]
async fn radio_task(lora: LoRaDriver) {
let ctx \= RadioCtx { rx\_count: 0, config: None, .. };
let m \= Radio::new(ctx);
m.run().await;
}
Five lines. The statechart declaration is the single source of truth for behavior. The imperative outer loop, the drain helper, and the match-on-state are all gone.
2.3 Where Did the I/O Logic Go?
Into the statechart declaration and two small functions:
state Receiving {
context: ReceivingCtx;
during: next\_packet;
on(PacketReceived { rssi, data }) \=\> handle\_packet;
on(StopRx) \=\> Idle;
}
async fn next_packet(local: &mut ReceivingCtx) -> RadioInput {
match local.lora.rx(\&mut local.buf).await {
Ok(pkt) \=\> RadioInput::PacketReceived { rssi: pkt.rssi, .. },
Err(e) \=\> RadioInput::RxFailed(e),
}
}
async fn handle_packet(ctx: &mut RadioCtx, rssi: i16, data: Vec<u8, 256>) {
ctx.rx\_count \+= 1;
ctx.last\_rssi \= rssi;
}
The developer never thinks about borrow conflicts, drain loops, or select-driven dispatch. They think about: "while in Receiving, produce packets" and "when a packet arrives, count it." The statechart reads as English. The functions are plain Rust.
2.4 DevUX Principles Scorecard
| Principle | Met? | Notes |
|---|---|---|
| One context: slot per state | Yes | Root has context:, states optionally add their own |
| User writes a plain struct | Yes | No Cell, RefCell, Mutex, or attributes required |
| Idiomatic &mut T / &T access | Yes | during: gets &mut Local; handlers get &mut Root, & ancestors |
| Async-first (embassy / tokio) | Yes | All handlers and during: functions are async fn |
| One m.run().await call | Yes | Works for Display, Radio, and Host |
| Declarative block reads as English | Yes | "during: next_packet" in state Receiving reads naturally |
| No user-visible footguns | Yes | No runtime panics; soundness proven at compile time by macro |
| Zero runtime cost | ~Yes | UnsafeCell is zero-cost; minor overhead from select in run loop |
2.5 Concepts the User Must Learn
The cost of any feature is what you must teach. This change introduces exactly two new concepts:
-
context: on a state — "this state has its own data, constructed when entering, dropped when exiting." Existing users already understand entry:/exit: lifecycle; this is a natural extension.
-
during: — "while in this state, repeatedly call this function to produce events." The mental model is Iterator::next() but async. Call it, get an event, process the event, call it again. If the state exits, the in-flight call is dropped.
Concepts the user does NOT need to learn: UnsafeCell, partial borrows, interior mutability, cancel-safety (beyond the standard embassy contract), or any new Rust language features.
3. Before: Current hsmc Architecture
3.1 Statechart Surface
The declarative surface is unchanged by this proposal. For reference:
statechart! {
Radio {
context: RadioCtx;
events: RadioInput;
default(Unconfigured);
state Unconfigured {
on(ConfigSet(cfg)) \=\> apply\_config, Configured;
}
state Configured {
default(Idle);
state Idle { on(StartRx) \=\> Receiving; }
state Receiving {
entry: announce\_rx;
on(PacketReceived { rssi, snr }) \=\> handle\_packet;
on(StopRx) \=\> Idle;
}
}
}
}
3.2 Procedural Surface (The Problem)
Two issues exist in the current procedural API:
3.2.1 The Drain Papercut
Machine::send(event) enqueues but does not process. Every user independently writes the same drain helper:
async fn push<E>(m: &mut M, ev: E) {
m.send(ev).expect("queue overflow");
while m.has\_pending\_events() {
m.step(Duration::ZERO).await;
}
}
Three copies of this exist in the dogfood firmware. This is a bug, not a design choice. Fix: make send() drain internally. This portion of the change is uncontroversial.
3.2.2 No State-Scoped I/O
Machine::run().await works for pure-event machines (Display) but not for machines whose I/O depends on current state (Radio, Host). These consumers must write imperative outer loops using match machine.current_state(), duplicating the statechart’s own state knowledge in user code.
4. After: Proposed Design
4.1 Feature 1 — State-Scoped Context
Any state may declare a context: type. This type is constructed on state entry and dropped on state exit. Construction receives a read-only reference to the parent state’s context (or the root context if the state is a top-level state).
4.1.1 Declaration
state Receiving {
context: ReceivingCtx;
entry: announce\_rx;
// ...
}
4.1.2 Context Struct and Construction
struct ReceivingCtx {
lora: LoRaDriver,
buf: \[u8; 256\],
}
impl ReceivingCtx {
fn new(parent: \&ConfiguredCtx) \-\> Self {
Self {
lora: take\_lora(),
buf: \[0; 256\],
}
}
}
If no context: is declared, the state has no local data. This is the common case for simple states.
4.1.3 Lifecycle
-
State entered: LocalCtx::new(&parent_ctx) is called. Then entry: actions run.
-
State active: during: function (if any) has &mut LocalCtx. Handlers can read &LocalCtx.
-
State exited: exit: actions run. Then LocalCtx is dropped (Drop trait runs if implemented).
-
Nested entry order: parent context constructed first, then child. Exit order is reversed (child dropped first, then parent), like stack unwinding.
4.1.4 Hierarchy Example
struct RadioCtx { rx_count: u32, config: Option<RadioConfig> }
struct ConfiguredCtx { validated_config: RadioConfig }
struct ReceivingCtx { lora: LoRaDriver, buf: [u8; 256] }
struct ScanningCtx { rssi_samples: [i16; 8], sample_idx: usize }
statechart! {
Radio {
context: RadioCtx;
state Configured {
context: ConfiguredCtx;
state Receiving {
context: ReceivingCtx;
state Scanning {
context: ScanningCtx;
}
}
}
}
}
When machine is in Configured > Receiving > Scanning, four context structs are alive simultaneously, each independently owned by its state.
4.2 Feature 2 — during: Activities
4.2.1 Declaration
state Receiving {
context: ReceivingCtx;
during: next\_packet;
on(PacketReceived { rssi, data }) \=\> handle\_packet;
on(StopRx) \=\> Idle;
}
Reads as: "During Receiving, run next_packet. When it produces a PacketReceived event, call handle_packet."
4.2.2 During Function Signature
// Takes &mut of its own state's context.
// Returns a single event.
// Called repeatedly while the state is active.
async fn next_packet(local: &mut ReceivingCtx) -> RadioInput {
match local.lora.rx(\&mut local.buf).await {
Ok(pkt) \=\> RadioInput::PacketReceived {
rssi: pkt.rssi,
snr: pkt.snr,
data: pkt.data,
},
Err(e) \=\> RadioInput::RxFailed(e),
}
}
4.2.3 Handler Signature
// Handlers get &mut of the root context.
// They also get & (read-only) of any ancestor context on the active path.
// They run AFTER the during: future is suspended or dropped.
async fn handle_packet(
ctx: \&mut RadioCtx, // root, always \&mut
receiving: \&ReceivingCtx, // ancestor on active path, read-only
rssi: i16,
data: Vec\<u8, 256\>,
) {
ctx.rx\_count \+= 1;
ctx.last\_rssi \= rssi;
defmt::info\!("ch={}", receiving.lora.current\_channel());
}
4.2.4 Execution Model
The generated run() loop operates as follows:
-
1. Collect all active during: functions on the current state path (one per state level that declares one).
-
2. Race all of them against the external event channel and the next timer deadline using select.
-
3. When any branch wins, suspend all others (they remain parked at their .await points).
-
4. If a during: function returned an event, or an external event arrived, or a timer fired: dispatch the event through the normal handler chain.
-
5. Handlers run with &mut root context and & of all ancestor contexts. This is safe because all during: futures are either dropped (the one that produced the event) or parked (all others). On a single-threaded cooperative executor, parked means unreachable.
-
6. After handler dispatch completes, respawn the during: that was dropped (if the state hasn’t changed), and loop back to step 2.
4.2.5 Multiple during: Functions on the Same Active Path
This is the hierarchical case and the primary reason state-scoped context exists:
state Configured {
context: ConfiguredCtx;
during: monitor\_battery; // \&mut ConfiguredCtx
state Receiving {
context: ReceivingCtx;
during: next\_packet; // \&mut ReceivingCtx
}
}
Both during: functions run concurrently (via select in the run loop). No borrow conflict because they hold &mut of different structs. Each state’s context is an independent island of ownership.
| Key Insight State-scoped context is not just an organizational convenience. It is the mechanism that makes concurrent during: functions possible without interior mutability. By giving each state its own data, the borrow checker sees independent borrows on independent values — no conflict, no locks, no runtime checks. |
|---|
4.2.6 Multiple I/O Sources Within a Single State
A single state cannot declare multiple during: entries. If a state needs to multiplex I/O sources, the developer uses select inside one during: function:
state Connected {
context: HostLocal;
during: next\_host\_event;
}
async fn next_host_event(local: &mut HostLocal) -> HostInput {
use embassy\_futures::select::{select3, Either3};
match select3(
local.usb.read\_packet(\&mut local.buf),
local.responses.receive(),
Timer::after\_millis(250),
).await {
Either3::First(Ok(n)) \=\> HostInput::UsbPacket(..),
Either3::Second(resp) \=\> HostInput::ResponseReady(resp),
Either3::Third(\_) \=\> HostInput::Tick,
}
}
This is the standard embassy pattern. Developers in the target audience already know it. The during: keyword simply gives it a declarative home.
5. Access Rules
The access model can be stated in three sentences:
| Access Rules 1. A during: function gets &mut of its own state’s context. Nothing else.2. A handler (entry:, exit:, on() action) gets &mut of the root context and & of every other context on the active state path.3. A context’s new() constructor gets & of its parent’s context. |
|---|
5.1 Why These Rules Are Sound
On a single-threaded cooperative executor (embassy, or tokio’s LocalSet):
-
Only one piece of code runs at a time. There is no true parallelism.
-
A during: function holds &mut LocalCtx across .await points, but while it is parked at .await, no code is executing through that reference. It is frozen inside a suspended future.
-
When an event is dispatched, the during: function is either dropped (it returned the event) or parked (a different branch of select won). Either way, no code is actively using its &mut borrow.
-
The macro controls every call site. No user code can obtain a raw reference to the UnsafeCell-wrapped storage. The only way to access a state’s context is through a macro-generated during: call or handler call, both of which enforce the rules above.
5.2 Walk-Through: Three Levels Deep
Machine is in Configured > Receiving > Scanning. Three during: functions are in the select race:
monitor_battery holds &mut ConfiguredCtx [parked at .await]
next_packet holds &mut ReceivingCtx [parked at .await]
scan_channels holds &mut ScanningCtx [parked at .await]
scan_channels wakes up and returns ScanComplete:
-
scan_channels future is dropped. Its &mut ScanningCtx borrow is released.
-
monitor_battery and next_packet remain parked. Their &mut borrows exist inside frozen futures but are unreachable.
-
Handler runs with: &mut RadioCtx (root), &ConfiguredCtx (parked during: holds &mut, but parked = unreachable), &ReceivingCtx (same reasoning), &ScanningCtx (during: was dropped).
-
Handler returns. Machine respawns scan_channels with fresh &mut ScanningCtx. Loop continues.
5.3 What About ISRs?
Interrupt service routines never touch statechart context. In embassy’s model, ISRs set waker flags and return. The during: function’s .await point resumes in task context on the single thread. If a developer needs to share data between a raw ISR and a statechart, they use atomics or critical sections outside hsmc, exactly as they would without hsmc.
Embassy’s InterruptExecutor (multiple priority levels) introduces real preemption, but only between executors. A single statechart runs on a single executor. The access model holds.
6. Complete Before/After: Radio Task
6.1 Before — Current Firmware
struct RadioCtx {
lora: LoRaDriver, // peripheral lives in shared context
config: Option\<RadioConfig\>,
rx\_count: u32,
tx\_count: u32,
last\_rssi: i16,
buf: \[u8; 256\],
}
statechart! {
Radio {
context: RadioCtx;
events: RadioInput;
default(Unconfigured);
state Unconfigured {
on(ConfigSet(cfg)) \=\> apply\_config, Configured;
}
state Configured {
default(Idle);
state Idle {
entry: radio\_idle;
on(StartRx) \=\> Receiving;
}
state Receiving {
entry: announce\_rx;
on(PacketReceived { rssi, snr }) \=\> handle\_packet;
on(StopRx) \=\> Idle;
}
}
}
}
// Imperative outer loop (30+ lines of boilerplate)
#[embassy_executor::task]
async fn radio_task(lora: LoRaDriver, cmds: Receiver<RadioInput>) {
let ctx \= RadioCtx { lora, config: None, rx\_count: 0,
tx\_count: 0, last\_rssi: 0, buf: \[0; 256\] };
let mut m \= Radio::new\_local(ctx);
loop {
match m.current\_state() {
State::Idle \=\> {
let cmd \= cmds.receive().await;
m.send(cmd).expect("overflow");
while m.has\_pending\_events() {
m.step(Duration::ZERO).await;
}
}
State::Receiving \=\> {
let ctx \= m.context\_mut();
match select(
ctx.lora.rx(\&mut ctx.buf),
cmds.receive()
).await {
Either::First(Ok(pkt)) \=\> {
m.send(RadioInput::PacketReceived {
rssi: pkt.rssi, snr: pkt.snr
}).expect("overflow");
while m.has\_pending\_events() {
m.step(Duration::ZERO).await;
}
}
Either::Second(cmd) \=\> { /\* same drain \*/ }
Either::First(Err(\_)) \=\> { /\* same drain \*/ }
}
}
\_ \=\> { /\* drain helper again \*/ }
}
}
}
6.2 After — Proposed
// Root context: bookkeeping only
struct RadioCtx {
config: Option\<RadioConfig\>,
rx\_count: u32,
tx\_count: u32,
last\_rssi: i16,
}
// State-scoped: peripherals and buffers live here
struct ReceivingCtx {
lora: LoRaDriver,
buf: \[u8; 256\],
}
impl ReceivingCtx {
fn new(parent: \&RadioCtx) \-\> Self {
Self { lora: take\_lora(), buf: \[0; 256\] }
}
}
struct TransmittingCtx {
lora: LoRaDriver,
}
impl TransmittingCtx {
fn new(parent: \&RadioCtx) \-\> Self {
Self { lora: take\_lora() }
}
}
statechart! {
Radio {
context: RadioCtx;
events: RadioInput;
default(Unconfigured);
state Unconfigured {
on(ConfigSet(cfg)) \=\> apply\_config, Configured;
}
state Configured {
on(every Duration::from\_secs(30)) \=\> heartbeat;
default(Idle);
state Idle {
entry: radio\_idle;
on(StartRx) \=\> Receiving;
on(Transmit(pkt)) \=\> begin\_tx, Transmitting;
}
state Receiving {
context: ReceivingCtx;
during: next\_packet;
on(PacketReceived { rssi, snr, data }) \=\> handle\_packet;
on(RxFailed(err)) \=\> handle\_rx\_error, Idle;
on(StopRx) \=\> Idle;
}
state Transmitting {
context: TransmittingCtx;
during: await\_tx\_done;
on(TxDone) \=\> handle\_tx\_done, Idle;
on(TxFailed(err)) \=\> handle\_tx\_error, Idle;
}
}
}
}
// during: functions — each gets &mut of its own state's context
async fn next_packet(local: &mut ReceivingCtx) -> RadioInput {
match local.lora.rx(\&mut local.buf).await {
Ok(pkt) \=\> RadioInput::PacketReceived {
rssi: pkt.rssi, snr: pkt.snr, data: pkt.data,
},
Err(e) \=\> RadioInput::RxFailed(e),
}
}
async fn await_tx_done(local: &mut TransmittingCtx) -> RadioInput {
match local.lora.wait\_tx\_complete().await {
Ok(()) \=\> RadioInput::TxDone,
Err(e) \=\> RadioInput::TxFailed(e),
}
}
// Handlers — &mut root, & ancestors
async fn apply_config(ctx: &mut RadioCtx, cfg: RadioConfig) {
ctx.config \= Some(cfg);
}
async fn handle_packet(
ctx: \&mut RadioCtx,
receiving: \&ReceivingCtx,
rssi: i16, snr: i16, data: Vec\<u8, 256\>,
) {
ctx.rx\_count \+= 1;
ctx.last\_rssi \= rssi;
}
async fn heartbeat(ctx: &mut RadioCtx) {
defmt::trace\!("alive, {} pkts", ctx.rx\_count);
}
// Task body — the entire procedural surface
#[embassy_executor::task]
async fn radio_task(lora: LoRaDriver) {
let ctx \= RadioCtx {
config: None,
rx\_count: 0,
tx\_count: 0,
last\_rssi: 0,
};
let m \= Radio::new(ctx);
m.run().await;
}
7. Implementation Notes
7.1 Macro Codegen
The statechart! macro generates a StateStorage struct containing the root context and Option-wrapped UnsafeCell for each state that declares a context:. The macro also generates:
-
Entry sequences that call LocalCtx::new(&parent_ctx), store the result, then call entry: actions.
-
Exit sequences that call exit: actions, then drop the local context (set Option to None).
-
A run() method containing the select-based event loop described in Section 4.2.4.
-
Handler wrappers that extract the correct references (&mut root, & ancestors) from StateStorage using UnsafeCell::get() and pass them to user functions.
7.2 Soundness Argument
The macro emits unsafe code to obtain references from UnsafeCell. The soundness argument has four premises:
-
P1. Single-threaded executor: the machine runs on one thread. No data races are possible.
-
P2. Cooperative scheduling: futures only yield at .await points. Between .await points, execution is sequential and uninterruptible.
-
P3. Macro-controlled access: the only code that obtains references to context storage is generated by the macro. User functions receive references as parameters; they cannot fabricate new references to other contexts.
-
P4. Select semantics: when one branch of select completes, all other branches are either dropped or remain parked. A parked future’s captured &mut is inside a frozen state machine and is unreachable by any running code.
From P1–P4: at any instant, at most one &mut reference to each context is reachable by running code. The UnsafeCell usage satisfies Rust’s aliasing requirements.
7.3 Cancel-Safety
During functions may be dropped at any .await point when an external event arrives or when the state exits. This is the standard embassy contract. The documentation must emphasize:
-
During functions must not leave hardware in an inconsistent state across .await points. If the peripheral requires cleanup, implement Drop on the state’s context struct.
-
Synchronous mutations before the first .await are committed even if the future is later dropped. This is normal Rust behavior but worth calling out.
7.4 Multi-Threaded Executors (tokio)
Under the tokio feature, the machine must be constrained to a single-threaded context (tokio::task::LocalSet) or the macro must use real synchronization primitives (tokio::sync::RwLock) instead of UnsafeCell. The recommended approach is LocalSet for v1, with real-lock support as a v2 option.
7.5 Peripheral Ownership
The new() constructor for state-scoped contexts must obtain peripheral handles. Several patterns are possible:
-
Static singleton via a take function (take_lora()), common in embedded HALs.
-
Option::take() from the root context (the root holds Option<LoRaDriver>, Receiving takes it on entry, returns it on exit via Drop).
-
Passed via a peripheral pool or registry external to the statechart.
This proposal does not mandate a specific pattern. The new(&parent) signature is sufficient to support all of them.
8. Open Questions and Acknowledged Rough Edges
This design is a checkpoint, not a final answer. The following items need further exploration:
8.1 Peripheral Shuttle Between States
When LoRaDriver moves from ReceivingCtx to TransmittingCtx (via Idle), where does it live during the transition? Option::take() from root context works but forces the root to hold Option<LoRaDriver>, which is mild clutter. Is there a cleaner peripheral lifecycle pattern?
8.2 Handler Access to Local Context
Handlers get & (read-only) of state contexts on the active path. Is there a legitimate case where a handler needs &mut of a non-root context? If so, the during: at that level would need to be fully dropped and respawned for that event, which adds latency and complexity.
8.3 The new() Convention
Requiring a new(&ParentCtx) -> Self constructor is simple but opaque. Should the macro instead accept an init: action that returns the context (re-introducing the keyword that was removed earlier)? Or should context construction happen inside entry: actions? The current proposal chooses new() for minimal syntax, but this may feel like hidden magic.
8.4 Testing
State-scoped contexts that own peripherals complicate unit testing. The existing step(Duration) test interface works for pure-event machines. Machines with during: functions need a way to inject mock peripherals through the context construction path. This likely means making context types generic over a peripheral trait, which adds complexity to struct definitions.
8.5 Does the Declarative Block Still Read as English?
The extended declaration for a state with context and during reads:
state Receiving {
context: ReceivingCtx;
during: next\_packet;
on(PacketReceived { rssi, data }) \=\> handle\_packet;
on(StopRx) \=\> Idle;
}
This is two lines longer than a state without I/O. The keywords read naturally: "state Receiving has context ReceivingCtx; during Receiving, run next_packet." But does the additional structure start to obscure the statechart’s topology for a reader who just wants to understand the state graph? Feedback requested.
8.6 Is This Just Path B With Better Names?
An honest concern. The original RFC rejected Path B ("split context + resources") as "too many concepts." This proposal introduces per-state context structs, which is arguably more concepts, not fewer. The counter-argument is that per-state context is semantically motivated by the state hierarchy (each state owns its data for its lifetime), whereas Path B’s flat "context vs resources" split was semantically arbitrary. Whether this distinction is meaningful to users, or is a rationalization, is an open question.
9. Migration Path
9.1 Phase 1: Drain-on-Send (Ship Immediately)
-
send() drains to quiescence internally.
-
Firmware migration: delete three copies of the drain helper, replace with machine.send(ev).await.
-
No breaking changes to existing statechart declarations.
9.2 Phase 2: State-Scoped Context + during: (This Proposal)
-
Existing statecharts without context: or during: are unaffected. Zero migration required.
-
display.rs: unchanged. Can optionally switch from new_local() to new() + run().await for consistency.
-
radio.rs: move LoRaDriver and buf into ReceivingCtx. Move bookkeeping (rx_count, etc.) into root RadioCtx. Add context: and during: to Receiving and Transmitting states. Delete the imperative outer loop entirely.
-
host/usb.rs: add HostLocal context holding USB handle, response channel, and buffer. Add during: next_host_event with select3 inside. Delete the imperative outer loop.
9.3 Backward Compatibility
new_local() and the step/send/current_state procedural API remain available for users who prefer or require imperative control. The during: feature is purely additive.
10. Recommendation
This document represents a checkpoint in an ongoing design iteration. The authors are not yet fully confident in this approach. Specific areas of discomfort include the new() construction convention, the peripheral ownership lifecycle, and whether per-state contexts introduce too much structural overhead for simple statecharts.
However, the core insight — that hierarchical state-scoped data ownership eliminates borrow conflicts between during: functions without interior mutability visible to the user — appears sound and is worth validating with a prototype implementation.
Recommended next steps:
-
Ship Phase 1 (drain-on-send) immediately. This is uncontroversial and immediately valuable.
-
Prototype the during: + state context model against the radio.rs dogfood consumer. If the prototype surfaces new DevUX problems (especially around peripheral lifecycle), iterate on the design before committing.
-
Collect feedback from at least two firmware engineers outside the core team on whether the extended statechart declaration (with context: and during:) remains readable.
-
Defer multi-threaded executor support (real RwLock under tokio) to a later phase. Single-threaded embassy is the primary target.