diff --git a/README.md b/README.md index e7c3d94..c647964 100644 --- a/README.md +++ b/README.md @@ -73,21 +73,31 @@ python obd_reader.py COM5 --crank --dash-log crank.csv # + record a CSV under the line), that's the high-pressure oil bleed-off — STC fitting / oil-rail O-rings. On exit it prints the peak and a verdict. `q` quits, `r` resets. -## Graphical app (preview — P1) +## Graphical app (preview) -A cross-platform desktop GUI (PySide6 + pyqtgraph) is in progress. P1 = PID -browser + live overlay plot; see [ARCHITECTURE.md](ARCHITECTURE.md) for the -roadmap (cranking/driving/diagnostics perspectives, record/playback, etc.). +A cross-platform desktop GUI (PySide6 + pyqtgraph). Vehicle-agnostic — all PIDs, +scaling, DTCs, and presets come from the JSON profiles in `profiles/`. ``` pip install -r requirements-gui.txt python run_gui.py # tick "Mock" + Connect to explore with no adapter ``` -![P1 GUI](docs/gui-p1-preview.png) +Features so far: +- **PID browser** (left) grouped by system, live values, confidence badges +- **Graph view** with **true multi-axis** overlay — one Y scale per unit (psi/V/rpm/…), + or a Normalize (% of range) mode +- **Gauge view** — arc gauges with peak-hold, one per signal +- **Table view** — value + min/max + confidence +- **Diagnostics** — read/clear DTCs (guarded), no-start codes flagged +- **Profile menu** — switch/import/edit vehicles; **File menu** — record/replay/export captures -The whole app runs against simulated data (`MockLink`) so it can be developed -on any machine and only needs the truck for real captures. +![Multi-axis graph](docs/gui-p2-multiaxis.png) +![Gauge view](docs/gui-p2-gauges.png) + +The whole app runs against simulated data (`MockLink`), so it can be developed +on any machine and only needs the vehicle for real captures. See +[ARCHITECTURE.md](ARCHITECTURE.md) for the roadmap. --- diff --git a/docs/gui-p2-gauges.png b/docs/gui-p2-gauges.png new file mode 100644 index 0000000..61f4984 Binary files /dev/null and b/docs/gui-p2-gauges.png differ diff --git a/docs/gui-p2-multiaxis.png b/docs/gui-p2-multiaxis.png new file mode 100644 index 0000000..a8eff3b Binary files /dev/null and b/docs/gui-p2-multiaxis.png differ diff --git a/docs/gui-p2-profiles.png b/docs/gui-p2-profiles.png deleted file mode 100644 index b92bd62..0000000 Binary files a/docs/gui-p2-profiles.png and /dev/null differ diff --git a/gui/main.py b/gui/main.py index 1cf41b0..c8b3dad 100644 --- a/gui/main.py +++ b/gui/main.py @@ -18,6 +18,7 @@ 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 @@ -86,8 +87,8 @@ class MainWindow(QtWidgets.QMainWindow): self.view_table = self._act(viewm, "Table View", lambda: self._set_view(1), checkable=True) self.view_graph.setChecked(True) - gauges = self._act(viewm, "Gauge View (P2)", lambda: None, checkable=True) - gauges.setEnabled(False) + 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) @@ -352,32 +353,43 @@ class MainWindow(QtWidgets.QMainWindow): def _build_center(self): self.stack = QtWidgets.QStackedWidget() - # graph page + # 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(lambda v: self.norm_act.setChecked(v)) - bar.addWidget(self.norm_chk); bar.addStretch(1) + 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.plot = pg.PlotWidget() - self.plot.addLegend(offset=(10, 10)) - self.plot.showGrid(x=True, y=True, alpha=0.25) - self.plot.setLabel("bottom", "time", units="s") - gl.addWidget(self.plot) - self.stack.addWidget(gpage) + 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 + # 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.") @@ -404,6 +416,7 @@ class MainWindow(QtWidgets.QMainWindow): 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}") @@ -538,25 +551,42 @@ class MainWindow(QtWidgets.QMainWindow): 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] = self.plot.plot([], [], name=f"{p.name} ({p.unit})", - pen=pg.mkPen(color=color, width=2)) + self.curves[key] = color # curves maps key -> color + self._graph().add_curve(key, f"{p.name} ({p.unit})", p.unit, color) + self._refresh_gauges() def _remove_curve(self, key): - c = self.curves.pop(key, None) - if c is not None: - self.plot.removeItem(c) - leg = self.plot.plotItem.legend - if leg: - try: - leg.removeItem(c) - except Exception: - pass + 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} ({p.unit})", p.unit, color) + self._apply_theme() + self._redraw_curves(static=not self.ctl.connected) + + def _refresh_gauges(self): + specs = [] + for key, color in self.curves.items(): + p = self.ctl.reg.get(key) + specs.append((key, p.name, p.unit, p.vmin, p.vmax, color)) + 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()) @@ -569,10 +599,9 @@ class MainWindow(QtWidgets.QMainWindow): self._apply_theme() def _apply_theme(self): - bg, fg = THEMES[self._theme] - self.plot.setBackground(bg) - self.plot.getAxis("bottom").setPen(fg); self.plot.getAxis("left").setPen(fg) - self.plot.getAxis("bottom").setTextPen(fg); self.plot.getAxis("left").setTextPen(fg) + 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() @@ -661,7 +690,8 @@ class MainWindow(QtWidgets.QMainWindow): else: since = (self.ctl.t0 or 0) + max(0.0, self.ctl.now() - PLOT_WINDOW_S) normalize = self.norm_chk.isChecked() - for key, curve in self.curves.items(): + 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): @@ -672,8 +702,9 @@ class MainWindow(QtWidgets.QMainWindow): ys.append((v - p.vmin) / (p.vmax - p.vmin) * 100.0) else: ys.append(v) - curve.setData(xs, ys) - self.plot.setLabel("left", "% of range" if normalize else "value") + active.set_data(key, xs, ys) + if normalize: + self.single.set_y_label("% of range") def _tick(self): if not self.ctl.connected: @@ -691,7 +722,12 @@ class MainWindow(QtWidgets.QMainWindow): self.table.item(r, 3).setText("--" if lo is None else f"{lo:g}") self.table.item(r, 4).setText("--" if hi is None else f"{hi:g}") self.tree.blockSignals(False) - self._redraw_curves() + if self.stack.currentIndex() == 2: # gauge view + for key in self.curves: + lo, hi = self.ctl.store.minmax(key) + self.gauges.set_value(key, self.ctl.store.latest(key), peak=hi) + else: + self._redraw_curves() def closeEvent(self, ev): try: diff --git a/gui/widgets.py b/gui/widgets.py new file mode 100644 index 0000000..56f68a3 --- /dev/null +++ b/gui/widgets.py @@ -0,0 +1,245 @@ +"""Custom widgets for the ford-obd GUI: a unit-grouped multi-axis plot, a +simple single-axis plot, and an arc gauge / gauge grid. + +All three plot/gauge containers share a small interface so the main window can +swap between them: add_curve(key,name,unit,color) / set_data(key,xs,ys) / +remove_curve(key) / clear(). +""" +import math + +from PySide6 import QtCore, QtGui, QtWidgets +import pyqtgraph as pg + +MAX_EXTRA_AXES = 4 # base left axis + up to 4 right axes (5 units shown) + + +class SinglePlot(QtWidgets.QWidget): + """One shared Y axis. Used for the Normalize (% of range) mode.""" + + def __init__(self): + super().__init__() + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + self.pw = pg.PlotWidget() + self.pw.addLegend(offset=(10, 10)) + self.pw.showGrid(x=True, y=True, alpha=0.25) + self.pw.setLabel("bottom", "time", units="s") + lay.addWidget(self.pw) + self._curves = {} + + def set_y_label(self, text): + self.pw.setLabel("left", text) + + def add_curve(self, key, name, unit, color): + self._curves[key] = self.pw.plot([], [], name=name, pen=pg.mkPen(color, width=2)) + + def set_data(self, key, xs, ys): + c = self._curves.get(key) + if c is not None: + c.setData(xs, ys) + + def remove_curve(self, key): + c = self._curves.pop(key, None) + if c is not None: + self.pw.removeItem(c) + leg = self.pw.plotItem.legend + if leg: + try: + leg.removeItem(c) + except Exception: + pass + + def clear(self): + for key in list(self._curves): + self.remove_curve(key) + + def set_background(self, bg): + self.pw.setBackground(bg) + + +class MultiAxisPlot(QtWidgets.QWidget): + """Overlay with one Y axis PER UNIT (psi / V / rpm / C / %), so mixed-scale + signals are all readable at their true values. Curves are grouped by unit; + each extra unit gets its own right-hand axis + linked ViewBox.""" + + def __init__(self): + super().__init__() + lay = QtWidgets.QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + self.glw = pg.GraphicsLayoutWidget() + lay.addWidget(self.glw) + self.p = self.glw.addPlot() + self.p.showGrid(x=True, y=True, alpha=0.2) + self.p.setLabel("bottom", "time", units="s") + self.legend = self.p.addLegend(offset=(10, 10)) + self.base_vb = self.p.vb + self._units = {} # unit -> {vb, ax, base} + self._curves = {} # key -> {curve, unit} + self._next_col = 3 + self.base_vb.sigResized.connect(self._sync) + + def _ensure_unit(self, unit): + e = self._units.get(unit) + if e is not None: + return e + if not self._units: # first unit -> base left axis + self.p.setLabel("left", unit) + e = {"vb": self.base_vb, "ax": self.p.getAxis("left"), "base": True} + elif len(self._units) - 1 < MAX_EXTRA_AXES: # add a right axis + vb = pg.ViewBox() + ax = pg.AxisItem("right") + self.p.layout.addItem(ax, 2, self._next_col) + self._next_col += 1 + self.p.scene().addItem(vb) + ax.linkToView(vb) + vb.setXLink(self.p) + ax.setLabel(unit) + e = {"vb": vb, "ax": ax, "base": False} + self._sync() + else: # out of axes -> reuse base + e = self._units[next(iter(self._units))] + self._units[unit] = e + return e + + def add_curve(self, key, name, unit, color): + e = self._ensure_unit(unit) + curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=2), name=name) + e["vb"].addItem(curve) + self.legend.addItem(curve, name) + self._curves[key] = {"curve": curve, "unit": unit} + + def set_data(self, key, xs, ys): + c = self._curves.get(key) + if c is not None: + c["curve"].setData(xs, ys) + + def remove_curve(self, key): + c = self._curves.pop(key, None) + if not c: + return + e = self._units.get(c["unit"]) + if e: + e["vb"].removeItem(c["curve"]) + try: + self.legend.removeItem(c["curve"]) + except Exception: + pass + + def clear(self): + for key in list(self._curves): + self.remove_curve(key) + + def set_y_label(self, _text): + pass # multi-axis labels itself per unit + + def set_background(self, bg): + self.glw.setBackground(bg) + + def _sync(self): + rect = self.base_vb.sceneBoundingRect() + for e in self._units.values(): + if not e["base"]: + e["vb"].setGeometry(rect) + e["vb"].linkedViewChanged(self.base_vb, e["vb"].XAxis) + + +class ArcGauge(QtWidgets.QWidget): + """A 270-degree arc gauge for a single signal: filled arc to the current + value, peak tick, big numeric readout, name + unit.""" + + def __init__(self, name, unit, vmin, vmax, accent="#3cb44b"): + super().__init__() + self.name = name + self.unit = unit + self.vmin = float(vmin) + self.vmax = float(vmax) if vmax != vmin else float(vmin) + 1.0 + self.accent = QtGui.QColor(accent) + self.value = None + self.peak = None + self.setMinimumSize(150, 130) + + def set_value(self, v, peak=None): + self.value = v + self.peak = peak + self.update() + + def _frac(self, v): + return max(0.0, min(1.0, (v - self.vmin) / (self.vmax - self.vmin))) + + def paintEvent(self, _ev): + p = QtGui.QPainter(self) + p.setRenderHint(QtGui.QPainter.Antialiasing) + w, h = self.width(), self.height() + p.fillRect(self.rect(), QtGui.QColor("#141414")) # own dark bg (theme-proof) + m = 14 + side = min(w, h - 18) + rect = QtCore.QRectF((w - side) / 2 + m / 2, m / 2, side - m, side - m) + start, span = 225 * 16, -270 * 16 # 270deg sweep, top-open down + + def arc_pen(color, width): + pen = QtGui.QPen(QtGui.QColor(color), width) + pen.setCapStyle(QtCore.Qt.RoundCap) + return pen + + p.setPen(arc_pen("#333", 9)) + p.drawArc(rect, start, span) + + if self.value is not None: + frac = self._frac(self.value) + p.setPen(arc_pen(self.accent, 9)) + p.drawArc(rect, start, int(span * frac)) + if self.peak is not None and self.peak != self.value: + pf = self._frac(self.peak) + ang = (225 - 270 * pf) + p.setPen(QtGui.QPen(QtGui.QColor("#e6c84b"), 2)) + cx, cy = rect.center().x(), rect.center().y() + r1, r2 = side / 2 - m - 9, side / 2 - m + 2 + a = math.radians(ang) + p.drawLine(QtCore.QPointF(cx + r1 * math.cos(a), cy - r1 * math.sin(a)), + QtCore.QPointF(cx + r2 * math.cos(a), cy - r2 * math.sin(a))) + + p.setPen(QtGui.QColor("#eee")) + f = p.font(); f.setPointSize(15); f.setBold(True); p.setFont(f) + val = "--" if self.value is None else f"{self.value:g}" + p.drawText(rect, QtCore.Qt.AlignCenter, val) + f.setPointSize(8); f.setBold(False); p.setFont(f) + p.setPen(QtGui.QColor("#999")) + p.drawText(QtCore.QRectF(0, h - 16, w, 14), QtCore.Qt.AlignCenter, + f"{self.name} ({self.unit})" if self.unit else self.name) + p.end() + + +class GaugeGrid(QtWidgets.QScrollArea): + """Scrollable grid of ArcGauges, one per signal.""" + + def __init__(self): + super().__init__() + self.setWidgetResizable(True) + self.setStyleSheet("QScrollArea, QWidget { background: #111; }") + self._inner = QtWidgets.QWidget() + self._grid = QtWidgets.QGridLayout(self._inner) + self._grid.setSpacing(8) + self.setWidget(self._inner) + self._gauges = {} + self._cols = 4 + + def rebuild(self, specs): + """specs: list of (key, name, unit, vmin, vmax, accent).""" + while self._grid.count(): + item = self._grid.takeAt(0) + wdg = item.widget() + if wdg: + wdg.deleteLater() + self._gauges = {} + for i, (key, name, unit, vmin, vmax, accent) in enumerate(specs): + g = ArcGauge(name, unit, vmin, vmax, accent) + self._gauges[key] = g + self._grid.addWidget(g, i // self._cols, i % self._cols) + + def keys(self): + return set(self._gauges) + + def set_value(self, key, v, peak=None): + g = self._gauges.get(key) + if g is not None: + g.set_value(v, peak)