"""OBDash GUI -- vehicle-agnostic scanner shell. Menu bar: File (captures) | Profile (vehicle profiles) | View | Help. Toolbar: port / baud / mock / connect + per-profile preset buttons. Left: PID browser (live values, confidence badges, checkboxes). Center: stacked Graph view (pyqtgraph overlay) and Table view. Vehicle data comes entirely from the active JSON profile (profiles/*.json), so the app is not Ford-specific -- load another profile to scan another truck. Runs against MockLink with no hardware (tick "Mock" + Connect). """ import os import shutil from PySide6 import QtCore, QtGui, QtWidgets import pyqtgraph as pg from obdcore import (list_profiles, profiles_dir, save_profile, load_profile, export_csv, replay_csv, TimeSeriesStore) from .controller import Controller from .widgets import MultiAxisPlot, SinglePlot, GaugeGrid PLOT_WINDOW_S = 60.0 REFRESH_MS = 100 CURVE_COLORS = [ "#e6194B", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#42d4f4", "#f032e6", "#bfef45", "#fabed4", "#469990", "#9A6324", "#ffe119", "#000075", "#a9a9a9", "#800000", ] GROUP_ORDER = ["fuel", "ficm", "air", "engine", "driveline", "power", "misc"] GROUP_LABEL = {"fuel": "Fuel / Injection", "ficm": "FICM", "air": "Air / Boost", "engine": "Engine", "driveline": "Driveline", "power": "Power", "misc": "Other"} CONF_BADGE = {"verified": "", "doc": " [DOC]", "tentative": " [?]"} THEMES = {"dark": ("#111", "#ccc"), "light": ("#fafafa", "#222")} class MainWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.ctl = Controller() self.curves = {} self._color_i = 0 self._theme = "dark" self._temp_f = False # display temperatures in F instead of C self._build_menubar() self._build_connection_bar() self._build_pid_browser() self._build_center() self._build_statusbar() self._refresh_title() self._rebuild_for_profile() self.timer = QtCore.QTimer(self) self.timer.timeout.connect(self._tick) self.timer.setInterval(REFRESH_MS) # ---------- menus ---------- def _build_menubar(self): mb = self.menuBar() filem = mb.addMenu("&File") self._act(filem, "New Capture", self._new_capture, "Clear the current data buffers") filem.addSeparator() self.rec_act = self._act(filem, "Start Recording…", self._toggle_record, "Stream samples to a CSV as they arrive") self._act(filem, "Export Capture As… (CSV)", self._export_capture, "Write the current in-memory buffers to a CSV") self._act(filem, "Open Capture (Replay)…", self._open_capture, "Load a recorded CSV and plot it (disconnect first)") filem.addSeparator() self._act(filem, "Quit", self.close, shortcut="Ctrl+Q") self.profm = mb.addMenu("&Profile") self._rebuild_profile_menu() diagm = mb.addMenu("&Diagnostics") self.read_dtc_act = self._act(diagm, "Read Codes", self._read_codes, "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)") viewm = mb.addMenu("&View") self.view_graph = self._act(viewm, "Graph View", lambda: self._set_view(0), checkable=True) self.view_table = self._act(viewm, "Table View", lambda: self._set_view(1), checkable=True) self.view_graph.setChecked(True) self.view_gauge = self._act(viewm, "Gauge View", lambda: self._set_view(2), checkable=True) viewm.addSeparator() self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock, checkable=True) self.show_pids.setChecked(True) self.norm_act = self._act(viewm, "Normalize Graph (% of range)", self._sync_norm_from_menu, checkable=True) viewm.addSeparator() self.theme_act = self._act(viewm, "Light Theme", self._toggle_theme, checkable=True) unitsm = mb.addMenu("&Units") self._temp_grp = QtGui.QActionGroup(self); self._temp_grp.setExclusive(True) self.temp_c_act = self._act(unitsm, "Temperature: °C", lambda: self._set_temp(False), checkable=True) self.temp_f_act = self._act(unitsm, "Temperature: °F", lambda: self._set_temp(True), checkable=True) self._temp_grp.addAction(self.temp_c_act); self._temp_grp.addAction(self.temp_f_act) self.temp_c_act.setChecked(True) helpm = mb.addMenu("&Help") self._act(helpm, "About OBDash", self._about) self._act(helpm, "PID Confidence Legend", self._legend) self._act(helpm, "Active Profile Info", self._profile_info) def _act(self, menu, text, slot, tip="", checkable=False, shortcut=None): a = QtGui.QAction(text, self) a.triggered.connect(lambda _=False: slot()) if tip: a.setStatusTip(tip) a.setCheckable(checkable) if shortcut: a.setShortcut(shortcut) menu.addAction(a) return a def _rebuild_profile_menu(self): self.profm.clear() self._profile_group = QtGui.QActionGroup(self) self._profile_group.setExclusive(True) active = getattr(self.ctl.profile, "path", None) for path, meta in list_profiles(): a = QtGui.QAction(meta.get("name", os.path.basename(path)), self) a.setCheckable(True) a.setChecked(active and os.path.abspath(path) == os.path.abspath(active)) a.triggered.connect(lambda _=False, p=path: self._load_profile(p)) self._profile_group.addAction(a) self.profm.addAction(a) self.profm.addSeparator() self._act(self.profm, "Load Profile…", self._load_profile_dialog) self._act(self.profm, "Import Profile…", self._import_profile) self._act(self.profm, "Reload Active", self._reload_profile) self._act(self.profm, "Edit Active Profile (JSON)…", self._edit_profile) self._act(self.profm, "Export Active Profile As…", self._export_profile) # ---------- toolbar ---------- def _build_connection_bar(self): tb = QtWidgets.QToolBar("Connection") tb.setMovable(False) self.addToolBar(tb) tb.addWidget(QtWidgets.QLabel(" Port ")) self.port_combo = QtWidgets.QComboBox() self.port_combo.setMinimumWidth(180) self._refresh_ports() tb.addWidget(self.port_combo) b = QtWidgets.QToolButton(); b.setText("↻"); b.clicked.connect(self._refresh_ports) tb.addWidget(b) tb.addWidget(QtWidgets.QLabel(" Baud ")) self.baud_edit = QtWidgets.QLineEdit("38400"); self.baud_edit.setFixedWidth(70) tb.addWidget(self.baud_edit) self.mock_chk = QtWidgets.QCheckBox("Mock"); tb.addWidget(self.mock_chk) self.connect_btn = QtWidgets.QPushButton("Connect") self.connect_btn.clicked.connect(self._toggle_connect) tb.addWidget(self.connect_btn) tb.addSeparator() self._preset_tb = tb self._preset_sep = tb.addSeparator() self._preset_buttons = [] def _rebuild_preset_buttons(self): for b in self._preset_buttons: self._preset_tb.removeAction(b) self._preset_buttons = [] for name in self.ctl.reg.preset_names(): btn = QtWidgets.QPushButton(name.capitalize()) btn.setEnabled(self.ctl.connected) btn.clicked.connect(lambda _=False, n=name: self._apply_preset(n)) self._preset_buttons.append(self._preset_tb.addWidget(btn)) self._preset_buttons[-1].setProperty("presetbtn", True) btn.setProperty("preset", True) # ---------- PID browser ---------- def _build_pid_browser(self): self.pid_dock = QtWidgets.QDockWidget("PIDs", self) self.tree = QtWidgets.QTreeWidget() self.tree.setColumnCount(2) self.tree.setHeaderLabels(["Signal", "Value"]) self.tree.itemChanged.connect(self._on_item_changed) self.pid_dock.setWidget(self.tree) self.pid_dock.visibilityChanged.connect( lambda vis: self.show_pids.setChecked(vis)) self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, self.pid_dock) def _populate_tree(self): self.tree.blockSignals(True) self.tree.clear() self._items = {} groups = {} for p in self.ctl.reg.all(): g = groups.get(p.group) if g is None: g = QtWidgets.QTreeWidgetItem([GROUP_LABEL.get(p.group, p.group), ""]) g.setFlags(QtCore.Qt.ItemIsEnabled) f = g.font(0); f.setBold(True); g.setFont(0, f) groups[p.group] = g it = QtWidgets.QTreeWidgetItem([f"{p.name}{CONF_BADGE.get(p.confidence,'')}", "--"]) it.setFlags(QtCore.Qt.ItemIsUserCheckable | QtCore.Qt.ItemIsEnabled) it.setCheckState(0, QtCore.Qt.Unchecked) it.setData(0, QtCore.Qt.UserRole, p.key) it.setToolTip(0, f"{p.key} (mode {p.mode} {p.pid}) {p.unit} {p.notes}") g.addChild(it) self._items[p.key] = it for gk in GROUP_ORDER: if gk in groups: self.tree.addTopLevelItem(groups[gk]) for gk, g in groups.items(): # any custom groups not in GROUP_ORDER if gk not in GROUP_ORDER: self.tree.addTopLevelItem(g) self.tree.expandAll() self.tree.resizeColumnToContents(0) self.tree.blockSignals(False) # ---------- diagnostics (DTCs) -- menu-driven dialogs, no docked panel ---------- _DIAG_GROUPS = [("stored", "Stored (mode 03)"), ("pending", "Pending (mode 07)"), ("permanent", "Permanent (mode 0A)")] def _codes_tree(self, codes): """Build a populated QTreeWidget of codes for a dialog (not docked).""" tree = QtWidgets.QTreeWidget() tree.setColumnCount(3) tree.setHeaderLabels(["Code", "Description", "System"]) tree.header().setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch) total = 0 for bucket, label in self._DIAG_GROUPS: lst = codes.get(bucket, []) total += len(lst) top = QtWidgets.QTreeWidgetItem([f"{label} ({len(lst)})", "", ""]) f = top.font(0); f.setBold(True); top.setFont(0, f) tree.addTopLevelItem(top) if not lst: none = QtWidgets.QTreeWidgetItem(["—", "(no codes)", ""]) none.setForeground(1, QtGui.QBrush(QtGui.QColor("#888"))) top.addChild(none) for code in lst: d = self.ctl.dtcdb.get(code) it = QtWidgets.QTreeWidgetItem([code, d.desc, d.system]) if getattr(d, "no_start", False): red = QtGui.QBrush(QtGui.QColor("#e6194B")) bf = it.font(0); bf.setBold(True) for c in range(3): it.setFont(c, bf); it.setForeground(c, red) it.setToolTip(0, "No-start / drive-disabling fault") top.addChild(it) top.setExpanded(True) tree.resizeColumnToContents(0) tree.resizeColumnToContents(2) return tree, total def _show_codes_dialog(self, codes, title, header=""): dlg = QtWidgets.QDialog(self) dlg.setWindowTitle(title) dlg.resize(560, 360) lay = QtWidgets.QVBoxLayout(dlg) tree, total = self._codes_tree(codes) if header: lbl = QtWidgets.QLabel(header); lbl.setWordWrap(True); lay.addWidget(lbl) lay.addWidget(tree) foot = QtWidgets.QLabel("Bold red = no-start / drive-disabling fault.") foot.setStyleSheet("color:#888;"); lay.addWidget(foot) bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close) bb.rejected.connect(dlg.reject); bb.accepted.connect(dlg.accept) lay.addWidget(bb) dlg.exec() return total def _read_codes(self): if not self.ctl.connected: QtWidgets.QMessageBox.information( self, "Not connected", "Connect (or tick Mock) before reading codes.") return try: codes = self.ctl.read_dtcs() except Exception as e: QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return total = self._show_codes_dialog(codes, "Trouble Codes") self.status.showMessage(f"Read codes: {total} found." if total else "Read codes: none stored.") def _clear_codes(self): if not self.ctl.connected: QtWidgets.QMessageBox.information( self, "Not connected", "Connect (or tick Mock) before clearing codes.") return btn = QtWidgets.QMessageBox.warning( self, "Clear codes?", "This erases stored + pending codes AND freeze-frame data, and " "resets emissions monitors.\n\n" "Read the codes first — on a no-start especially. If the fault is " "still present the code comes right back.\n" "Permanent codes (mode 0A) will NOT clear until the fault is fixed " "and the vehicle self-clears them over several drive cycles.\n\n" "Clear codes now?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No) if btn != QtWidgets.QMessageBox.Yes: self.status.showMessage("Cancelled. No codes cleared.") return try: ok = self.ctl.clear_dtcs() except Exception as e: QtWidgets.QMessageBox.critical(self, "Clear failed", str(e)); return if not ok: QtWidgets.QMessageBox.warning( self, "No acknowledgement", "The ECU did not acknowledge the clear.\n" "Make sure the key is in RUN and connected, then try again.") self.status.showMessage("Clear not acknowledged by ECU.") return try: codes = self.ctl.read_dtcs() except Exception: codes = {} returned = sum(len(codes.get(b, [])) for b, _ in self._DIAG_GROUPS) if returned: self._show_codes_dialog( codes, "Cleared — codes returned", "These codes came back immediately after clearing — an active " "fault is still present:") self.status.showMessage(f"Cleared — {returned} code(s) returned (active fault).") else: QtWidgets.QMessageBox.information(self, "Codes cleared", "Cleared. No codes on re-read.") self.status.showMessage("Cleared. No codes on re-read.") # ---------- center (graph + table stack) ---------- def _build_center(self): self.stack = QtWidgets.QStackedWidget() # graph page: a sub-stack of multi-axis (raw) and single-axis (normalized) gpage = QtWidgets.QWidget(); gl = QtWidgets.QVBoxLayout(gpage) gl.setContentsMargins(4, 4, 4, 4) bar = QtWidgets.QHBoxLayout() self.norm_chk = QtWidgets.QCheckBox("Normalize (% of range)") self.norm_chk.toggled.connect(self._set_normalize) bar.addWidget(self.norm_chk) bar.addWidget(QtWidgets.QLabel(" (off = true multi-axis, one Y scale per unit)")) bar.addStretch(1) bar.addWidget(QtWidgets.QLabel(f"window: {int(PLOT_WINDOW_S)}s")) gl.addLayout(bar) self.multi = MultiAxisPlot() self.single = SinglePlot() self.graph_stack = QtWidgets.QStackedWidget() self.graph_stack.addWidget(self.multi) # index 0 = multi-axis self.graph_stack.addWidget(self.single) # index 1 = normalized gl.addWidget(self.graph_stack) self.stack.addWidget(gpage) # center index 0 = graph # table page (center index 1) self.table = QtWidgets.QTableWidget(0, 6) self.table.setHorizontalHeaderLabels(["Signal", "Value", "Unit", "Min", "Max", "Conf"]) self.table.horizontalHeader().setStretchLastSection(True) self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self.stack.addWidget(self.table) # gauge page (center index 2) self.gauges = GaugeGrid() self.stack.addWidget(self.gauges) self.setCentralWidget(self.stack) self._apply_theme() def _graph(self): """The active graph widget (multi-axis unless Normalize is on).""" return self.single if self.norm_chk.isChecked() else self.multi def _build_statusbar(self): self.status = self.statusBar() self.status.showMessage("Not connected. Pick a port (or Mock) and Connect.") # ---------- profile lifecycle ---------- def _rebuild_for_profile(self): self._populate_tree() self._rebuild_preset_buttons() self._populate_table_rows() self._refresh_title() def _refresh_title(self): self.setWindowTitle(f"OBDash — {self.ctl.profile.name}") def _load_profile(self, path): if self.ctl.connected: QtWidgets.QMessageBox.information(self, "Disconnect first", "Disconnect before switching vehicle profiles.") self._rebuild_profile_menu() return try: self.ctl.load_profile(path) except Exception as e: QtWidgets.QMessageBox.critical(self, "Profile load failed", str(e)) return self.curves.clear(); self._color_i = 0 self.multi.clear(); self.single.clear(); self.gauges.rebuild([]) self._rebuild_for_profile() self._rebuild_profile_menu() self.status.showMessage(f"Loaded profile: {self.ctl.profile.name}") def _load_profile_dialog(self): path, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Load vehicle profile", profiles_dir(), "Profiles (*.json)") if path: self._load_profile(path) def _import_profile(self): path, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Import vehicle profile", "", "Profiles (*.json)") if not path: return dest = os.path.join(profiles_dir(), os.path.basename(path)) try: load_profile(path) # validate before copying in shutil.copyfile(path, dest) except Exception as e: QtWidgets.QMessageBox.critical(self, "Import failed", str(e)) return self._load_profile(dest) def _reload_profile(self): if getattr(self.ctl.profile, "path", None): self._load_profile(self.ctl.profile.path) def _export_profile(self): path, _ = QtWidgets.QFileDialog.getSaveFileName( self, "Export active profile", profiles_dir(), "Profiles (*.json)") if path: save_profile(self.ctl.profile, path) self.status.showMessage(f"Exported profile to {path}") def _edit_profile(self): p = getattr(self.ctl.profile, "path", None) if not p: return dlg = JsonEditDialog(p, self) if dlg.exec() == QtWidgets.QDialog.Accepted and not self.ctl.connected: self._load_profile(p) def _profile_info(self): m = self.ctl.profile.meta text = "\n".join(f"{k}: {v}" for k, v in m.items()) QtWidgets.QMessageBox.information(self, "Active profile", text or "(no metadata)") # ---------- connection ---------- def _refresh_ports(self): self.port_combo.clear() try: from obdcore.link import find_ports ports = find_ports() except Exception: ports = [] for p in ports: self.port_combo.addItem(f"{p.device} ({p.description})", p.device) if not ports: self.port_combo.addItem("(no ports found)", None) def _toggle_connect(self): if self.ctl.connected: self._disconnect(); return port = self.port_combo.currentData() try: baud = int(self.baud_edit.text()) except ValueError: baud = 38400 try: ok = self.ctl.connect(port=port, baud=baud, mock=self.mock_chk.isChecked()) except Exception as e: QtWidgets.QMessageBox.critical(self, "Connect failed", str(e)); return self.ctl.start(); self.timer.start() self.connect_btn.setText("Disconnect") for b in self.findChildren(QtWidgets.QPushButton): if b.property("preset"): b.setEnabled(True) kind = "MOCK" if self.mock_chk.isChecked() else "ELM327" self.status.showMessage(f"Connected ({kind}) protocol " f"{getattr(self.ctl.link,'protocol','?')} " f"{'(ECU answered)' if ok else '(no 0100 ack — key to RUN?)'}") names = self.ctl.reg.preset_names() if names: self._apply_preset(names[0]) def _disconnect(self): self.timer.stop() for key in list(self.curves): self._remove_curve(key) self.ctl.stop() self.rec_act.setText("Start Recording…") self.tree.blockSignals(True) for it in self._items.values(): it.setCheckState(0, QtCore.Qt.Unchecked); it.setText(1, "--") self.tree.blockSignals(False) self.connect_btn.setText("Connect") for b in self.findChildren(QtWidgets.QPushButton): if b.property("preset"): b.setEnabled(False) self.status.showMessage("Disconnected.") # ---------- PID selection ---------- def _apply_preset(self, name): if not self.ctl.connected: return wanted = set(self.ctl.reg.presets.get(name, [])) self.tree.blockSignals(True) for key, it in self._items.items(): it.setCheckState(0, QtCore.Qt.Checked if key in wanted else QtCore.Qt.Unchecked) self.tree.blockSignals(False) for key in self._items: self._sync_key(key) def _on_item_changed(self, item, col): if col == 0: key = item.data(0, QtCore.Qt.UserRole) if key: self._sync_key(key) def _sync_key(self, key): checked = self._items[key].checkState(0) == QtCore.Qt.Checked has = key in self.curves if checked and not has: if self.ctl.connected: self.ctl.subscribe(key) self._add_curve(key) elif not checked and has: self.ctl.unsubscribe(key) self._remove_curve(key) def _add_curve(self, key): p = self.ctl.reg.get(key) color = CURVE_COLORS[self._color_i % len(CURVE_COLORS)]; self._color_i += 1 self.curves[key] = color # curves maps key -> color self._graph().add_curve(key, f"{p.name} ({self._dunit(p)})", self._dunit(p), color) self._refresh_gauges() def _remove_curve(self, key): if self.curves.pop(key, None) is None: return self.multi.remove_curve(key) self.single.remove_curve(key) self._refresh_gauges() def _set_normalize(self, on): """Swap between true multi-axis (raw) and single-axis (% of range).""" self.norm_act.setChecked(on) self.multi.clear(); self.single.clear() self.graph_stack.setCurrentIndex(1 if on else 0) active = self._graph() for key, color in self.curves.items(): p = self.ctl.reg.get(key) active.add_curve(key, f"{p.name} ({self._dunit(p)})", self._dunit(p), color) self._apply_theme() self._redraw_curves(static=not self.ctl.connected) # ---------- display units (C / F) ---------- def _is_temp(self, p): return p.unit == "C" def _dval(self, p, v): if v is None: return None return v * 9 / 5 + 32 if (self._temp_f and self._is_temp(p)) else v def _dunit(self, p): return "F" if (self._temp_f and self._is_temp(p)) else p.unit def _dzones(self, p): z = {"warn_hi": p.warn_hi, "redline_hi": p.redline_hi, "warn_lo": p.warn_lo, "redline_lo": p.redline_lo} if self._temp_f and self._is_temp(p): z = {k: (None if x is None else x * 9 / 5 + 32) for k, x in z.items()} return z def _set_temp(self, to_f): if to_f == self._temp_f: return self._temp_f = to_f # graph: relabel + rescale by rebuilding curves on the active widget self.multi.clear(); self.single.clear() active = self._graph() for key, color in self.curves.items(): p = self.ctl.reg.get(key) active.add_curve(key, f"{p.name} ({self._dunit(p)})", self._dunit(p), color) for key, r in getattr(self, "_table_row", {}).items(): self.table.item(r, 2).setText(self._dunit(self.ctl.reg.get(key))) self._refresh_gauges() if self.ctl.connected: self._tick() def _refresh_gauges(self): specs = [] for key, color in self.curves.items(): p = self.ctl.reg.get(key) specs.append((key, p.name, self._dunit(p), self._dval(p, p.vmin), self._dval(p, p.vmax), color, self._dzones(p))) self.gauges.rebuild(specs) # ---------- view ---------- def _set_view(self, idx): self.stack.setCurrentIndex(idx) self.view_graph.setChecked(idx == 0) self.view_table.setChecked(idx == 1) self.view_gauge.setChecked(idx == 2) def _toggle_pid_dock(self): self.pid_dock.setVisible(self.show_pids.isChecked()) def _sync_norm_from_menu(self): self.norm_chk.setChecked(self.norm_act.isChecked()) def _toggle_theme(self): self._theme = "light" if self.theme_act.isChecked() else "dark" self._apply_theme() def _apply_theme(self): bg, _fg = THEMES[self._theme] self.multi.set_background(bg) self.single.set_background(bg) def _populate_table_rows(self): pids = self.ctl.reg.all() self.table.setRowCount(len(pids)) self._table_row = {} for r, p in enumerate(pids): self._table_row[p.key] = r self.table.setItem(r, 0, QtWidgets.QTableWidgetItem(p.name)) self.table.setItem(r, 1, QtWidgets.QTableWidgetItem("--")) self.table.setItem(r, 2, QtWidgets.QTableWidgetItem(self._dunit(p))) self.table.setItem(r, 3, QtWidgets.QTableWidgetItem("--")) self.table.setItem(r, 4, QtWidgets.QTableWidgetItem("--")) self.table.setItem(r, 5, QtWidgets.QTableWidgetItem(p.confidence)) self.table.resizeColumnsToContents() # ---------- captures ---------- def _new_capture(self): self.ctl.store.clear() import time self.ctl.t0 = time.time() self.status.showMessage("New capture — buffers cleared.") def _toggle_record(self): if self.ctl.store.recorder is None: path, _ = QtWidgets.QFileDialog.getSaveFileName( self, "Record capture to", "", "CSV (*.csv)") if not path: return self.ctl.record(path) self.rec_act.setText("Stop Recording") self.status.showMessage(f"Recording to {path}") else: self.ctl.stop_record() self.rec_act.setText("Start Recording…") self.status.showMessage("Recording stopped.") def _export_capture(self): path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Export capture", "", "CSV (*.csv)") if path: export_csv(self.ctl.store, path) self.status.showMessage(f"Exported capture to {path}") def _open_capture(self): if self.ctl.connected: QtWidgets.QMessageBox.information(self, "Disconnect first", "Disconnect before replaying a capture."); return path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Open capture", "", "CSV (*.csv)") if not path: return store = TimeSeriesStore() replay_csv(path, store) self.ctl.store = store for key in list(self.curves): self._remove_curve(key) import time ts = [t for s in store.snapshot().values() for t, _ in s] self.ctl.t0 = min(ts) if ts else time.time() self.tree.blockSignals(True) for key in store.keys(): if key in self._items: self._items[key].setCheckState(0, QtCore.Qt.Checked) self._add_curve(key) self.tree.blockSignals(False) self._redraw_curves(static=True) self.status.showMessage(f"Replay: {os.path.basename(path)} ({len(ts)} samples)") # ---------- help ---------- def _about(self): QtWidgets.QMessageBox.about(self, "About OBDash", "OBDash — vehicle-agnostic OBD-II scanner\n\n" "Open source. Vehicle data lives in JSON profiles you can add/share.\n" "git.jpaul.io/justin/obdash") def _legend(self): QtWidgets.QMessageBox.information(self, "PID confidence", "verified — multi-source or read on a real vehicle\n" "[DOC] — documented in sources, not yet read on this vehicle\n" "[?] — single-source / disputed scaling; sanity-check first") # ---------- refresh ---------- def _redraw_curves(self, static=False): if self.ctl.t0 is None: return if static: since = None else: since = (self.ctl.t0 or 0) + max(0.0, self.ctl.now() - PLOT_WINDOW_S) normalize = self.norm_chk.isChecked() active = self._graph() for key in self.curves: p = self.ctl.reg.get(key) xs, ys = [], [] for t, v in self.ctl.store.channel(key).series(since=since): if v is None: continue xs.append(t - self.ctl.t0) if normalize and p.vmax != p.vmin: ys.append((v - p.vmin) / (p.vmax - p.vmin) * 100.0) else: ys.append(self._dval(p, v)) active.set_data(key, xs, ys) if normalize: self.single.set_y_label("% of range") def _tick(self): if not self.ctl.connected: return self.tree.blockSignals(True) for key, it in self._items.items(): v = self.ctl.store.latest(key) p = self.ctl.reg.get(key) dv = self._dval(p, v) txt = "--" if dv is None else f"{dv:g} {self._dunit(p)}".strip() it.setText(1, txt) if key in self._table_row: r = self._table_row[key] lo, hi = self.ctl.store.minmax(key) dlo, dhi = self._dval(p, lo), self._dval(p, hi) self.table.item(r, 1).setText("--" if dv is None else f"{dv: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.tree.blockSignals(False) if self.stack.currentIndex() == 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)) else: self._redraw_curves() def closeEvent(self, ev): try: self.timer.stop() self.ctl.stop() finally: super().closeEvent(ev) class JsonEditDialog(QtWidgets.QDialog): """Minimal raw-JSON editor for the active profile (validates on save).""" def __init__(self, path, parent=None): super().__init__(parent) self.path = path self.setWindowTitle(f"Edit profile — {os.path.basename(path)}") self.resize(720, 560) lay = QtWidgets.QVBoxLayout(self) self.edit = QtWidgets.QPlainTextEdit() self.edit.setFont(QtGui.QFont("monospace")) with open(path) as f: self.edit.setPlainText(f.read()) lay.addWidget(self.edit) bb = QtWidgets.QDialogButtonBox( QtWidgets.QDialogButtonBox.Save | QtWidgets.QDialogButtonBox.Cancel) bb.accepted.connect(self._save); bb.rejected.connect(self.reject) lay.addWidget(bb) def _save(self): import json text = self.edit.toPlainText() try: json.loads(text) # syntax check except Exception as e: QtWidgets.QMessageBox.critical(self, "Invalid JSON", str(e)); return tmp = self.path + ".tmp" with open(tmp, "w") as f: f.write(text) try: load_profile(tmp) # schema/formula validation except Exception as e: os.remove(tmp) QtWidgets.QMessageBox.critical(self, "Invalid profile", str(e)); return os.replace(tmp, self.path) self.accept() def run(): import sys app = QtWidgets.QApplication(sys.argv) win = MainWindow() win.resize(1150, 700) win.show() sys.exit(app.exec()) if __name__ == "__main__": run()