Preface
The preceding stage brought us here — three processes on one CAN bus, a gateway that owns the digital twin, and a body ECU that closes the headlamp loop with ACK/NACK:
Stage I made the control loop work on the wire. Stage II makes the twin decomposable, observable, and provable — without changing what we see when we run the demo. Both the stages produce the same stdout: same FSM transitions, headlamp cycles, buzzer at speed limits. We pay down structural debt so the next iteration can give each assembly its own actor without a rewrite.
What actually changed (and what did not)
Unchanged: three processes on vcan0, the same wire IDs and FSM states, the rule that the twin emits intent not raw I/O.
Changed underneath: flat VehicleContext → per-assembly contexts; monolithic step.rs → thin orchestrator; transition records → portable audit ledger; invariants → enforce / announce / detect; twin → private fields and a single FSM mutator; live-run fixes for silent ACK success and logging off the protocol path.
Details, wire tables, and file paths live in the Iteration 2 README.
Design decision 1 — Decompose state to mirror the car's zones
Iteration 1 had one flat context and a ~238-line step.rs. Iteration 2 introduces assemblies — each owns its data and the rules over it:
pub struct VehicleContext {
pub powertrain: PowertrainContext,
pub health: VehicleHealthContext,
pub visibility: VisibilityContext,
pub headlamp: HeadlampContext,
}
step decides when to call each assembly; the assembly decides how. A real SDV is not one blob — powertrain, lighting, and health sit in different zones. This is groundwork for each assembly having its own actor, not a monolithic twin holding everything.
Design decision 2 — Two observation streams, two jobs
| Transition ledger | Diagnostic log | |
|---|---|---|
| Delivery | bounded, lossless-or-error | unbounded, best-effort |
| Ordering | total, one sequence per event | none guaranteed |
| Cadence | one record per FSM event | many sources (init, ticks, failures, meta) |
| Audience | replay, audit, offline checks | console / logs |
The twin writes through small sink interfaces. We keep the streams separate because diagnostics cannot be reconstructed from the ledger alone — and when each assembly has its own actor, that split matters even more.
Design decision 3 — step coordinates; the actor owns the turn
One mailbox message → one pure decision → persist → record → execute side effects → only then the next event. Actions are intended outputs from step, not a second mutation path:
let result = fsm::step(state, ctx, &evt, now);
runtime_state.twin_car.apply_step(result.next_state, result.modified_ctx);
Self::try_emit_transition_record(..., result.transition_record);
for action in result.actions { /* execute — immutable twin ref */ }
The ledger captures what we decided before actuation runs — not whether it succeeded.
Doesn't RequestFrontHeadlampOn change state immediately? The lighting state changes inside step() — e.g. Off → OnRequested — and the action is the outbound command. The lamp is not On until an ACK arrives in a later step(). Executing the action sends CAN traffic; it does not mutate the twin. Side effects cannot re-enter step() in the same turn; ACK/NACK comes back as a new ingress event.
Design decision 4 — Time, correctness, and control policy
Two clocks. Monotonic time inside the FSM; wall-clock timestamps in the serializable mirror for offline replay.
Two sequence numbers — ledger position (every FSM event) vs command position (each correlated headlamp command on CAN). Snapshots return the twin plus a sequence stamp; there is no mutating "refresh" API.
Enforce / announce / detect. Clamp in the FSM; warnings on the diagnostic log, not the actuation path; named state laws checked offline over a captured ledger — never on the live hot path. The twin type has private fields and a single apply_step(...) mutator.
Anti-flap — both guards live in the twin; the emulator only sends raw telemetry:
| Boundary | Mechanism | Enter / exit (simplified) |
|---|---|---|
| Lux → headlamp | value deadband | ON at lux ≤ 840; OFF at lux ≥ 860; hold in (840, 860) |
Speed → ExtremeOperationWarning | temporal latch | enter at speed > 160; leave only after ≥ 5 s dwell AND hazard cleared |
Lux wobbles in our emulator, so a deadband stops relay-style flipping. Speed warnings latch until the hazard clears and five seconds have passed — a minimum dwell, not an auto-clear timer. Wire ID 0x101 (observed speed) is decoded but not fed to the twin today; speed comes from RPM until a future ECU path lands.
Lessons from a live run
Pressing Ctrl-S / Ctrl-Q froze stdout (XOFF/XON) and surfaced two gaps:
- Silent success. A clean headlamp ACK had no human-visible line; the ledger already recorded the context change. The actor now compares headlamp state before and after
step()and emits a confirmation when*Requested → On/Offsettles.
let headlamp_before = runtime_state.twin_car.context().headlamp.state;
let result = fsm::step(...);
let headlamp_after = result.modified_ctx.headlamp.state;
// ... persist, record, execute ...
if let Some(direction) = front_headlamp_confirmed_direction(headlamp_before, headlamp_after) {
sink.try_emit(diag_front_headlamp_confirmed(identity, direction));
}
- Logging on the hot path. Synchronous
printlnin the gateway and actuator could stall CAN handling under console back-pressure. Both now use bounded side-channels and non-blockingtry_send(drop-on-full); the gateway delivers the ACK to the twin before logging.
What comes next
Each assembly having its own actor moves to a fresh cloned project: parent FSM actor, child actors per assembly, a single-writer ledger, correlation IDs end-to-end, diagnostics derived from the ledger, and an actuation child for buzzer/egress I/O. See the roadmap.
Summary
Stage III is a refactor-and-contracts milestone: same demo, stronger internals — assemblies, a designed observation split, a strict turn contract, explicit anti-flap policy, and live-run hardening. The next stage splits the monolith along the assembly seams we drew here.
Where the code is
- Repository:
sdv_simulation_2on GitHub — builds onsdv_simulation_1. - Operational reference: project README (crate layout, wire IDs, tests, flags, demo assets).
- This post is the narrative; the README on
mainis the current truth for running and extending the prototype.
Series: Prototyping a Software Defined Vehicle
← Previous Stage I
All posts in this series: sdv-prototype