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
This commit is contained in:
2026-06-30 19:43:31 -04:00
parent 4a4daf3fa0
commit 6548cf7fbe
4 changed files with 200 additions and 3 deletions
+7 -2
View File
@@ -22,8 +22,13 @@ a new vehicle is data, not code. Runs on **Windows, macOS, and Linux**.
- **Gauge view** — round, tach-style gauges with tick scales, needles, **redline - **Gauge view** — round, tach-style gauges with tick scales, needles, **redline
zones** (configurable per metric), and peak-hold. zones** (configurable per metric), and peak-hold.
- **Table view** — value, min/max, and confidence per signal. - **Table view** — value, min/max, and confidence per signal.
- **Diagnostics** — read stored/pending/permanent trouble codes and clear them - **Diagnostics** — read/clear trouble codes (guarded), with descriptions from a
(guarded), with descriptions and **no-start codes flagged**. built-in **1,400+ generic SAE DTC database** (profiles override) and no-start
codes flagged; plus **freeze-frame** (the snapshot when a code set).
- **Emissions readiness** — I/M monitor status + MIL → a "will it pass inspection?"
report. **Vehicle info** — VIN, calibration IDs, ECU name (Mode 09).
- **Trip / Performance** — live MPG, trip distance/fuel, and **0-60 mph & 1/4-mile**
timers (auto-detected from a standing start).
- **Vehicle profiles** — switch/import/edit vehicles from the Profile menu. - **Vehicle profiles** — switch/import/edit vehicles from the Profile menu.
- **Units** — °C/°F toggle (US/metric). - **Units** — °C/°F toggle (US/metric).
- **Captures** — record a session to CSV and replay it. - **Captures** — record a session to CSV and replay it.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+41
View File
@@ -9,6 +9,7 @@ import time
from obdcore import (PidRegistry, DtcDatabase, TimeSeriesStore, PollScheduler, from obdcore import (PidRegistry, DtcDatabase, TimeSeriesStore, PollScheduler,
CsvRecorder, load_default, load_profile) CsvRecorder, load_default, load_profile)
from obdcore.mock import MockLink 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 # default poll rates (Hz) -- fast for the no-start metrics, slower for the rest
FAST = {"ICP", "FICM_M", "RPM"} FAST = {"ICP", "FICM_M", "RPM"}
@@ -34,6 +35,19 @@ class Controller:
self.sched = None self.sched = None
self.t0 = None self.t0 = None
self.connected = False 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): def load_profile(self, path):
"""Switch the active vehicle profile (only allowed while disconnected).""" """Switch the active vehicle profile (only allowed while disconnected)."""
@@ -56,6 +70,14 @@ class Controller:
self.sched = PollScheduler(self.link, self.reg, self.store, clock=time.time) self.sched = PollScheduler(self.link, self.reg, self.store, clock=time.time)
self.t0 = time.time() self.t0 = time.time()
self.connected = True 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 return ok
def hz_for(self, key): def hz_for(self, key):
@@ -112,6 +134,25 @@ class Controller:
Returns True if the ECU acknowledged.""" Returns True if the ECU acknowledged."""
return bool(self._oneoff(lambda: self.link.clear_dtcs())) 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): def stop(self):
if self.sched: if self.sched:
self.sched.stop() self.sched.stop()
+152 -1
View File
@@ -80,6 +80,13 @@ class MainWindow(QtWidgets.QMainWindow):
"Read stored / pending / permanent trouble codes") "Read stored / pending / permanent trouble codes")
self.clear_dtc_act = self._act(diagm, "Clear Codes…", self._clear_codes, self.clear_dtc_act = self._act(diagm, "Clear Codes…", self._clear_codes,
"Erase stored codes + freeze frame (mode 04)") "Erase stored codes + freeze frame (mode 04)")
diagm.addSeparator()
self._act(diagm, "Freeze Frame", self._freeze_frame,
"Sensor snapshot captured when a code set (mode 02)")
self._act(diagm, "Emissions Readiness", self._readiness,
"I/M readiness monitors + MIL (will it pass inspection?)")
self._act(diagm, "Vehicle Info (VIN)", self._vehicle_info,
"VIN, calibration IDs, ECU name (mode 09)")
viewm = mb.addMenu("&View") viewm = mb.addMenu("&View")
self.view_graph = self._act(viewm, "Graph View", lambda: self._set_view(0), self.view_graph = self._act(viewm, "Graph View", lambda: self._set_view(0),
@@ -89,6 +96,8 @@ class MainWindow(QtWidgets.QMainWindow):
self.view_graph.setChecked(True) self.view_graph.setChecked(True)
self.view_gauge = self._act(viewm, "Gauge View", lambda: self._set_view(2), self.view_gauge = self._act(viewm, "Gauge View", lambda: self._set_view(2),
checkable=True) checkable=True)
self.view_trip = self._act(viewm, "Trip / Performance", lambda: self._set_view(3),
checkable=True)
viewm.addSeparator() viewm.addSeparator()
self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock, self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock,
checkable=True) checkable=True)
@@ -332,6 +341,91 @@ class MainWindow(QtWidgets.QMainWindow):
"Cleared. No codes on re-read.") "Cleared. No codes on re-read.")
self.status.showMessage("Cleared. No codes on re-read.") self.status.showMessage("Cleared. No codes on re-read.")
# ---------- standard OBD services (dialogs) ----------
def _need_connection(self):
if not self.ctl.connected:
QtWidgets.QMessageBox.information(
self, "Not connected", "Connect (or tick Mock) first.")
return False
return True
def _vehicle_info(self):
if not self._need_connection():
return
try:
info = self.ctl.read_vehicle_info() or {}
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
rows = [("VIN", info.get("vin") or ""),
("Calibration ID", info.get("calibration") or ""),
("ECU Name", info.get("ecu_name") or "")]
text = "\n".join(f"{k}:\t{v}" for k, v in rows)
QtWidgets.QMessageBox.information(self, "Vehicle Info", text)
self.status.showMessage(f"VIN: {info.get('vin') or 'not reported'}")
def _readiness(self):
if not self._need_connection():
return
try:
r = self.ctl.read_readiness()
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
if not r:
QtWidgets.QMessageBox.information(self, "Readiness", "No readiness data returned.")
return
dlg = QtWidgets.QDialog(self); dlg.setWindowTitle("Emissions Readiness")
dlg.resize(420, 360)
lay = QtWidgets.QVBoxLayout(dlg)
not_ready = [m for m in r["monitors"] if not m["ready"]]
passed = (not r["mil"]) and r["dtc_count"] == 0 and len(not_ready) <= 1
head = QtWidgets.QLabel(
f"<b>{'LIKELY PASS' if passed else 'NOT READY'}</b> — "
f"MIL {'ON' if r['mil'] else 'off'}, {r['dtc_count']} code(s), "
f"{r['ready_count']}/{r['total']} monitors ready "
f"({r['ignition']} ignition)")
head.setStyleSheet(f"color:{'#3cb44b' if passed else '#e6a23c'};")
head.setWordWrap(True); lay.addWidget(head)
tree = QtWidgets.QTreeWidget(); tree.setHeaderLabels(["Monitor", "Status"])
tree.header().setStretchLastSection(False)
tree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
for m in r["monitors"]:
it = QtWidgets.QTreeWidgetItem([m["name"], "READY" if m["ready"] else "not ready"])
it.setForeground(1, QtGui.QBrush(QtGui.QColor("#3cb44b" if m["ready"] else "#e6a23c")))
tree.addTopLevelItem(it)
lay.addWidget(tree)
bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
bb.rejected.connect(dlg.reject); lay.addWidget(bb)
dlg.exec()
self.status.showMessage(f"Readiness: {r['ready_count']}/{r['total']} ready, "
f"MIL {'on' if r['mil'] else 'off'}")
def _freeze_frame(self):
if not self._need_connection():
return
try:
ff = self.ctl.read_freeze_frame() or {}
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
vals = ff.get("values") or []
if not vals and not ff.get("dtc"):
QtWidgets.QMessageBox.information(self, "Freeze Frame",
"No freeze-frame data stored (no fault has captured one).")
return
dlg = QtWidgets.QDialog(self); dlg.setWindowTitle("Freeze Frame")
dlg.resize(440, 380)
lay = QtWidgets.QVBoxLayout(dlg)
d = ff.get("dtc")
cap = self.ctl.dtcdb.get(d).desc if d else "(unknown)"
lay.addWidget(QtWidgets.QLabel(f"Captured by: <b>{d or ''}</b> — {cap}"))
tree = QtWidgets.QTreeWidget(); tree.setHeaderLabels(["Signal", "Value"])
tree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
for name, val, unit in vals:
tree.addTopLevelItem(QtWidgets.QTreeWidgetItem([name, f"{val} {unit}".strip()]))
lay.addWidget(tree)
bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
bb.rejected.connect(dlg.reject); lay.addWidget(bb)
dlg.exec()
# ---------- center (graph + table stack) ---------- # ---------- center (graph + table stack) ----------
def _build_center(self): def _build_center(self):
self.stack = QtWidgets.QStackedWidget() self.stack = QtWidgets.QStackedWidget()
@@ -366,9 +460,47 @@ class MainWindow(QtWidgets.QMainWindow):
self.gauges = GaugeGrid() self.gauges = GaugeGrid()
self.stack.addWidget(self.gauges) self.stack.addWidget(self.gauges)
# trip / performance page (center index 3)
self.stack.addWidget(self._build_trip_page())
self.setCentralWidget(self.stack) self.setCentralWidget(self.stack)
self._apply_theme() self._apply_theme()
def _build_trip_page(self):
page = QtWidgets.QWidget()
page.setStyleSheet("background:#111; color:#ddd;")
lay = QtWidgets.QVBoxLayout(page)
lay.setContentsMargins(24, 24, 24, 24)
self._trip_labels = {}
def big(title):
box = QtWidgets.QFrame()
box.setStyleSheet("QFrame{background:#1a1a1a;border-radius:8px;}")
v = QtWidgets.QVBoxLayout(box)
t = QtWidgets.QLabel(title); t.setStyleSheet("color:#999;font-size:11px;")
val = QtWidgets.QLabel("--"); val.setStyleSheet("color:#fff;font-size:26px;font-weight:bold;")
v.addWidget(t); v.addWidget(val)
return box, val
grid = QtWidgets.QGridLayout()
cards = [("Instant MPG", "inst_mpg"), ("Average MPG", "avg_mpg"),
("Trip Distance (mi)", "dist"), ("Fuel Used (gal)", "fuel"),
("0-60 mph (s)", "zero60"), ("1/4 mile (s)", "quarter"),
("Speed (mph)", "speed"), ("Trip Time", "time")]
for i, (title, key) in enumerate(cards):
box, val = big(title)
self._trip_labels[key] = val
grid.addWidget(box, i // 4, i % 4)
lay.addLayout(grid)
self._trip_note = QtWidgets.QLabel(
"MPG needs a MAF sensor (speed-density/diesel vehicles report distance + "
"0-60 only). Best 0-60 / 1/4-mile are kept; pull away from a stop to time a run.")
self._trip_note.setWordWrap(True)
self._trip_note.setStyleSheet("color:#888;font-size:11px;")
lay.addWidget(self._trip_note)
lay.addStretch(1)
return page
def _graph(self): def _graph(self):
"""The active graph widget (multi-axis unless Normalize is on).""" """The active graph widget (multi-axis unless Normalize is on)."""
return self.single if self.norm_chk.isChecked() else self.multi return self.single if self.norm_chk.isChecked() else self.multi
@@ -606,6 +738,7 @@ class MainWindow(QtWidgets.QMainWindow):
self.view_graph.setChecked(idx == 0) self.view_graph.setChecked(idx == 0)
self.view_table.setChecked(idx == 1) self.view_table.setChecked(idx == 1)
self.view_gauge.setChecked(idx == 2) self.view_gauge.setChecked(idx == 2)
self.view_trip.setChecked(idx == 3)
def _toggle_pid_dock(self): def _toggle_pid_dock(self):
self.pid_dock.setVisible(self.show_pids.isChecked()) self.pid_dock.setVisible(self.show_pids.isChecked())
@@ -743,15 +876,33 @@ class MainWindow(QtWidgets.QMainWindow):
self.table.item(r, 3).setText("--" if dlo is None else f"{dlo:g}") self.table.item(r, 3).setText("--" if dlo is None else f"{dlo:g}")
self.table.item(r, 4).setText("--" if dhi is None else f"{dhi:g}") self.table.item(r, 4).setText("--" if dhi is None else f"{dhi:g}")
self.tree.blockSignals(False) self.tree.blockSignals(False)
if self.stack.currentIndex() == 2: # gauge view spd, maf = self.ctl.update_trip() # accumulate trip/perf every tick
idx = self.stack.currentIndex()
if idx == 2: # gauge view
for key in self.curves: for key in self.curves:
p = self.ctl.reg.get(key) p = self.ctl.reg.get(key)
lo, hi = self.ctl.store.minmax(key) lo, hi = self.ctl.store.minmax(key)
self.gauges.set_value(key, self._dval(p, self.ctl.store.latest(key)), self.gauges.set_value(key, self._dval(p, self.ctl.store.latest(key)),
peak=self._dval(p, hi)) peak=self._dval(p, hi))
elif idx == 3: # trip / performance view
self._update_trip_page(spd, maf)
else: else:
self._redraw_curves() self._redraw_curves()
def _update_trip_page(self, spd, maf):
t, s = self.ctl.trip, self.ctl.trip.stats()
L = self._trip_labels
L["inst_mpg"].setText(f"{t.instant_mpg(spd, maf):.1f}" if (spd and maf) else "--")
L["avg_mpg"].setText(f"{s['avg_mpg']:.1f}" if self.ctl.maf_key else "n/a (no MAF)")
L["dist"].setText(f"{s['distance_mi']:.2f}")
L["fuel"].setText(f"{s['fuel_gal']:.3f}" if self.ctl.maf_key else "n/a")
L["speed"].setText(f"{spd / 1.60934:.0f}" if spd is not None else "--")
mm, ss = divmod(int(s["elapsed_s"]), 60)
L["time"].setText(f"{mm}:{ss:02d}")
pm = self.ctl.perf
L["zero60"].setText(f"{pm.best_0_60}" if pm.best_0_60 else "--")
L["quarter"].setText(f"{pm.best_quarter}" if pm.best_quarter else "--")
def closeEvent(self, ev): def closeEvent(self, ev):
try: try:
self.timer.stop() self.timer.stop()