ODIN Support + Vehicle sim refactor (and 2020 CAN messages) #11
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/odin-support"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Runnable-now 521 -> 529/661 (80%). Reuses the existing UDS stack rather than re-implementing: adds UdsSession.read_dtcs(status_mask) mirroring clear_dtc (reportDTCByStatusMask 0x19 02 -> {dtc_code: status} dict, empty on a healthy ECU), wired via _UdsAdapter.read_dtcs; uds.UdsDTCMaskRepr renders a status byte. +3 tests (real UdsSession.read_dtcs parse via __new__ + monkeypatched _send_raw; Engine handler). Full suite green (2445), ruff clean. NOTE: client.py in this commit also carries 3 pre-existing local debug-log lines (TX/RX hex in _send_raw) that were already uncommitted in the working tree; git can't split hunks within a file non-interactively here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Select which peer ECUs to simulate vs which are present on the bus for real: --real/--provided NODES do not simulate these (real HW or another process owns them) --sim NODES simulate only these (whitelist) --profile {full-car,di-bench} --list-nodes print the inventory (names + IDs + bus) Legacy --no-shifter/--no-gtw/--no-ui become thin aliases for --real SCCM/GTW/UI (byte-identical output verified); --no-das/--no-221 stay ID-level drops (they remove one frame of a node) and --no-party stays a bus filter. MIA-aggregate coverage warning: firmware OR-aggregates (vcfront/esp/ibst/epas3p/rcm/ gtw/ui) clear only when ALL members arrive; sim_registry.mia_coverage_warnings flags a partially-covered aggregate up front. IDs owned by a --real node or handed off via a profile count as covered, so di-bench raises no false alarm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Generalize the shared bench engine from single-bus to multi-bus, additively, so vehicle_sim can fold onto it (Phase 3b) without a second runtime: - Frame.bus ("vehicle" | "party" | "charge"), default "vehicle" - BenchState holds a {logical bus -> python-can bus} map; send(..., bus=) routes, unknown/unopened bus falls back to the vehicle bus (bridged) - Scheduler emits each frame on its own bus (still one timer thread) - run() gains party_channel/charge_channel (+ interfaces); a secondary bus opens only if some enabled frame targets it; all distinct buses shut down cleanly Single-ECU benches (di.py/pcs.py) are unchanged -- default bus="vehicle", single-bus run() -- verified headless (DI 2 frames, PCS 14 frames). New tests/test_ecu_bench_multibus.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>HVP is the ECU that commands the PCS and owns the HV contactors. It sources two frames (origin=hvp in Model3_ETH.compact.json, 2020.8.1): 0x22A HVP_pcsControl 10ms dlc4 -- pcsControlRequest + charge/dcdc HW enables 0x20A HVP_contactorState 10ms dlc6 -- pack contactor states + closingAllowed / dcLinkAllowedToEnergize / hvilStatus Signal start/width taken verbatim from compact.json; neither frame carries a rolling counter or checksum in that firmware, so both are plain frames. The node OWNS its control intent (control / charge_hw / dcdc_hw / contactor_stage / hv_voltage) and broadcasts it every cycle. DEFAULT is idle/safe (SHUTDOWN, contactors OPEN) so a drive bench that never touches HVP asserts nothing. set_mode(off|dcdc| charge|both|precharge) is the driver/orchestrator hook that moves it through operating modes; eventually this becomes reactive (HVP responding to BMS/VCFRONT/CP broadcasts). This is the first of the PCS-bench frames migrating to their real owner nodes so pcs.py can fold onto the unified node model (the orchestrator, external to the nodes, will poke node state to drive a charge session). Context: pcs.py currently hard-codes HVP_pcsControl / HVP_contactorState in its FRAMES list; 0x545 turned out to be a mistranslation of 0x221 (VCFRONT_LVPowerState), and 0x13D/0x2B2 are undocumented (headed to UNKNOWN). Also: fix vehicle_sim --list-nodes, which still called the removed node.build(controller) / VehicleController path from before the stateful-Node refactor; drop the now-unused VehicleController import. Golden inventory extends by the two HVP frames; full pytest green (2495 passed, incl. two new HVP tests: idle-default-is-safe + set_mode drives control/contactors). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>The CP node now sources 0x21D CP_evseStatus (origin=cp, 100ms dlc8; signal start/width verbatim from Model3_ETH.compact.json) alongside its 0x25D liveness, and OWNS whether an EVSE is plugged in + at what current limit. set_evse(connected, limit_a) -- driver externality: simulate a charger plugged in (evseAccept=1, proximity=LATCHED, pilot=LINE_CHARGE, pilotCurrent/cableCurrentLimit from the limit, acChargeState=ENABLED) or unplugged (all-zero -> no charge intent). DEFAULT is UNPLUGGED so a drive bench asserts nothing; the orchestrator flips it to initiate a charge session (UI charge request -> VCFRONT -> HVP/PCS reacts off this state). 0x25D stays a plain liveness frame (absent from the 2020 compact.json; its CONNECTED cable-state enum isn't pinned, so EVSE connection is reported only via the authoritative 0x21D). Golden inventory + the two CP-subset tests extend by 0x21D; new test covers plug/limit/ unplug. Node suite green (18 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>The UI node now sources 0x333 UI_chargeRequest (origin=ui, 500ms dlc4; signal start/width verbatim from Model3_ETH.compact.json) and OWNS the user's charge intent: set_charge(enable, limit_a, termination_pct) -- driver externality: the "user asks to charge" input (UI_chargeEnableRequest + UI_acChargeCurrentLimit + UI_chargeTerminationPct, defaulting termination to 80%). The rest of the car reacts to this once an EVSE is reported connected (CP.set_evse) to initiate a charge session. DEFAULT is no request (all-zero frame) so a drive bench asserts no charge intent. 0x333 is not part of the drive uiMIA aggregate, so the aggregate is unchanged. Golden + the UI real-alias test extend by 0x333; new test covers enable/limit/termination. Node suite green (19 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pcs.py was the standalone PCS bench (a port of pcs_send.py: a hand-built FRAMES list + _build_* frame builders + pcs_mode/precharge/current_limits verbs + an interactive main()). All of that now lives on the node model, so pcs.py collapses to just the Pcs(Node) class that sources PCS_dcdcStatus 0x224 (the pcsMIA liveness the DIR monitors). Where the old FRAMES went (reconciled against Model3_ETH.compact.json): * HVP_pcsControl 0x22A / HVP_contactorState 0x20A -> HVP node * CP_evseStatus 0x21D -> CP (set_evse); UI_chargeRequest 0x333 -> UI (set_charge) * BMS_status 0x212 charge signals -> BMS.set_mode; VCFRONT 0x3A1 -> set_charge_enable * 0x545 was a mistranslation of decimal 545 = 0x221 (VCFRONT_LVPowerState) -> already VCFRONT-sourced; nothing new * 0x13D ("OBC_control" -- there is no OBC in a Model 3) + 0x2B2 ("charge_power"): in NO Model 3 DBC/compact.json, necessary but undocumented -> the UNKNOWN holding pen with the canned bench default, pending PCS-firmware RE to attribute them The PCS bench is intentionally non-runnable in this state (no more pcs.py main()); charge scenarios come back next via the orchestrator ([scenario] in sim.toml sets the peer nodes' EVSE/charge/HVP state). Nothing imports pcs.py's removed internals (only sim_registry uses NODE). Golden extends by 0x13D/0x2B2 (UNKNOWN); full pytest green (2499 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>The external orchestrator: a [scenario.<NODE>] block in the bench TOML sets each selected node's initial state at startup, so a charge (or any) scenario is configuration, not code. * sim_core.Node.configure(**settings): base hook that REJECTS unknown keys (catches scenario typos). Stateful nodes override it to map their scenario keys onto their setters: CP evse_connected, evse_limit_a -> set_evse UI charge_enable/limit/termination + any UI setting (pedal_map, ...) -> set_charge/set_ui VCFRONT lv_power_state, hv_charge_enable -> set_lv / set_charge_enable BMS mode -> set_mode HVP mode, hv_voltage -> set_mode / set_hv_voltage SCCM gear -> gear * sim_registry: BenchConfig gains .scenario; load_bench_config parses [scenario.<NODE>] (node name upper-cased + validated, each block must be a table). * vehicle_sim applies bench.scenario via node.configure() after the CLI seeds (the profile wins for the nodes it addresses); a scenario for a deselected node is skipped with a note. * scenarios/charge.toml: a runnable charge profile (PCS = DUT via [nodes] real; CP EVSE plugged @32A, UI charge request, VCFRONT HV-charge-enable, BMS+HVP charge mode). This is how the PCS bench comes back after the strip: `vehicle_sim --config scenarios/ charge.toml` brings up a PCS DUT with the rest of the car in a charge session. Verified end to end offline: HVP SUPPORT+chargeHW+400V+contactors-closed, CP evseAccept@32A, BMS CHARGE/ HV_UP_FOR_CHARGE, VCFRONT bmsHvChargeEnable. Full pytest green (2504 passed; new configure + [scenario] parse/validate tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Nodes now react to each OTHER's broadcasts, not just DUT frames off the bus, so a charge session cascades from externalities instead of being a hardcoded end-state: CP evse-connected + UI charge-request -> VCFRONT authorizes HV charge (on_rx 0x333 + 0x21D -> bmsHvChargeEnable) -> BMS goes to charge + HVP commands the PCS (on_rx 0x3A1 -> SUPPORT / charge HW / contactors closed). Engine wiring: * ecu_bench.Scheduler gains an on_emit(can_id, data, bus) hook, called after each frame is sent. di.py is unaffected (defaults to None). * vehicle_sim extracts a single locked _dispatch(can_id, data) used by BOTH the Notifier (bus/DUT frames) and the scheduler's on_emit (sim-node broadcasts), so every node hears every frame; one lock serializes the two threads against node-state races. Node reactions (on_rx): VCFRONT (0x333 UI charge-request + 0x21D CP evse -> hv_charge_enable), BMS (0x3A1 bmsHvChargeEnable -> charge/drive), HVP (0x3A1 -> charge/off). configure() direct overrides still exist for tests/manual, but the reactive path is the point. scenarios/charge.toml now sets ONLY the externalities (CP plugged in @32A, UI charge request); VCFRONT/BMS/HVP charge state emerges. Verified end to end offline: from CP+UI alone the cascade lands VCFRONT hv_charge_enable / BMS charge / HVP SUPPORT+chargeHW+contactors-closed. Full pytest green (2507 passed; new per-node on_rx + cascade tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>The drive inverter is now in the registry as regular nodes, so the model matches the hardware layout and the sim knows which IDs the inverter owns: * DI (scripts/di/di_node.py) -- the VEHICLE-LEVEL drive-inverter aggregate the rest of the car sees: DI_systemStatus 0x118, DI_speed, DI_alertMatrix1-4, ... (originNode=di). * DIR (scripts/dir/dir.py) -- the REAR physical inverter: DIR_torque 0x108, DIR_status, DIR_power, temperatures, alert matrices (originNode=dir). * PMR (scripts/pmr/pmr.py) -- the rear power-module CAN half: PMR_alertMatrix1 0x385 + PMR_info 0x6D4 (originNode=pmr). Frames (id / cycle / dlc) are the cyclic sets from Model3_ETH.compact.json (2020.8.1); event-driven *_udsResponse frames are omitted. Payloads are skeleton (all-zero) for now -- enough to model the layout and give a fully-virtual car an inverter; real signal content is a follow-up (as is the front axle DIF/PMF for AWD). DI lives on DIR for RWD, so a RWD bench marks DI+DIR+PMR together. Because "is this ECU real?" is per-bench (previous commit dropped board_owned), the drive bench marks the connected inverter real via scenarios/drive.toml ([nodes] real = DI/DIR/PMR) so the sim never transmits its IDs and can't collide (canDataBusB). With no inverter connected, the sim broadcasts them (virtual car). vehicle_sim now WARNS when it would simulate an inverter node, pointing at --config scenarios/drive.toml. The golden inventory is the drive-bench SIMULATED set (peers, inverter marked real), so the two exact-set tests select via _sim_frames(); delta-based selection tests are unaffected. Full pytest green (2510 passed; new DUT-node + drive-scenario tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>di.py becomes a thin interactive front-end over the shared nodes instead of hand-building frames: it sources its two driver-input frames FROM the nodes (SCCM_rightStalk 0x229 from the SCCM node, UI_powertrainControl 0x334 from the UI node) and its verbs poke that node state -- gear() -> sccm.gear(), pedalmap()/stopping() -> ui.set_ui() -- so vehicle_sim and di.py share one encoder per ID. The 0x118 DI report (rx_hook overlay of the compact.json-stripped DI_systemState/immobilizerState/accelPedalPos + DB decode) + status()/watch() stay. Immobilizer unified onto the VCSEC node L04 responder (challenge_response_l04), same as vehicle_sim, replacing di.py older ecu_bench ImmoSpec/challenge_response path: the key is resolved via resolve_di_key and handed to the VCSEC node, which answers 0x276 -> 0x3D9 from the RX hook (Notifier thread; no lock/cascade needed here). Reuses ecu_bench.run for the scheduler + RX cache + shell. Fix a package-name collision the reorg exposed: scripts/di/di.py (this tool) is auto-added to sys.path and, as a regular module, SHADOWS the di node package (scripts/di/di_node.py) so "from di.di_node import NODE" in sim_registry failed. di.py now drops its own dir from sys.path (it imports only from scripts/ + root), so di resolves to the package. Verified offline: frames = {0x229, 0x334} from the nodes; verbs mutate node state; rx_hook answers 0x276->0x3D9 (L04) + extracts 0x118 immo=DISARMED; di.py --help imports clean. Full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Add scripts/odin_service.py as the one import surface for the CLI and the coming can_live cockpit: - list_procedures(runnable_only) reuses odin_coverage's handled-set + transitive walk to mark each entry proc runnable-now, and pulls title/valid_states/ principals off its comments.TaskInfo (bench preconditions + a human picker). TaskInfo.title is a bare string in the real bundle but {'value': ..} in synthetic graphs, so _unwrap handles both. - run_procedure(basename, backend, on_event) wraps Engine.run_procedure, returns a JSON-friendly RunResult dict, and streams trace/metric/done|error events. backend accepts a Backend instance or 'mock'/'bench'. Engine gains an on_event hook (_emit; _log -> 'trace', CaptureMetric / CaptureConnectorInfoLookup -> 'metric'). odin_runner main() now routes through the service and grows --list [--runnable] [--json]. tests/test_odin_service.py covers discovery (runnable vs blocked, both title shapes, no-TaskInfo) and a streamed run, all on a synthetic bundle (no Tesla bundle content vendored). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Add the non-interactive core of tm3diag's DID menus to odin_service, so the terminal menus and the coming can_live/CLI DID surface share one encode/decode path off the ODJ (no hand-packed duplicate): - list_dids(cfg) -> {read, write} DID metadata (id, size, security_level, fields) - read_did(sess, cfg, name_or_id) -> {name, id, hex_id, raw, fields}, decoding via odj_codec.decode_response; runs SecurityAccess when the read subspec needs a level; parsed toggles enum-name vs raw. - encode_did_write(cfg, name_or_id, values) -> (name, did_id, bytes) so a caller can confirm the exact bytes before sending; write_did(sess, cfg, ...) encodes via odj_codec.encode_request (enum names or raw bytes/hex), runs SecurityAccess for the write subspec's level, and writes. Helpers take an already-opened (sess, cfg) -- same context tm3diag has -- and are covered by tests/test_odin_service.py with a FakeSession + synthetic NodeConfig (no bench, no firmware data). encode_request is big-endian for byte-aligned fields, i.e. the wire-correct path that tm3diag's LSB-first _prompt_routine_inputs hand-packing gets wrong for multi-byte writes. Deferred: routing tm3diag's _did_menu/_did_write_menu through these -- that file has uncommitted WIP inside _did_menu, so the refactor waits until it lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Point _did_menu (0x22) and _did_write_menu (0x2E) at odin_service's DID helpers so there's one ODJ encode/decode + SecurityAccess path: - read: odin_service.read_did runs SecurityAccess (when the read subspec needs a level), reads, and decodes; the menu renders from its raw bytes so the on-screen display is unchanged. - write: build a {field: value} dict (new _prompt_field_values), encode via odin_service.encode_did_write (confirm the exact bytes), then write_did. This replaces _prompt_routine_inputs' LSB-first hand-packing, which was WRONG for multi-byte byte-aligned writes -- the codec is big-endian (wire-correct). Verified end-to-end: a 16-bit 0x1234 now goes out as 12 34, not 34 12. scripts/ is added to sys.path so tm3diag can import odin_service. Also converts a try/except/pass in the just-landed _dl_probe_cmd to contextlib.suppress (ruff SIM105) to keep the file lint-clean. (_prompt_routine_inputs is left as-is: still used by the routine/io-control menus, which have the same latent LSB-first issue -- a separate follow-up.) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Add scripts/odin_web.py: aiohttp route glue exposing the ODIN runner + DID read/write over HTTP + WebSocket, built on the odin_service core. can_live wires it in with one setup_routes(app, ...) call. Endpoints: - GET /api/odin/procedures[?all=1] runnable (or full) procedure list, cached + computed off the event loop (executor). - POST /api/odin/run {procedure} run one proc; returns the RunResult dict. The Engine is synchronous, so the run goes through loop.run_in_executor; its on_event callback hands trace/metric/done/error events back to the loop via call_soon_threadsafe -> asyncio.Queue -> broadcast to /ws/odin. One run at a time (a lock; a second run gets 409). - GET /ws/odin server->client progress stream. - GET /api/did/{node} the node's readable/writable DIDs. - POST /api/did/read|write read/decode or encode/write a DID. Backends/sessions are injected so it's testable with no bus: backend_factory() -> a Backend ('mock' default), node_provider(node) -> (NodeConfig, session) for DID ops (503 without one). tests/test_odin_web.py drives every endpoint (incl. the WS stream and the single-run 409) through the aiohttp test client against a MockBackend + a FakeSession, under asyncio.run (no pytest-asyncio needed). Deferred: the ~2-line can_live _build_app hook (add scripts/ to sys.path + odin_web.setup_routes(app, ...)) -- can_live.py has uncommitted WIP in _build_app, so it lands once that WIP is in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Two bench-driven fixes for the party-bus error spikes, plus a cleaner reactive model. 1) Node.on_rx (monolithic, can_id if-ladder, re-checked every frame) -> Node.rx_handlers(): a node returns {arbitration_id: handler(data, send)}. The engine builds ONE global {id: [handlers]} table from the selected nodes, so an inbound frame goes STRAIGHT to only its registered handlers -- no per-frame scan of every node, and a non-reactive ID is just a dict miss. VCFRONT {0x333,0x21D}, BMS/HVP {0x3A1}, EPB {0x118}, VCSEC {0x276}; everyone else registers nothing. (0x3A1 fans to both BMS and HVP.) 2) The scheduler's on_emit dispatch is now NON-BLOCKING: it try-acquires the rx lock and skips if the Notifier is mid-burst holding it (the next periodic broadcast re-triggers). Combined with (1) eliminating the hot-path node scan, an RX-processing burst on the real inverter can no longer stall the TX scheduler -> kills the "errors clear then periodically spike" pattern. 3) BenchState.tx_err_by_id: per-arbitration-ID tx-error tally, surfaced in vehicle_sim's periodic report ("tx err by id: 0xNNN=k, ..."). A spike now attributes itself: a few IDs => something specific; every ID => a bus event / overrun. di.py (now the DI node) has no dispatch; only vehicle_sim consumes the table. Tests migrated to a _rx() helper (the test-side of the dispatch) + a registration test asserting each node's declared IDs and the global table (0x3A1 -> 2 handlers). Full suite green (2547). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Add uds_local/datastore.py: a persistent {board_id: {namespace: {...}}} JSON (odin_data.json, gitignored). Board id = board serial (DID 0xF013). Namespaces: * immo -- the paired immobilizer key/salt * outputs -- outputs captured from an ODIN procedure run - Keystore now reads/writes the DataStore's 'immo' namespace instead of a separate immo_keys.json; same get/put/__contains__/__iter__ API, so di.py + the immo tools are unaffected. DEFAULT_KEYSTORE points at odin_data.json (no legacy fallback). - BenchBackend.store_outputs persists a procedure's RunResult.outputs under its board id (resolved by reading 0xF013 on the first node the run reached), via a json_safe coercion that turns raw bytes (odometer / resolver-calibration blobs) into hex strings so they survive JSON. Engine.run_procedure calls it; the default Backend / MockBackend are no-ops (offline runs persist nothing). tests/test_datastore.py + TestStoreOutputs in test_odin_bench cover the store, Keystore-over-DataStore, json_safe, and bench persistence keyed by board. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>vehiclecontrols.EnsureApplicationState was a no-op, so PROC_*_STORE-DATA-BOOT (which sets application_state=BOOTLOADER) read package identity in the app state and NRC'd -> all None, data_out={}. (PMR has those DIDs at read_sl=0, confirmed; the STORE-DATA-APP procs with application_state=None already worked.) BenchBackend.ensure_application_state now REUSES the flasher's UdsSession bootloader handover -- the same ecu_reset_no_wait(0x01) + wait_for_bootloader() that flash_scripts' step_ecu_reset + step_wait_for_bootloader wrap (wait_for_bootloader floods TesterPresent through the reboot so the bootloader holds; update.img enter_bootloader_v0). No duplicated sequence -- the shared primitives live on UdsSession. APPLICATION just resets (no TP flood -> boots on into the app). Tracked in self._bootloader so an already-in-state node isn't needlessly reset; None / ESP are no-ops; the base Backend / MockBackend stay no-ops (offline). This DOES reset the real ECU when a proc requests BOOTLOADER -- intended (it's the same reboot the flasher does before a flash). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>This is the actual reason the sim's nodes showed no data. `hide_own_tx` was on by default and dropped any frame with `is_rx is False`, commented as "echo of a frame this very socket sent". That is not what the flag means. python-can derives is_rx from MSG_DONTROUTE, and SocketCAN raises MSG_DONTROUTE for a frame created by ANY local socket — MSG_CONFIRM is the this-socket marker. So the filter was throwing away everything vehicle_sim broadcast, while real ECU traffic (is_rx True, off-host) came through untouched: exactly "the nodes we transmit on are dead, everything else is fine". The class docstring asserted the opposite of the truth — that our tooling's frames "are indistinguishable from the vehicle's by socket flags because they were sent from a different process", and that arbitration id was therefore the only way to tell them apart. Being sent from a different local process is precisely what the socket flag marks, and that wrong premise is what kept the filter looking harmless. Corrected in place. The this-socket case the flag was meant to cover needs no filtering at all: the reader never asks for receive_own_messages, so the kernel already withholds its own echoes. - hide_own_tx -> hide_host_tx, defaulting OFF - --show-own-tx (opt out) -> --hide-host-tx (opt in), so the default is a plain store_true pass-through with no inversion to get backwards again - 4 tests over _TxFilter.drop: local frames survive by default, --hide-host-tx still drops them, tx_ids toggle both ways, ignore_ids outlast the UI toggle Introduced 2026-07-30 inbc7672f. Note the CLI default itself is not unit-tested — main() builds its parser inline, so there is no parser to construct without refactoring it. 2632 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>DI_locStatus rollPreventionState + vehicleHoldState sat at FAULT because they share one chassis hold/roll FSM (DIR_chassisHoldRollFsmNextState @0x9dd0a) whose input gate (DIR_chassisHoldRollFsmInputGate @0xa7dfd) forces not-ready unless six VCFRONT status codes == 2. DIR_rx0x102_vcfront stores 0x102 bits0-3 -> DAT_1419a, bits4-7 -> DAT_1419b (==0 flagged invalid); DIR_rx0x2e1_vcfrontStatus stores 0x2E1 bits3-6 -> DAT_14198 (mux0, bits0-2==0). The sim sent both as zeros(8): fresh enough to pass MIA, but the status nibbles were 0 not 2 -> FSM stuck -> FAULT. Send 0x102 bits0-3=2 & bits4-7=2, and 0x2E1 bits3-6=2. Three of the six gate codes (DAT_1419c/1419d/1419e) have a value-source in orphaned RX code we could not statically pin; if hold/roll is still FAULT after this plus the drive-operational gate (DAT_13a19 in {2,5}, needs HVIL closed), bisect them with --set. AEB (DI_aebState=UNAVAILABLE) is separate (DAS/radar). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Static analysis (gen26 12603 2020.8.1 PMR+DIR) resolves the long-standing "board-TX?" question for 0x1E5/0x240: they are EXTERNAL ESP-group inputs the DIR consumes, NOT transmitted by the DU. - DIR RXes both: FUN_000aa944 (0x1E5 -> g_nDirEspSignalStatus2 b0-3) and FUN_000adddd (0x240 -> b4). - PMR transmits neither: complete PMR_canTxFrameById party-TX set is {0x108,0x118,0x148,0x256,0x257,0x286}; no 0x1E5/0x240 immediate anywhere in PMR code; no PMR RX handler either. - So on a DU-only bench nothing refreshes these ESP inputs -> they can gate the DIR VDC readiness (a199/a222/a223/a210 cluster). The 2026 PM_locState=pm on 0x1E5 is a later ID reuse, irrelevant to 2020.8.1. --vdc-esp (off by default) appends both as party frames with idle content from a known-good drive log (blazer-party-can-idle-then-shift-to-drive.trc), reproduced byte-exact: 0x1E5 DLC8 ctr@53w3/cksum@56 magic0xE6 (00 0c..00 f2); 0x240 DLC2 ctr@8w4/cksum@0 magic0x42 (72 30). Kept opt-in until bench-confirmed: if a066 canDataBusB appears, a TX path exists after all; if the VDC cluster clears, they were the missing input. Golden node set unchanged (runtime-only injection). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>