diff --git a/README.md b/README.md index 9650d75..1659a70 100644 --- a/README.md +++ b/README.md @@ -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 zones** (configurable per metric), and peak-hold. - **Table view** — value, min/max, and confidence per signal. -- **Diagnostics** — read stored/pending/permanent trouble codes and clear them - (guarded), with descriptions and **no-start codes flagged**. +- **Diagnostics** — read/clear trouble codes (guarded), with descriptions from a + 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. - **Units** — °C/°F toggle (US/metric). - **Captures** — record a session to CSV and replay it. diff --git a/docs/gui-trip.png b/docs/gui-trip.png new file mode 100644 index 0000000..d901ed4 Binary files /dev/null and b/docs/gui-trip.png differ diff --git a/gui/controller.py b/gui/controller.py index 520b6ce..29ac200 100644 --- a/gui/controller.py +++ b/gui/controller.py @@ -9,6 +9,7 @@ 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"} @@ -34,6 +35,19 @@ class Controller: 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).""" @@ -56,6 +70,14 @@ class Controller: 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): @@ -112,6 +134,25 @@ class Controller: 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() diff --git a/gui/main.py b/gui/main.py index b46540e..33fa817 100644 --- a/gui/main.py +++ b/gui/main.py @@ -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"{'LIKELY PASS' if passed else 'NOT READY'} — " + 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: {d or '—'} — {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()