ODIN Support + Vehicle sim refactor (and 2020 CAN messages) #11

Merged
outlandnish merged 70 commits from feat/odin-support into main 2026-08-28 10:24:55 -05:00
Owner
No description provided.
Run Tesla's real ODIN diagnostic graphs against a bench/EV-conversion drive
unit via our own interpreter + hardware-interop shim, instead of the frozen
odin-engine. Node-type inventory: 181 types, only 5 modules (uds/odx/can/cid/
vehiclecontrols) touch hardware; the rest is pure compute.

- odin_runner.py: interpreter PoC. Control-push/data-pull engine + Split
  concurrency + subnetwork resolution; 25 node handlers. Runs the real
  PROC_DI_X_RESOLVER-LEARN -> lib/DI_RESOLVER_LEARNING end-to-end. Backend seam:
  MockBackend (scripts 4 scenarios, validates the interpreter) + BenchBackend
  skeleton wired to uds_local.UdsSession.
- odin_coverage.py: introspects the handlers, transitively expands referenced
  subnetworks over all 661 entry procedures, reports runnable-now + ranked
  missing-type worklist. Keystone blocker = networks.RunReferencedSubnetwork (96%).
- resolver_cal.py: hand-port of resolver-learn over UDS (0x406 OFFSET_LEARNING +
  0x407 RESOLVER_LEARNING), used as the odx/result-parsing reference.

Firmware findings + the multi-step build plan live in docs/private (gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expand the ODIN node-graph interpreter from PoC (25 handlers, 1 runnable
procedure) to broad coverage (99 handlers, 58 runnable), and move the
general-purpose runner/coverage tools to scripts/ top level (not DI-specific).

- Keystone: networks.ReferencedSubnetwork / RunReferencedSubnetwork inline
  subnetwork I/O -- the slots./outputs./signals. connection grammar (first-dot
  parse), SetOutput/Output collection, bare-task vs wired entry handling.
- Pure-logic + iteration batch: logic / dicts / collections / lists / math /
  strings / control(iteration) / bytes / json / regex / sets. Fixes two latent
  bugs: logic.Compare ignored its operator; collections.GetItem list indexing.
- odx.* / uds.* on uds_local.UdsSession via new uds_local/odj_codec.py
  (table-driven ODJ encode/decode, validated vs resolver_cal ground truth).
  BenchBackend fully wired: per-node cached session, ESP no-op stubs.
- 67 self-contained tests (synthetic graphs / tmp bundle / fake session; no
  Tesla bundle content). Full suite green, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runnable-now jumps 58 -> 361/661 (54%): PowerContext + EnsureApplicationState
were gating half the library, and the whole DI/DIS/PM powertrain suite is now
runnable end-to-end (motor/HVIL/accel/brake/oil-pump self-tests, resolver
cal-data, DI reset/SN, odometer, ...).

- vehiclecontrols.PowerContext: run body inside an assumed power state (failure
  only for real power faults); EnsureApplicationState / EnsurePowerState /
  McuScreenOn: assume-state no-ops. Routed through new no-op Backend hooks
  (ensure_power_state / ensure_application_state) so a real bench can later drive
  vehicle_sim LV/BMS.
- control.TryExcept: typed-catch variant of TryExceptAll; catch-all (Tesla's
  exception names don't map to ours) matches the intent of swallowing expected
  UDS/ISO-TP errors.
- +4 self-contained tests. Full suite green (2416 passed), ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runnable-now 361 -> 487/661 (73%). CID handlers back onto two providers:

- CidStore: read-your-writes data-value store (Get/Set/GetDataValueUntil/
  ListDataValues) + in-memory SaveData/LoadData blobs, seeded with bench
  defaults; an optional derive() supplies live values (VAPI_shiftState,
  GUI_tractionControlModeRequest) that win over stored ones.
- CidFilesystem: READ-ONLY, path-jailed view of the firmware dump (config.ROOT
  = TM3_ROOT, a full CID buildroot rootfs). HashFile/GetDirectoryContents/Grep
  serve REAL data for static firmware content; runtime paths absent from the
  image read empty/not-found (accurate for a fresh unit). Never writes to the
  dump; ..-escape rejected.
- Stubs: ExecuteApplication/CidCommand/ExecuteScript -> canned success (dump
  binaries can't run); SvCommand/CheckProcess/RebootCid/ClearCache/
  EmitRebootGateway -> no-op; GetVin/GetVitals/GetDiskFree -> placeholder.

Adds config.ROOT (public TM3_ROOT export). +15 self-contained tests incl. a
real-dump test that skips when TM3_ROOT is unset. Full suite green (2431),
ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runnable-now 487 -> 521/661 (78%).

- Live CAN: BenchBackend.can_read decodes a live-bus RX cache (_CanRxCache +
  can_decoder.CanDatabase, lazily opened, closed in close()). Handlers:
  can.CANSignalRead (data), can.CANSignalMonitor (fire value_changed on a
  change, else timed_out), can.BytesToInt (bytes/int/hex).
- Cheap logic: dicts.FromInputs (ordered a,b,c.. list), bytes.Base64Decode/
  Encode/RandomBytes, messages.ProgressUpdate/StatusUpdate/Listen (non-blocking
  -> fire done), proto.ReadFile (served from the firmware dump via CidFilesystem).
- +11 self-contained tests (incl. a fake-CanDatabase RX-cache test). Full suite
  green (2442), ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
odin_runner/odin_coverage no longer require flags — everything comes from .env.

- config.ODIN_BUNDLE (resolve_odin_bundle): the bundle networks/ dir derived
  from TM3_ROOT (opt/odin/odin_bundle_extracted/odin_bundle/networks) or
  TM3_ODIN_BUNDLE. --bundle now optional.
- config.VEHICLE_CHANNEL / PARTY_CHANNEL / CHARGE_CHANNEL (+ TM3_*_CHANNEL env;
  vehicle falls back to TM3_CHANNEL) and config.can_channel(bus) mapping ODIN
  bus tokens (ETH/VEH->vehicle, PARTY->party, CH->charge). Downstream tools
  (vehicle_sim) can share config.CAN_CHANNELS.
- BenchBackend.can_read routes each bus to its channel (one RX cache per
  channel; unconfigured buses fall back to the vehicle channel). --channel /
  --interface default from TM3_CHANNEL / TM3_INTERFACE via config.apply_defaults.

+7 config tests (bundle resolution + bus routing). Full suite green (2452),
ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Standardize the CAN-channel env var on the bus it names. It's the shared
--channel default for every tool (via config._ENV_MAP) and the vehicle bus in
config.VEHICLE_CHANNEL, so the rename spans config.py, .env(.example), the
odin_runner help/docstring, and the tool docs (tm3uds/tm3diag/dfu/immobilizer/
ghidra + README). config.VEHICLE_CHANNEL now reads TM3_VEHICLE_CHANNEL directly
(dropped the TM3_CHANNEL fallback).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Group the flat SimFrame list into per-ECU SimNodes, each owning the messages it
sources (scripts/<node>/<node>.py); sim_registry aggregates them and vehicle_sim
expands the selection into one frame list. Zero behavior change: per-bus ID sets +
every frame period/counter/checksum/DLC are identical to the pre-refactor code,
verified across all --no-*/--only/--exclude combos. New tests/test_vehicle_sim_nodes.py
locks the golden inventory.

- scripts/sim_core.py: SimFrame (party:bool -> bus:str) + SimNode + zeros()
- scripts/tesla_frames.py: j1850_crc8 + J1850Frame (shared payload CRC helpers)
- 0x221 LV folds into the VCFRONT node (removes the special lv_loop)
- --no-gtw/--no-das/--no-ui/--no-shifter/--no-221 preserved as ID drops
  (become --sim/--real node selection in Phase 1)
- immo responder (VCSEC) stays in main() until Phase 3
- relocate vehicle_sim.py di/ -> scripts/; PCS liveness node co-located in pcs.py

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>
Clears every pure-logic / bench-runnable ODIN node type; coverage
135 -> 158 handlers, 559 -> 644/748 procs runnable (74% -> 86%). All
remaining blockers are cloud/OTA interop (http.*/cidupdater.*/cert.*/…),
correctly skip-on-bench.

Runner (scripts/odin_runner.py):
- networks.Subnetwork: inline-nested subgraph. _ctrl_networks_Subnetwork
  lifts inner nodes into a child graph, strips the "<name>." connection
  prefix (_deprefix), and reuses run_graph. Handles both entry styles —
  Enter/Exit and Slot/Signal (_graph_start starts at Enter-or-Slot;
  networks.Slot/Signal/Cancelled handlers).
- networks.DynamicallyReferencedSubnetwork (runtime basename via `name`)
  + scripts.RunScriptTest (basename via `script_name`). Unified basename
  resolution (_resolve_basename) and wired-vs-wrapper running
  (_run_child_graph); run_procedure now just calls it.
- control.MultiSplit/MultiMerge (fan-out/join), control.Merge (OR-join),
  control.Break (loop-break via _BreakLoop caught by every loop / _run_body).
- Logic/tail: networks.AppendOutput, control.ForAccumulate, misc.DateTime,
  misc.Uuid, math.SeriesSum/Abs, can.ActiveAlerts, cid.SaveAuthoredPopup,
  messages.Send, odx.OdxStartAndWaitResults_V2, uds.UdsIOControl.
- isotp.Send: raw ISO-TP transmit with explicit tx/rx CAN IDs
  (to_controller/from_controller), reusing the py-uds transport
  (PyCanTransportInterface + NormalCanAddressingInformation) the UDS stack
  is built on — its own transport per (channel, tx, rx), torn down in close().

Coverage (scripts/odin_coverage.py):
- _walk_nodes descends into inline networks.Subnetwork inner nodes (so their
  types are counted, not hidden); _basename resolves script_name/name.

Tests (tests/test_odin_runner.py): +22 (51 -> 73) — both inline-subnet
styles, Break, MultiSplit/Merge, accumulators, logic tail, RunScriptTest +
DynamicSubnetwork (tmp bundle), OdxV2 success/fail, UdsIOControl, isotp.Send.
Full suite: 2483 passed, 1 skipped; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add a top-level vehicle_sim.toml (auto-loaded if present; --config PATH overrides)
configuring both node selection and message->bus assignment:

  [nodes] sim/real   which ECUs to simulate vs present-for-real
  [bus] id = "bus"   message-level bus override, over each frame's firmware default

CLI --sim/--real/--profile compose on top (CLI wins). Buses now resolve via config:
--party-channel/--charge-channel default from TM3_PARTY_CHANNEL/TM3_CHARGE_CHANNEL, and
a new 'charge' logical bus is supported end-to-end (bus binding + per-bus tx counters +
summary generalized to vehicle/party/charge). config.canonical_bus normalizes bus names:
ETH and any unknown token map to the vehicle bus.

Without a config file, output is byte-identical to Phase 1 (verified across all combos).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CMP (0x247 A/C-compressor liveness -> cmpMIA) and PTC (0x207 cabin-heater liveness
-> ptcMIA) are distinct source ECUs, so per the attribution principle each gets its
own node instead of a shared THERMAL grouping. Frame output is unchanged -- both stay
on the vehicle bus with identical metadata; only the node grouping (and --list-nodes)
changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Fold vehicle_sim's hand-rolled runtime onto ecu_bench so there is one engine, not two:

- One ecu_bench.Scheduler (single timer thread, per-frame bus routing) replaces the 45
  per-frame TX threads. Each selected SimFrame becomes an ecu_bench.Frame whose builder
  calls sf.frame() (counter/checksum/--set overrides stay in the SimFrame).
- A can.Notifier with reactive listeners replaces the hand-rolled recv loop: the L04
  immobilizer responder (0x276 -> 0x3D9, per-counter dedup) and a 0x118 listener
  (closed-loop EPB + progress watch) run on the vehicle bus.
- TX ok/err counting moves into BenchState.send (per logical bus), keeping the NO-ACK
  visibility; ecu_bench no longer silently suppresses send errors.

Node selection, the sim.toml config, the bus map, MIA warnings, --set, the control
server, and all summary output are unchanged. Verified: golden bus-A/bus-B summary
byte-identical; a fake-bus smoke exercises scheduler + immo(0x3D9) + 0x118/EPB; di.py/
pcs.py still run unchanged; full pytest green; real DIR/PMR bench smoke reproduces the
same IDLE->REQUEST immobilizer progression with frames ACKing on both buses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move the L04 immobilizer from an inline listener in vehicle_sim into the VCSEC node,
completing the "responders on the shared core" model:

- SimNode gains a `responders` list of factories make(ctx) -> listener | None.
- scripts/vcsec/vcsec.py declares the immo responder: listen 0x276 -> answer 0x3D9 =
  AES-128(key, L04)[:8] once per counter. It builds nothing without a key (returns None).
- vehicle_sim installs the responders of the SELECTED nodes on the vehicle bus, passing a
  ctx with a bound send + the resolved immo key. So immo now also requires the VCSEC node
  to be selected: --real VCSEC (or a --sim without it) turns it off, reported in the summary.

The VCSEC node is no longer an empty placeholder -- it sources the 0x3D9 response.
Verified: fake-bus smoke still answers 0x276->0x3D9 via the node responder; golden summary
unchanged; full pytest green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turn each peer ECU into a stateful Node that OWNS its state, BROADCASTS frames reflecting
it, and TRANSITIONS on frames it receives (on_rx) -- the shared model both vehicle_sim
(drive) and pcs.py (charge, next increments) will drive via injected externalities.

- sim_core: Node base (frames()/on_rx()) + NodeContext; SimNode/build(controller) removed.
- All 18 nodes become Node subclasses. VehicleController dissolves into the nodes that own
  each piece: SCCM (gear stalk), UI (UiConfig), VCFRONT (LV power), EPB (brake, on_rx 0x118),
  GTW (car-config), VCSEC (L04 immobilizer, on_rx 0x276). The rest are fixed-broadcast
  liveness nodes. Reactive responders folded into on_rx.
- sim_registry: NODES are classes; instantiate() + collect_frames() replace build_frames.
- vehicle_sim: instantiate the selected nodes, seed the drive scenario + externalities
  (LV/UI/immo key), fan every inbound frame to the nodes' on_rx (one Notifier listener), and
  route control-server commands via a facade to the owning node (can_live unchanged).

DRIVE scenario byte-identical: golden bus-A/bus-B inventory + per-frame metadata unchanged;
full pytest green (+ new EPB/VCSEC on_rx tests); real DIR/PMR bench reproduces the same
IDLE->REQUEST immobilizer progression with frames ACKing on both buses.

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>
VCFRONT and BMS now own their charge-vs-drive intent as settable state; the DRIVE
default is byte-identical to the pre-refactor build (frame inventory unchanged).

VCFRONT 0x3A1 VCFRONT_vehicleStatus:
  set_charge_enable(on) -- when set, layers VCFRONT_bmsHvChargeEnable=1 +
  VCFRONT_12vStatusForDrive=READY onto the drive status (pack side authorizes HV charge +
  12V rail ready). Default OFF -> drive build unchanged.

BMS 0x212 BMS_status:
  set_mode(drive|dcdc|charge) -- selects the HV/contactor/state signal set per the
  PCS_OPERATING_MODES tables (charge -> HV_UP_FOR_CHARGE / BMS_CHARGE / chargeRequest=1 /
  uiChargeStatus=CHARGING; dcdc -> HV_UP / BMS_SUPPORT). Default "drive" reproduces the old
  drive-ready build exactly.

These are the pack-side inputs the orchestrator flips (alongside CP.set_evse +
UI.set_charge) to start a charge session. Signal positions from compact.json / the mode
tables. Full pytest green (2499 passed; new VCFRONT charge-enable + BMS mode tests).

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>
Whether an ECU is present as REAL hardware (a DUT) is a property of the bench, not the node:
you only avoid transmitting an ID when that device is physically connected (else two
transmitters collide -> canDataBusB). With the hardware absent, simulating those frames (a
virtual ECU) is correct. So it belongs in the config's [nodes] real list, not a hardcoded
node flag -- e.g. the drive bench marks the connected inverter real, a virtual car does not.

No node ever set board_owned=True, so this is behavior-preserving: remove the attr
(sim_core), the select_nodes() branch (sim_registry), and the --list-nodes tag (vehicle_sim).
Node suite green (29 passed).

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>
"DUT" (device-under-test) is a per-BENCH role, not a property of a node: on the drive bench
you exercise the inverter, on the charge bench the PCS, elsewhere the inverter may just be
present. Baking "DUT" into node names/docs repeats the board_owned category error (a bench
fact treated as intrinsic). They are the drive-inverter nodes (DI/DIR/PMR); whether a node is
"real"/present is per-bench config.

Rename sim_registry.DUT_INVERTER_NODES -> INVERTER_NODES (+ vehicle_sim ref/var, test var);
reword the docstrings/comments/scenario files that said "the DUT" to "the real hardware" /
"the connected inverter". No behavior change; suite green.

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>
Lands three interactive-diagnostics features that were sitting as local WIP in
tm3diag.py:
- write-did: WriteDataByIdentifier (0x2E) menu -- the write counterpart to the
  DID read menu; lists writable DIDs, prompts field-by-field (or raw hex),
  echoes + confirms the exact bytes, runs SecurityAccess for the DID's level.
- dl-probe: fire single RequestDownload(addr,size) frames (no TransferData, so
  it's safe to repeat) to map the ECU's accept envelope and read the NRC --
  triangulating why RequestDownload(0xC000) is rejected (address vs size-unit vs
  precondition).
- --log-file / --verbose: capture the full UDS TX/RX wire exchange (incl. the
  exact 0x34 RequestDownload frame + NRCs) to a file, which survives the
  interactive display's line-clearing.

Landed as-is to unblock routing the DID menus through the shared odin_service
DID helpers.

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>
tm3diag's _prompt_routine_inputs hand-packed input fields LSB-first, so a
byte-aligned multi-byte value (e.g. a 16-bit 0x1234) went out as 34 12 instead
of the wire-correct 12 34 -- wrong for every routine / io-control / write-DID
input wider than a byte.

Fix by routing all named-input packing through the ODJ codec (already big-endian
for byte-aligned fields, matching decode):
- odj_codec: add encode_fields(fields, values, input_size) as the shared packer;
  encode_request now delegates to it. encode_fields also serves IO controls, whose
  IoControlEntry carries a bare fields dict + input_size (not a SubSpec).
- tm3diag: the routine menu now encodes via encode_request(sub, values) and the
  io-control menu via encode_fields(io_entry.input, values, io_entry.input_size),
  both fed by the prompt-only _prompt_field_values. Delete _prompt_routine_inputs
  (the buggy LSB-first packer, now unused -- the DID write menu already moved off
  it in f76a30b).

Regression tests in test_odj_codec (0x1234 -> 12 34) + a manual smoke driving all
three menus with a FakeSession.

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>
Working-tree di.py had an accidental `11 ` typed before the shebang (line 1). It
parsed as a statement, so `from __future__ import annotations` (line 52) was no
longer first -> SyntaxError, breaking di.py and everything importing it (can_live
imports di.di for its DI decode maps). Remove it.

Also lands the local autoformat WIP on di.py (multi-line dicts / call args; no
logic change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lands the can_live driver-HUD dashboard (local WIP) and builds the ODIN cockpit
on top of it.

Dashboard (was WIP): a /dash driver HUD (speed, gear command + reported gear,
immobilizer, telltales, faults, LV/pedal/car-config) that FORWARDS control actions
to vehicle_sim (--control / --sim-url, which owns the bus + transmits); + dash.html
and a small index.html tweak. Fixes its di import -- import via the `di` package
(di.di) with scripts/ on sys.path, NOT scripts/di, which would make di.py shadow
the package and break sim_registry's `from di.di_node import NODE`.

ODIN cockpit (phase 4): a new /odin page (can_live_ui/odin.html) styled to match
the index viewer's design tokens, with:
- ODIN tab: searchable runnable-procedure list -> Run -> live progress/metrics over
  /ws/odin -> pass/fail + exit code + metrics table.
- DID tab: node -> readable/writable DIDs -> read (decoded fields) / write (field
  values, big-endian via the shared codec).
Wires odin_web.setup_routes into _build_app against a lazily-built bench backend
(this app's CAN channel), closed on shutdown; odin_runner.BenchBackend gains
open_node() as the (NodeConfig, session) seam odin_service's DID helpers need.

E2E-smoked (aiohttp test client, no bus): /odin serves the page and
/api/odin/procedures returns the 644 runnable procedures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- ODIN built its bench backend on can_live's *viewing* channel, which can be empty
  (socketcan binds 'any': reads work but UDS TX fails "No such device or address").
  Source the vehicle bus from config.VEHICLE_CHANNEL (fall back to --channel), with a
  clear error if neither is a concrete interface.
- can_live now resolves its own buses from config.VEHICLE_CHANNEL / config.PARTY_CHANNEL
  (still overridable by --channel/--channel2), so the viewer and the ODIN cockpit
  share one concrete bus instead of the 'any' interface.
- odin_web: build the run backend BEFORE starting the progress pump, so a backend
  failure (e.g. no channel) surfaces as HTTP 500 without leaking the pump task.
- odin.html: render object/array metric + DID field values as JSON (were showing
  "[object Object]") via a fmt() helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The odx.* nodes (OdxStartRoutine / StartAndWaitResults / RequestResults / ReadData /
WriteData) sent the request straight through, so a security-gated routine returned
NRC 0x33 (securityAccessDenied) -- e.g. PMR CAN_COMM_SELF_TEST, which the ODJ marks
as security level 5. Tesla's odx layer authenticates implicitly from the ODJ; these
graphs carry no explicit uds.UdsSecurityAccess node before an odx routine.

_OdxAdapter now enters the programming session + runs SecurityAccess(seed_level) for
any routine/DID subspec whose ODJ security_level > 0 (matching tm3diag; level 5 is a
programming-session unlock). Idempotent; start_and_wait auths once and the results
polls reuse the unlocked session. Level-0 ops are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correcting the previous di.py reorg: di.py should not transmit anything. Gear (SCCM 0x229)
and pedal-map (UI 0x334) are DRIVER inputs the external orchestrator injects by poking the
SCCM/UI nodes' state (scenario config [scenario.SCCM] gear="D", the can_live dashboard, or a
later interactive orchestrator mode); those nodes then transmit. Watching the DI's own 0x118
report is likewise the orchestrator's job (vehicle_sim already decodes 0x118).

So di.py becomes the DI meta-node: the vehicle-level drive-inverter aggregate (originNode=di
-- DI_systemStatus 0x118, DI_speed, DI_alertMatrix1-4, ...). This absorbs the stopgap
di_node.py (removed) and repoints sim_registry to `from di.di import NODE`. Because di.py is
no longer a runnable script, the di.py-shadows-the-di-package collision is gone (no scripts/di
sys.path entry from running it), and the sys.path hack + interactive cockpit + VCSEC-immo
wiring the cockpit carried are all dropped.

Bonus: that cockpit mutated sys.path at import time, which was suppressing ~35 tests during
collection; making di.py a clean node restores them (2510 -> 2545 passed). --list-nodes shows
DI with its 14 DI_* frames; golden unchanged (DI still marked real on the drive bench).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Making di.py the DI node dropped the 0x118 decode tables (DI_GEAR_LABELS / DI_IMMO_LABELS /
DI_SYS_LABELS / DI_HVIL_LABELS / _DI_0X118_RECOVERED), but can_live imports them
to render the driver HUD -> ImportError. These are the DI domain knowledge (how to decode the
DI own 0x118 status, incl. the compact.json-stripped-signal overlay), so they belong in the DI
node file as module constants for CONSUMERS (the dashboard, an orchestrator 0x118 watch) --
just the data, not the removed cockpit rx_hook/report. can_live import + full suite green (2545).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bench findings on PMR CAN_COMM_SELF_TEST (ODJ security level 5, no session field):

1. The odx auto-SecurityAccess entered the PROGRAMMING session (0x02). For a
   security-gated DIAGNOSTIC routine/DID that's the wrong tier -- programming can push
   the ECU toward a flash/bootloader mode -- so use the EXTENDED diagnostic session
   (0x03), where Tesla grants security access for diagnostics. Flashing graphs still
   set programming explicitly via uds.* nodes. (If a routine ever NRCs 0x33 in
   extended, it genuinely needs programming and we make the session ODJ-driven.)

2. UdsSession._tp_loop re-raised any non-bus-down send error, so a transient ENOBUFS
   (errno 105: socketcan TX queue momentarily full) crashed the TesterPresent daemon
   thread with a stderr traceback. This is easy to hit on a busy/contended bus -- e.g.
   a sim transmitting the DUT's own IDs alongside the real ECU. Skip the tick and
   retry next interval; bus-down still stops the keep-alive as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bench-observed on a real DIR/PMR: the vehicle bus was clean but can1 (party) sat at ~20%
tx err that a bigger txqueuelen did NOT fix -> not a queue-depth problem. Root cause: ALL
party frames were forced to one period and fired in a single burst, hammering the DIR's
throttled CANB path (the PMR->DIR IPC relay) faster than it can RX/ACK -> steady no-ACK
errors, and dropped frames skipped the rolling counter -> rcmMIA (consistent) + ibstMIA
(intermittent) because RCM/IBST sit late in the per-tick burst.

Two changes:
- vehicle_sim --party-period default 0.005 (200Hz) -> 0.01 (100Hz). 200Hz was an unreverted
  2x experiment; 100Hz is the documented group2-staleness floor that clears the MIAs.
- ecu_bench.Scheduler is now DEADLINE-DRIVEN with PHASE OFFSETS: frames sharing a (bus,
  period) are spread evenly across that period (new _stagger()) instead of all firing on one
  tick, and the loop sleeps until the soonest due frame. The target ECU sees a smooth,
  evenly-spaced arrival (steadier counters) rather than an N-frame spike -- robust regardless
  of queue size or rate. di.py/pcs (via ecu_bench.run) and vehicle_sim both benefit; the
  on_emit inter-node dispatch is preserved.

Verified: live thread smoke shows 4x100Hz party frames sent ~2.5ms apart (0 bursted), correct
rate, clean stop; new _stagger unit test; full suite green (2546).

Co-Authored-By: Claude Opus 4.8 <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>
Symmetric to the rx handler-table registration: each node already declares its frames bus
(SimFrame.bus), so vehicle_sim now groups the registered frames by bus and runs ONE Scheduler
thread PER BUS instead of a single shared scheduler routing every frame.

Bus isolation: a busy bus -- party at 100Hz, 15 frames -- runs on its own thread, so it can
no longer stall another bus TX cadence (previously one thread interleaved all buses, so a
slow/blocked party send delayed vehicle frames). Each per-bus scheduler also phase-offsets
only its own frames. The non-blocking on_emit reactive dispatch is shared across the
schedulers via the rx lock (party frames are non-reactive -> a dict miss -> no contention).

Verified: two schedulers (vehicle + party) on one BenchState run at independent rates and
stop cleanly. Full suite green (2547).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- DID list: a DID that is both readable and writable now appears ONCE (tagged R/W)
  and its detail view offers a Read action AND a Write form. Previously read/write
  were separate sections, so a writable DID had no way to read it.
- Run result: render the procedure outputs (networks.Output / SetOutput) as a table
  alongside metrics -- e.g. odin_output + Output.DRIVE_UNIT_ODOMETER -- with
  object/array values shown as JSON via fmt().

Co-Authored-By: Claude Opus 5 <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>
- The DID detail view now always shows a Read (0x22): decoded fields when the ODJ
  declares a read subspec, else a raw read. 206 DIDs declare both read+write, but 63
  are write-only in the ODJ yet still physically readable -- this lets you read their
  current value before writing.
- Fix the write-success line: Element.append() returns undefined, so appending an
  element and then setting .innerHTML on the return threw a TypeError. Build the
  node, set its HTML, then append.

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>
An earlier echo >> .gitignore concatenated onto a line with no trailing newline,
producing .claudeodin_data.json -- so neither .claude nor odin_data.json (which
holds immobilizer keys + ODIN outputs) was ignored. Split into two rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An input bound to None by the caller must use the networks.Input nodes
For a selected ODIN procedure, statically list what it expects on the bus
(the CAN signals it reads, plus preconditions and target ECUs) so a bench
operator knows what vehicle_sim / a real ECU must provide before running.
Motivated by PROC_PMR_X_WRITE-DRIVE-TYPE hanging silently: it reads
GTW_drivetrainType (gateway-broadcast) which is absent on a PMR/DIR bench,
so the poll times out with nothing telling the operator the signal was needed.

- odin_coverage.collect/_walk_nodes: optional keyword-only visit() callback so
  a caller gathers per-node facts over the same transitive referenced/inline-
  subnet descent the coverage counter already does.
- odin_service.procedure_requirements(basename): signals grouped by bus (with
  read/monitor/compare kind), alerts, UDS target nodes, preconditions
  (valid_states/application_state/power_state/cid_values), dynamic_count.
  _field_value prefers a field's declared "value" default even when a
  "connection" is also present (the None->default fallback), recovering UDS
  node_names + signals that a connection-is-dynamic rule would drop; only a
  pure connection with no default counts toward dynamic_count (a lower bound).
- odin_web: GET /api/odin/requirements?procedure= (executor + per-basename
  cache; 400 missing param / 404 unknown proc / 503 no bundle).
- odin.html: "Requires on bus" section in selectProc, grouped by bus with
  preconditions and an "N dynamic unresolved" note.
- can_live: live cross-reference. _flusher stamps seen_msg[aid] on frame
  arrival (the /odin page holds no decoded-stream client, so arrival is the
  reliable presence test); GET /api/seen-signals expands fresh ids to signal
  names; the panel polls it and colors each signal green (seen) / red (missing).

Tests: procedure_requirements (grouping, declared-default preference, dynamic
count, preconditions, unknown-proc), the requirements endpoint, and the
seen-signals window/expansion. Synthetic graphs only; the bundle stays
external via config.ODIN_BUNDLE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author per-node fw_variants from the 2020<->2022 DIR CAN-RX diff (di12808 vs
di13003) so --fw sends the firmware-correct message set. 2020 baseline (the
bench DU) left untouched; all firmware-confirmed 2022-new msgs gated to
fw_variants["2022.45.15"]:

- DAS: +0x289/0x39B (dasMIA members, 2022-new)
- BMS: +0x452 (torque-limit input + bmsMIA) + 0x392 BMS_packConfig
- CMP: +0x2A7 (config-selected cmp variant)
- UI:  +0x3B3 UI_vehicleControl2 (uiMIA member; required in drive mode)
- APP: new node + 0x25C (appMIA a108)
- EPAS3P: drops 0x392 in 2022 (ID reassigned EPAS3P_alertMatrix -> BMS_packConfig)
- ESP: re-home 0x11D from UNKNOWN (espMIA a091 + VDC slip a195/6/7)

Golden inventory + fw-versioning tests updated (2610 pass). gitignore _client_fw/.
Bundles other in-progress WIP (config/vehicle_sim/scenarios/alerts tooling).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Force-add the gitignored RE deliverable to the private archive (matches how the
other docs/private PLAN files are tracked). Covers: two-dispatcher CANA/CANB
model, full per-message node/MIA maps + signal send-map, the 2020<->2022 DIR RX
diff (BMS interface expansion, 0x392 ID reassignment, 0x103 door), drive-state
gating of all optional-node MIAs, and the sim fw-versioning wiring notes.

Private only (docs/private is gitignored + pre-push hook blocks it on origin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three changes to the can_live web UI, all landing on the single viewer page.

WSS. The WebSocket URLs were hardcoded ws://, so the page was blocked by
mixed-content when served over HTTPS. They are plain relative paths now (/ws,
/ws-raw, /ws/odin): the constructor resolves them against the page and maps
http->ws / https->wss, so localhost keeps working over ws:// with no branch.

Perf. The whole signal DB is materialized into the table at load and selectNode
only scrolls, so applyFrame was writing textContent and flash classes into rows
nobody could see. An IntersectionObserver on the scroll container gates those
writes; msgState still updates for every frame, so filters, the alert view and
liveness stay correct, and a section repaints from state (deliberately without
flashing) when it scrolls back in. The 250ms age sweep skips off-screen sections
too. Sections hidden by applyFilter stop intersecting, so they fall out free.

ODIN. odin.html is gone; its two panes are tabs on the viewer. Its CSS is scoped
under .odin -- it styled .sidebar/.sidebar-header/.placeholder/.main and bare
input/table, all of which index also styles -- and its JS is wrapped in an IIFE
because it brings its own $/el/fmt. Tab switching is now selectTab() with a
tabInit registry of one-shot initializers, so a tab you never open costs nothing:
ODIN opens no socket and fetches no procedure list until first shown, and Raw
Frames moved onto the same hook. /#odin restores the tab on reload. The
standalone /odin route is removed (404, no redirect).

Also adds the low-level UDS primitives the procedures are composed from, pinned
above the procedure list -- when bringing a bench ECU up, no packaged procedure
covers "hold this node in its bootloader":

- state:       probe state, enter bootloader/application, ECU reset
- session:     diagnostic session, security access, TesterPresent keepalive
- diagnostics: raw DID read, routine control, read/clear DTCs

Enter bootloader routes through BenchBackend.ensure_application_state, the same
ECUReset + TesterPresent-flood handover the flasher uses, so its state tracking
stays consistent with a procedure run afterwards; it reports back the node's
0xF180 fw_type and 0xF181 probe. Everything else maps 1:1 onto existing
UdsSession methods -- no new UDS behavior. The catalog (GET /api/uds/ops) is
data, so adding an op server-side needs no UI change, and ops flagged danger
require a confirm. Hex fields parse as hex, not decimal (0403 is routine 0x0403,
not 403) -- pinned by a test.

2620 tests pass (+10 in test_odin_web.py). The two remaining ruff errors
(dump_alerts.py, opc_via_pmr.py) pre-date this. Bench-untested: none of the
reset or bootloader paths have touched a real ECU.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not the cause of the nodes-look-dead symptom -- that turned out to be the TX
filter doing its job: --tx-ids hides those ids by default (tx_hidden=bool(tx_ids)
and not --show-tx), so the sim's own traffic was being filtered out on purpose.
No code change here for that.

What this does fix is a real neighbouring gap found while chasing it: the decoded
view dropped any arbitration id missing from the signal DB before decoding
(can_live.py:683), so those frames never rendered at all, TX filter or not.
Against the shipped compact.json that leaves APP (0x25C), PTC (0x207) and UNKNOWN
(0x13D/0x2B2) with nothing the Live tab can show, ESP at 2 of 7 frames, and DAS
missing 0x289/0x39B, BMS 0x452, RCM 0x111, IBST 0x38E, EPB 0x2A8, CP 0x25D, GTW
0x528. A viewer that silently hides live traffic reads as an ECU that stopped
transmitting, which is the worst failure mode this tool has.

Unknown ids are now forwarded undecoded, tagged unknown:1 with their raw payload,
filed under a synthetic "unknown" node group the client builds on first sight.
Capped at 64 distinct ids so a real vehicle bus (many more ids than the DB
carries) can't spray hundreds of sections into the table -- the cap logs when it
bites, and each newly seen id logs once. Only sent to unfiltered clients: an
unknown id has no originNode to match a node subscription against.
--hide-unknown restores the old drop.

Viewport gating now fails OPEN. Sections started visible:false and waited for the
IntersectionObserver to turn them on, so any failure to observe left a section
permanently blank -- the same dead-node symptom from a third cause. They start
visible and the observer switches them off; worst case is now the un-gated
behaviour.

Also, both UI asks:
- Raw Frames tab hidden behind a HIDDEN_TABS set rather than deleted. The view
  and its lazy initializer stay put (an unshown tab opens no socket), so putting
  it back is one word. The hash boot skips hidden tabs, or /#raw would strand you
  on a tab with no button to leave by.
- The low-level UDS state/session groups tile into columns instead of running
  down the page. diagnostics stays one card per row -- routine control has three
  fields and a hex argument and doesn't fit a 250px column.

2628 tests pass (+5 covering forwarding, the --hide-unknown drop, node-filtered
clients, and that known ids still decode). Still bench-untested.

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 in bc7672f. 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>
The dual-CPU PCS/PM-family bootloader image (pcsbl/pcscpu2bl/pmbl/pmfbl/pmrbl)
was sending WDBI 0x0102 = 0x0C. That came from reading the image's 0x82000 start
address as "the CPU2/secondary flash region", but 0x0C is the CPU2-routed APP
path (0x80800-0xbfff0 over IPC). Sending it for a bootloader image silently
selects app mode and writes the bl into CPU2's bank — the wrong target.

0x05 is the updater's BOOTLOADER-program mode: CPU1-direct, program 0x82000,
erase sectors 1-3. Decoded from setFlashRegionMode in the updater firmware
(client_pmrbu @0x8c4fd), which accepts only those two selectors.

No fallback is registered for the *bl types: if an older resident bootloader
rejects 0x05, that NRC is the diagnostic and should surface rather than be
masked. *bu agents and every single-CPU *bl stay 0x00.

Adds tests/test_flash_module_bytes.py pinning all three cases, and corrects the
module-byte paragraph in docs/FIRMWARE_UPDATE.md, which claimed 0x00 for all *bl.

These changes predate this session — they were sitting in the working tree and
are committed here as-is, unmodified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five UI fixes to the low-level UDS panel and the DID tab.

Node selection is a dropdown in both places instead of a free-text box. Backed by
a new GET /api/uds/nodes, which lists every node carrying a UDS request/response
pair in nodes.json (28 of them) with its tx/rx ids. That is static config rather
than bus state, so the endpoint needs no backend and no bus; the list is cached
for the life of the process. Typing a node name that nodes.json doesn't carry was
only ever going to come back as an error from the server.

Missing-node validation is now visible. It always produced a message, but wrote
it to the output block at the very bottom of the panel -- off-screen on a
scrolled page, so Run looked like it did nothing. It marks the field itself,
scrolls it into view, focuses it, and prints alongside it; picking a node clears
it.

Card heights are even. align-items:start let each card size to its own help text,
so a row of three ran ragged. They stretch now, and each card is a flex column
with the Run button pinned to the bottom, which gives the row one baseline to
read along.

The diagnostics group tiles too, so the per-group opt-in is gone -- every group
uses the grid. The track minimum goes 250px -> 290px to clear the widest form,
routine control's "argument (hex bytes)" label plus its control.

Dropped the DANGER chip. The filled-red Run button (against ghost for everything
else) already separates them, and the confirm still gates every op flagged
danger -- that behaviour is unchanged, only the chip is gone.

2632 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The low-level panel runs long once every group tiles, so state / session /
diagnostics each get a header that toggles their grid. Collapse state persists in
localStorage, which is load-bearing rather than a nicety: selectLowLevel rebuilds
the panel from scratch every time the sidebar entry is clicked, so without it a
closed section reopens on every visit. Each header carries a caret that rotates
when closed and the op count for the group.

The header reuses .section-label for its typography and rule and resets only what
the button UA style imposes. font-family: inherit, deliberately not the `font`
shorthand -- the shorthand resets the label's 10px back to the inherited 13px and
quietly makes these headers bigger than every other label on the page.

Also the missing gap: the divider between sections is the next section-label's
border-top, and .uds-grid had no bottom margin, so the last row of cards sat
flush against it. 18px, which also spaces the final group off the result block.

2632 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The ESP 0x11D frame was sent as zeros(8), which passes the espMIA freshness
(checksum+counter) check but leaves the otherControllerState code (bits 54-55)
at 0. DIR_vdcOtherCtrlStatus_a210src (dir gen26 2020.8.1 @0xb16ae) raises DI
a210 vdcOtherControllerStates unless that code == 2, and a210 holds the
VDC-operational gate down -> also strands a199 vdcFaulted / a222 vdcDisabled /
a223 tractionControlDisabled. Send bits 54-55 = 2; leave the slip/sat status
fields (56-63) at 0 (benign) so we do not manufacture a195/196/197.

Co-Authored-By: Claude Opus 4.8 <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>
EpbResponder.payload() emitted only systemStatus (bits0-3); every other field was 0.
The DIR unpacks more from EPBL/EPBR_status (EPBL FUN_000aaeeb / EPBR FUN_000acf33,
symmetric): systemStatus(0-3), freeRollModeStatus(4-5), summonEnabled(17), and
okToPark(bit47 -> DAT_13dae b5/b6). A healthy stationary EPB asserts okToPark, so the
old all-zeros-except-status frame read as a degraded EPB (okToPark=0). Set okToPark=1
and track telltale (bits12-14, UI-only) to RED_ON when parked for HUD fidelity;
freeRollMode/summonEnabled stay 0 (correct normal-driving defaults).

Fidelity fix for DI_parkBrakeState / EPB picture; note the DI rollPrevention/vehicleHold
FAULT is a SEPARATE gate (VCFRONT 0x102/0x2E1, commit 27fba15), not this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DIR chassis hold/roll FSM input gate (DIR_chassisHoldRollFsmInputGate
@0xa7dfd, gen26 2020.8.1) forces the FSM NOT-READY unless ALL SIX VCFRONT/
VCRIGHT status codes == 2. Commit 27fba15 populated the four sourced from
VCFRONT 0x102/0x2E1, but the remaining three come from CAN 0x103
(VCRIGHT_doorStatus), which the sim still sent as zeros(8):

  DIR_rx0x103_vcrightHoldRollCodes @0xa9cf8 (was orphaned; pinned via the
  gen26_12603_newsla re-import whose updated SLEIGH resolves the pointer-
  table/orphaned xrefs the old analyzer missed):
    byte0 bits0-3 -> DAT_1419c   byte0 bits4-7 -> DAT_1419d
    byte7 bits0-3 -> DAT_1419e

So the hold/roll gate could never clear even after 27fba15. Send 0x103 as
byte0=0x22, byte7=0x02 (all three nibbles = 2). Decompile-confirmed. Hold/roll
also needs drive-operational (DAT_13a19 drive-FSM code in {2,5}), so on a
static bench it may still read non-ready via that secondary gate -- bisect
with --set if so. Suite: 2632 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sets ESP_ptcTargetState (0x145 b36-37) = 2, a plausible real drive value
feeding the DIR traction-mode state machine.

NOT the VDC-cluster fix (corrected by deeper static trace): a199/a222/a210
gate on g_nDirVdcState, a ~20-input validity MIN that collapses ~2s into
warmup on one silent absent input. That is orthogonal to the traction
0x4000 path this signal touches (which feeds DIR_vdcTask's control law, not
the readiness MIN, and can't reach healthy while parked anyway). Kept as a
correctness improvement; may only quiet the a223/a203 traction sub-path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PMR bl/bu verifyCRC (RC 0x0201) expected value is EMBEDDED in the image
(32-bit word @ header[0xe]->end), not supplied by the host. Reversed from
FUN_0008af30/FUN_0008aed9/FUN_0008b8ec (pmrbu 2026.8.3); init=0/xorout=0
reproduces the stock 2024/2026/client images byte-exactly. Updates the crc
module + flash step docstrings accordingly.

Also: bms sim pack voltage 360->373 V (more realistic drive pack), pin
py-uds==4.0.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the near-zero RCM_inertial1 (0x101) and DAS_control (0x2B9) payloads
with the exact static content a known-good Dynam Labs controller sends while
driving a Tesla M3 DU (blazer-party-can-idle-then-shift-to-drive.trc):
  0x101 bytes0-5 = B8 FF F2 FF 36 40
  0x2B9 bytes0-5 = 0D 42 88 2F B1 8B  (+ byte6 bits0-4 = 0x19)

Both reproduce the log byte-for-byte once the SimFrame counter/checksum overlay
is applied (verified ctr=0: 0x101 -> ..05 25, 0x2B9 -> ..19 16). All decoded
fields are non-SNA so the DIR reads them as valid; note 0x2B9 is firmware-read
as ESP wheel-speed/brake (FUN_00098d2f), NOT DAS_control as the DBC labels it.
This is a fidelity change (zeros were already valid); it removes 0x101/0x2B9 as
variables while the 0x1E5/0x240 board-TX question is investigated separately.

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>
fix(sim): 0x1E5 vdc frame counter is 2-bit, not 3-bit
Some checks failed
Tests / test (pull_request) Has been cancelled
6a672065fd
DIR_rx0x1e5_cksumCtr reads the rolling counter as (word3>>5)&3 = 2 bits at
byte6 bits 5-6 (the log wraps 00->20->40->60->00, i.e. mod-4). --vdc-esp built
it counter_width=3, which also sets byte6 bit7; harmless to the counter check
(low 2 bits still increment) but not byte-faithful to the accepted log. Match
the DIR + log exactly at 2 bits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merge branch 'main' into feat/odin-support
Some checks failed
Tests / test (pull_request) Has been cancelled
9b181056ee
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
outlandnish/tm3diag!11
No description provided.