Skip to content

Fault Injection

farzad edited this page Aug 19, 2026 · 1 revision

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.

Available presets

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.

Activating one

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 + descriptions

Both 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.

How activation actually works

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:

  1. 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).
  2. FaultListenerThread (running in the simulator process, started by run_simulator()) receives it, updates a shared FaultState, and sends an acknowledgment frame on FAULT_ACK_ID = 0x7F1.
  3. 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.

DTC encoding

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-request with service=3 returns them under decoded.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.

Adding a new preset

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 in vehicle.dbc (or mcp-can dbc-info <message>) first, the way overheat's 87.5 was chosen deliberately rather than the DBC's declared-but-unencodable 127.5.
  • Does the signal name exist? A typo in overrides won't error — it'll just be silently ignored by SimThread, since it only checks has_override(sig.name) per signal it's actually encoding. tests/test_faults.py::test_every_preset_override_targets_a_real_dbc_signal guards against this for the shipped presets; a new preset should stay covered by the same test (it iterates PRESETS generically).

Clone this wiki locally