d435384b58
Profile-defined UDS action sequences, run safely -- the framework for #2 (real per-vehicle actuator tests/resets are follow-on, added as verified profile data). - obdcore/actions.py: Action model + run_action() executing session (Mode 10) -> security (Mode 27 seed->key) -> command steps (2F/31/11/3E/... any hex) with positive/negative response checks. Security KEY algorithms are per-vehicle secrets and NOT bundled -- only trivial transforms (xor-ff/invert/add-ff) known; an action naming an unknown algorithm is BLOCKED (fails safe). Never synthesizes bytes -- runs only what the profile defines. validate_action() rejects malformed hex at load. - profile.py: load/save an actions[] block; ElmLink/MockLink read_raw(hex). - GUI: Diagnostics -> Service & Bi-directional dialog -- lists the profile's actions with risk badges; caution/danger gated behind a warning confirmation. - generic-obd2: two safe STANDARD actions (Tester-Present ping; ECU-Reset, caution + engine-off warning). PROFILE_SPEC.md documents the actions schema + safety rules. - tests/test_actions.py: runner, session+reset, security handshake, unknown-algo block, hex validation, profile load. All 5 suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
186 lines
6.6 KiB
Python
186 lines
6.6 KiB
Python
"""Controller -- owns the obdcore link/registry/store/scheduler for the GUI.
|
|
|
|
Keeps all acquisition concerns out of the widgets. The GUI subscribes/
|
|
unsubscribes PIDs (== what's polled == what's plotted) and reads the store on
|
|
a timer; the scheduler thread does the serial work.
|
|
"""
|
|
import time
|
|
|
|
from obdcore import (PidRegistry, DtcDatabase, TimeSeriesStore, PollScheduler,
|
|
CsvRecorder, load_default, load_profile)
|
|
from obdcore.mock import MockLink
|
|
from obdcore.trip import TripComputer, PerformanceMeter
|
|
|
|
# default poll rates (Hz) -- fast for the no-start metrics, slower for the rest
|
|
FAST = {"ICP", "FICM_M", "RPM"}
|
|
DEFAULT_HZ = 2
|
|
FAST_HZ = 5
|
|
|
|
# DTC services: result-bucket label, request mode string, response service byte
|
|
# (mode "03"->0x43 stored, "07"->0x47 pending, "0A"->0x4A permanent)
|
|
DTC_SERVICES = (
|
|
("stored", "03", 0x43),
|
|
("pending", "07", 0x47),
|
|
("permanent", "0A", 0x4A),
|
|
)
|
|
|
|
|
|
class Controller:
|
|
def __init__(self):
|
|
self.profile = load_default()
|
|
self.reg = PidRegistry(self.profile)
|
|
self.dtcdb = DtcDatabase(self.profile)
|
|
self.store = TimeSeriesStore()
|
|
self.link = None
|
|
self.sched = None
|
|
self.t0 = None
|
|
self.connected = False
|
|
self.trip = TripComputer()
|
|
self.perf = PerformanceMeter()
|
|
self.speed_key = None # PID key for standard speed (mode 01 0D)
|
|
self.maf_key = None # PID key for standard MAF (mode 01 10)
|
|
|
|
def _find_std_keys(self):
|
|
"""Locate the speed/MAF PIDs (mode 01, pid 0D/10) by any key name."""
|
|
self.speed_key = self.maf_key = None
|
|
for p in self.reg.all():
|
|
if p.mode == "01" and p.pid.upper() == "0D":
|
|
self.speed_key = p.key
|
|
elif p.mode == "01" and p.pid.upper() == "10":
|
|
self.maf_key = p.key
|
|
|
|
def load_profile(self, path):
|
|
"""Switch the active vehicle profile (only allowed while disconnected)."""
|
|
self.profile = load_profile(path)
|
|
self.reg = PidRegistry(self.profile)
|
|
self.dtcdb = DtcDatabase(self.profile)
|
|
|
|
def connect(self, port=None, baud=38400, mock=False, conn=None):
|
|
"""conn: optional {'kind': 'serial'|'wifi'|'ble', ...}. Falls back to
|
|
serial(port, baud) for backward compatibility."""
|
|
if mock:
|
|
self.link = MockLink(clock=time.time)
|
|
else:
|
|
from obdcore.link import ElmLink # imported lazily (needs pyserial)
|
|
c = conn or {"kind": "serial", "port": port, "baud": baud}
|
|
kind = c.get("kind", "serial")
|
|
if kind == "wifi":
|
|
self.link = ElmLink.tcp(c["host"], c.get("port", 35000))
|
|
elif kind == "ble":
|
|
self.link = ElmLink.ble(c["address"])
|
|
else:
|
|
self.link = ElmLink.serial(c.get("port", port), c.get("baud", baud))
|
|
self.link.init()
|
|
ok = self.link.connect()
|
|
try:
|
|
self.link.fast_timing(True)
|
|
except Exception:
|
|
pass
|
|
self.sched = PollScheduler(self.link, self.reg, self.store, clock=time.time)
|
|
self.t0 = time.time()
|
|
self.connected = True
|
|
self.trip.reset()
|
|
self.perf = PerformanceMeter()
|
|
# keep speed + MAF polled in the background so trip/performance always run
|
|
self._find_std_keys()
|
|
if self.speed_key:
|
|
self.sched.subscribe(self.speed_key, 2)
|
|
if self.maf_key:
|
|
self.sched.subscribe(self.maf_key, 2)
|
|
return ok
|
|
|
|
def hz_for(self, key):
|
|
return FAST_HZ if key in FAST else DEFAULT_HZ
|
|
|
|
def subscribe(self, key):
|
|
if self.sched:
|
|
self.sched.subscribe(key, self.hz_for(key))
|
|
|
|
def unsubscribe(self, key):
|
|
if self.sched:
|
|
self.sched.unsubscribe(key)
|
|
|
|
def subscribed(self):
|
|
return set(self.sched.subscriptions()) if self.sched else set()
|
|
|
|
def start(self):
|
|
if self.sched:
|
|
self.sched.start()
|
|
|
|
def record(self, path):
|
|
self.store.recorder = CsvRecorder(path)
|
|
|
|
def stop_record(self):
|
|
if self.store.recorder:
|
|
self.store.recorder.close()
|
|
self.store.recorder = None
|
|
|
|
def now(self):
|
|
return (time.time() - self.t0) if self.t0 else 0.0
|
|
|
|
# -- diagnostics (DTCs) --------------------------------------------------
|
|
# All link access goes through the scheduler's one-off path when a
|
|
# scheduler exists, so a DTC read/clear never races the polling thread for
|
|
# the serial link. When disconnected (no scheduler), call the link direct.
|
|
def _oneoff(self, fn, timeout=8.0):
|
|
if self.sched is not None:
|
|
return self.sched.run_oneoff(fn, timeout=timeout)
|
|
if self.link is not None:
|
|
return fn()
|
|
raise RuntimeError("not connected")
|
|
|
|
def read_dtcs(self):
|
|
"""Read stored (03), pending (07) and permanent (0A) DTCs.
|
|
Returns {"stored": [...], "pending": [...], "permanent": [...]}."""
|
|
out = {}
|
|
for label, mode, svc in DTC_SERVICES:
|
|
out[label] = self._oneoff(
|
|
lambda m=mode, s=svc: self.link.read_dtcs(m, s)) or []
|
|
return out
|
|
|
|
def clear_dtcs(self):
|
|
"""Mode 04: clear stored+pending codes and freeze frame.
|
|
Returns True if the ECU acknowledged."""
|
|
return bool(self._oneoff(lambda: self.link.clear_dtcs()))
|
|
|
|
# -- standard OBD services (via the one-off path) --
|
|
def read_vehicle_info(self):
|
|
return self._oneoff(lambda: self.link.read_vehicle_info())
|
|
|
|
def read_readiness(self):
|
|
return self._oneoff(lambda: self.link.read_readiness())
|
|
|
|
def read_freeze_frame(self):
|
|
return self._oneoff(lambda: self.link.read_freeze_frame())
|
|
|
|
# -- bi-directional / service actions --
|
|
def actions(self):
|
|
return self.profile.actions or []
|
|
|
|
def run_action(self, action):
|
|
from obdcore.actions import run_action
|
|
return self._oneoff(lambda: run_action(action, self.link), timeout=20.0)
|
|
|
|
# -- trip / performance (fed from the live store each GUI tick) --
|
|
def update_trip(self):
|
|
spd = self.store.latest(self.speed_key) if self.speed_key else None
|
|
maf = self.store.latest(self.maf_key) if self.maf_key else None
|
|
now = time.time()
|
|
self.trip.update(now, spd, maf)
|
|
self.perf.update(now, spd)
|
|
return spd, maf
|
|
|
|
def stop(self):
|
|
if self.sched:
|
|
self.sched.stop()
|
|
self.sched = None
|
|
self.stop_record()
|
|
if self.link:
|
|
try:
|
|
self.link.fast_timing(False)
|
|
except Exception:
|
|
pass
|
|
self.link.close()
|
|
self.link = None
|
|
self.connected = False
|