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
+152 -1
View File
@@ -80,6 +80,13 @@ class MainWindow(QtWidgets.QMainWindow):
"Read stored / pending / permanent trouble codes")
self.clear_dtc_act = self._act(diagm, "Clear Codes…", self._clear_codes,
"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")
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_gauge = self._act(viewm, "Gauge View", lambda: self._set_view(2),
checkable=True)
self.view_trip = self._act(viewm, "Trip / Performance", lambda: self._set_view(3),
checkable=True)
viewm.addSeparator()
self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock,
checkable=True)
@@ -332,6 +341,91 @@ class MainWindow(QtWidgets.QMainWindow):
"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) ----------
def _build_center(self):
self.stack = QtWidgets.QStackedWidget()
@@ -366,9 +460,47 @@ class MainWindow(QtWidgets.QMainWindow):
self.gauges = GaugeGrid()
self.stack.addWidget(self.gauges)
# trip / performance page (center index 3)
self.stack.addWidget(self._build_trip_page())
self.setCentralWidget(self.stack)
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):
"""The active graph widget (multi-axis unless Normalize is on)."""
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_table.setChecked(idx == 1)
self.view_gauge.setChecked(idx == 2)
self.view_trip.setChecked(idx == 3)
def _toggle_pid_dock(self):
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, 4).setText("--" if dhi is None else f"{dhi:g}")
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:
p = self.ctl.reg.get(key)
lo, hi = self.ctl.store.minmax(key)
self.gauges.set_value(key, self._dval(p, self.ctl.store.latest(key)),
peak=self._dval(p, hi))
elif idx == 3: # trip / performance view
self._update_trip_page(spd, maf)
else:
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):
try:
self.timer.stop()