Prototyping a Software Defined Vehicle - Stage II

5 minute read Published: 2026-05-28

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 II arrangement — emulator, gateway, front-headlamp actuator on vcan0

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 VehicleContextper-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 ledgerDiagnostic log
Deliverybounded, lossless-or-errorunbounded, best-effort
Orderingtotal, one sequence per eventnone guaranteed
Cadenceone record per FSM eventmany sources (init, ticks, failures, meta)
Audiencereplay, audit, offline checksconsole / 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. OffOnRequested — 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:

BoundaryMechanismEnter / exit (simplified)
Lux → headlampvalue deadbandON at lux ≤ 840; OFF at lux ≥ 860; hold in (840, 860)
Speed → ExtremeOperationWarningtemporal latchenter 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:

  1. 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/Off settles.
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));
}
  1. Logging on the hot path. Synchronous println in the gateway and actuator could stall CAN handling under console back-pressure. Both now use bounded side-channels and non-blocking try_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


Series: Prototyping a Software Defined Vehicle

← Previous Stage I

All posts in this series: sdv-prototype