-
Notifications
You must be signed in to change notification settings - Fork 2
Fault Injection
simulator/faults.py defines named scenario presets that force the simulator into a specific fault state on demand, instead of waiting on independent random signal generation to happen to produce one. Useful for testing an LLM client's diagnostic reasoning, or just demoing what a fault looks like without waiting.
| Preset | Effect | DTC |
|---|---|---|
overheat |
ENGINE_TEMP pinned to its hottest reportable value (87.5 — see Architecture for why not the DBC's declared 127.5), SYSTEM_STATUS set to FAULT_PRESENT
|
P0217 (Engine Overtemp Condition) |
abs_fault |
All four WHEEL_SPEED_FL/FR/RL/RR stuck at 0.0, SYSTEM_STATUS set to FAULT_PRESENT
|
C0035 (Left Front Wheel Speed Sensor Circuit) |
low_fuel |
FUEL_LEVEL pinned to 2.0
|
none — a low-fuel light isn't a stored trouble code on a real vehicle either |
mcp-can fault list (or the PRESETS dict in faults.py) is the source of truth if this table ever drifts from the code.
MCP tool:
activate_fault_scenario(preset: Optional[str] = None, timeout_s: float = 2.0) -> FaultScenarioResult
preset=None clears the active scenario.
CLI:
mcp-can fault overheat # activate
mcp-can fault clear # deactivate
mcp-can fault list # show presets + descriptionsBoth require a simulator already running and sharing the same bus (mcp-can demo/simulate) — activation is a round trip over the bus, the same pattern send_obd_request/send_diagnostic_request use, not a direct call into the simulator's memory.
The CLI/MCP tool that requests a scenario change runs as a separate process from the one running the simulator threads (unless it's demo, which is still a separate function call/thread within the process, but shares the bus either way). Since there's no other shared state between them, activation goes over the bus itself:
- The client builds a small control frame on a dedicated arbitration ID (
FAULT_CONTROL_ID = 0x7F0) — byte 0 is the preset's index (0= clear). -
FaultListenerThread(running in the simulator process, started byrun_simulator()) receives it, updates a sharedFaultState, and sends an acknowledgment frame onFAULT_ACK_ID = 0x7F1. - The client waits for that ack (
timeout_s) before reporting success.
Once active, SimThread consults FaultState.get_override(signal_name) for every signal it encodes (checked before the correlated state lookup, so an override always wins), and OBDResponderThread consults FaultState.dtcs() when answering an OBD Mode 03 request.
Fault presets' DTCs use the real J2012-style 2-byte wire format (the same one real OBD-II scanners use), implemented in obd.py:
-
encode_dtc("P0217") -> (0x02, 0x17)— verified against the actual published encoding for that code, not just an internally-consistent roundtrip. -
decode_dtcs(value_bytes)decodes a Mode 03 response's DTC bytes back into code strings;send_obd_request/obd-requestwithservice=3returns them underdecoded.dtcs. - A single-frame OBD response fits at most 3 DTCs (7 payload bytes: 1 for the service ID + 2 per code) — not a practical limit given each preset sets at most one.
Add an entry to PRESETS in faults.py:
"my_scenario": FaultPreset(
name="my_scenario",
description="Human-readable one-liner shown by 'mcp-can fault list'.",
overrides={"SOME_SIGNAL": some_value}, # physical value, or choice-table int for enum signals
dtcs=["P1234"], # optional
),Two things worth checking before you do:
-
Is the override value actually encodable?
SimThread._clamp_to_encodable()will saturate an out-of-range value rather than crash, but a value silently clamped down is a confusing thing to debug later — check the signal's bit width invehicle.dbc(ormcp-can dbc-info <message>) first, the wayoverheat's87.5was chosen deliberately rather than the DBC's declared-but-unencodable127.5. -
Does the signal name exist? A typo in
overrideswon't error — it'll just be silently ignored bySimThread, since it only checkshas_override(sig.name)per signal it's actually encoding.tests/test_faults.py::test_every_preset_override_targets_a_real_dbc_signalguards against this for the shipped presets; a new preset should stay covered by the same test (it iteratesPRESETSgenerically).
Getting started
Reference
Simulation features
Operating it
Contributing