Files
obdash/gui/controller.py
T
justin f3f0bf2a77 Make app vehicle-agnostic: JSON vehicle profiles + menu bar
Vehicle data is now DATA, not code. PIDs/scaling/DTCs/presets live in
profiles/*.json; the app loads them at runtime, so it works across vehicles
and others can contribute profiles (open source).

Core:
- obdcore/formula.py: safe AST evaluator for scaling formulas (A/B/... byte
  vars, Torque/FORScan convention). Only arithmetic/bitwise + min/max/abs/
  round/int/float; names/attrs/arbitrary calls rejected at load -> a community
  profile CANNOT execute code.
- obdcore/profile.py: load/save/list profiles; compiles each formula into a
  decode callable. registry.py now profile-backed (PidRegistry/DtcDatabase
  take a Profile); hardcoded Ford table removed.
- store.py: clear()/snapshot()/export_csv() for capture management.

Profiles:
- profiles/ford-6.0-powerstroke.json (27 PIDs, verified formulas, DTCs)
- profiles/generic-obd2.json (standard SAE Mode-01 base, any vehicle)
- profiles/README.md (schema + formula language + contributing)

GUI:
- Menu bar: File (new/record/export/replay capture, quit), Profile (switch/
  load/import/reload/edit-JSON/export, live profile list), View (Graph/Table
  views, gauges P2, toggle PID dock, normalize, light/dark theme), Help
  (about/confidence legend/profile info).
- PID browser + presets rebuild on profile switch; added Table view; raw-JSON
  profile editor dialog (validates schema+formulas before saving).

Tests: profiles load+compile, formula sandbox rejects hostile input, decoders
still match real truck bytes, crank/derived/dead-PID/replay -- all pass.

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

95 lines
2.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
# 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
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
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
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
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