Files
obdash/gui/controller.py
T
justin 6548cf7fbe Section 1 GUI: Vehicle Info, Emissions Readiness, Freeze Frame, Trip/Performance
- Diagnostics menu: Vehicle Info (VIN/cal/ECU), Emissions Readiness (I/M
  monitors + MIL -> pass/fail), Freeze Frame (snapshot + capturing DTC).
  All routed through the scheduler one-off path; dialogs, no docked panels.
- New Trip / Performance view (View menu, center page): live + average MPG,
  trip distance/fuel/time, and 0-60 / 1/4-mile timers. The controller keeps
  SPEED + MAF polled in the background and feeds TripComputer/PerformanceMeter
  every tick, so trips accumulate regardless of the active view. Honest MAF
  caveat shown for speed-density/diesel vehicles.

Validated headless against MockLink: VIN dialog, readiness dialog, freeze-frame
dialog, and the live trip page (28.8 mpg / distance accruing). All tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-06-30 19:43:31 -04:00

169 lines
5.8 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):
if mock:
self.link = MockLink(clock=time.time)
else:
from obdcore.link import ElmLink # imported lazily (needs pyserial)
self.link = ElmLink(port, 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())
# -- 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