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:
@@ -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.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user