"""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 def go(): # actions/routines can take longer than the fast polling window and # may reply 0x78 (pending) — restore slow ELM timing for the run try: self.link.fast_timing(False) except Exception: pass try: return run_action(action, self.link) finally: try: self.link.fast_timing(True) except Exception: pass return self._oneoff(go, timeout=25.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