P1: PySide6 + pyqtgraph GUI shell (PID browser + live overlay plot)

First graphical frontend on obdcore. Cross-platform (Win/mac/Linux).

- gui/controller.py: owns link/registry/store/scheduler; subscribe == poll ==
  plot; per-PID rates (ICP/FICM/RPM fast); optional CSV recording.
- gui/main.py: connection bar (port dropdown via find_ports, baud, Mock,
  connect), left PID browser grouped by system with live values + confidence
  badges + checkboxes, central pyqtgraph overlay plot with legend, Normalize
  (% of range) toggle for mixed-scale PIDs, Crank/Driving/Vitals presets,
  10Hz refresh reading the store off the acquisition thread.
- run_gui.py launcher; requirements-gui.txt.
- store.py: lock Channel push/series (GUI reads while scheduler writes).
- docs/gui-p1-preview.png: validated render (mock crank, ICP ramp to 540).

Validated headless (offscreen Qt): connect(mock) -> crank preset -> ICP
streams past 500 -> normalize -> uncheck removes curve -> clean disconnect.
obdcore tests still pass after the locking change.

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 14:13:49 -04:00
parent 01de18a568
commit 4589904b97
8 changed files with 463 additions and 9 deletions
+319
View File
@@ -0,0 +1,319 @@
"""ford-obd GUI -- P1 shell: connection bar, PID browser (side), live overlay plot.
Checking a PID in the browser subscribes it (polls + plots); unchecking removes
it. Preset buttons bulk-select. 'Normalize' overlays mixed-scale PIDs (ICP vs
FICM) as % of each PID's range so they're all readable on one axis.
Built to run against MockLink with no hardware -- pick "Mock" and Connect.
"""
import time
from PySide6 import QtCore, QtGui, QtWidgets
import pyqtgraph as pg
from obdcore import PRESETS
from .controller import Controller
PLOT_WINDOW_S = 60.0 # seconds of history shown
REFRESH_MS = 100 # GUI redraw rate (10 Hz)
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": " [?]"}
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("ford-obd -- 6.0 Power Stroke scanner")
self.resize(1100, 680)
self.ctl = Controller()
self.curves = {} # key -> PlotDataItem
self._color_i = 0
self._build_connection_bar()
self._build_pid_browser()
self._build_plot()
self._build_statusbar()
self.timer = QtCore.QTimer(self)
self.timer.timeout.connect(self._tick)
self.timer.setInterval(REFRESH_MS)
# ---- UI construction ----
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)
refresh = QtWidgets.QToolButton()
refresh.setText("")
refresh.setToolTip("Rescan serial ports")
refresh.clicked.connect(self._refresh_ports)
tb.addWidget(refresh)
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")
self.mock_chk.setToolTip("Use simulated data (no adapter needed)")
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()
for name in ("crank", "driving", "vitals"):
b = QtWidgets.QPushButton(name.capitalize())
b.setToolTip(f"Select the '{name}' PID set")
b.clicked.connect(lambda _=False, n=name: self._apply_preset(n))
b.setEnabled(False)
b.setProperty("preset", True)
tb.addWidget(b)
def _build_pid_browser(self):
dock = QtWidgets.QDockWidget("PIDs", self)
dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
self.tree = QtWidgets.QTreeWidget()
self.tree.setColumnCount(2)
self.tree.setHeaderLabels(["Signal", "Value"])
self.tree.setRootIsDecorated(True)
self.tree.setUniformRowHeights(True)
self.tree.itemChanged.connect(self._on_item_changed)
dock.setWidget(self.tree)
self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
self._populate_tree()
def _populate_tree(self):
self.tree.blockSignals(True)
self.tree.clear()
self._items = {} # key -> QTreeWidgetItem
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])
self.tree.expandAll()
self.tree.resizeColumnToContents(0)
self.tree.blockSignals(False)
def _build_plot(self):
pg.setConfigOptions(antialias=True, background="#111", foreground="#ccc")
central = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(central)
lay.setContentsMargins(4, 4, 4, 4)
bar = QtWidgets.QHBoxLayout()
self.norm_chk = QtWidgets.QCheckBox("Normalize (% of range)")
self.norm_chk.setToolTip("Scale each curve to its min..max so mixed units "
"(ICP vs FICM) are all readable on one axis")
bar.addWidget(self.norm_chk)
bar.addStretch(1)
self.window_label = QtWidgets.QLabel(f"window: {int(PLOT_WINDOW_S)}s")
bar.addWidget(self.window_label)
lay.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")
self.plot.setLabel("left", "value")
lay.addWidget(self.plot)
self.setCentralWidget(central)
def _build_statusbar(self):
self.status = self.statusBar()
self.status.showMessage("Not connected. Pick a port (or Mock) and Connect.")
# ---- 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
mock = self.mock_chk.isChecked()
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=mock)
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Connect failed", str(e))
return
self.ctl.start()
self.timer.start()
self.connect_btn.setText("Disconnect")
self._set_presets_enabled(True)
proto = getattr(self.ctl.link, "protocol", "?")
kind = "MOCK" if mock else "ELM327"
self.status.showMessage(f"Connected ({kind}) protocol {proto} "
f"{'(ECU answered)' if ok else '(no 0100 ack - key to RUN?)'}")
self._apply_preset("crank")
def _disconnect(self):
self.timer.stop()
for key in list(self.curves):
self._remove_curve(key)
self.ctl.stop()
# uncheck everything
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")
self._set_presets_enabled(False)
self.status.showMessage("Disconnected.")
def _set_presets_enabled(self, on):
for b in self.findChildren(QtWidgets.QPushButton):
if b.property("preset"):
b.setEnabled(on)
# ---- PID selection ----
def _apply_preset(self, name):
if not self.ctl.connected:
return
wanted = set(PRESETS.get(name, []))
self.tree.blockSignals(True)
for key, it in self._items.items():
want = key in wanted
it.setCheckState(0, QtCore.Qt.Checked if want else QtCore.Qt.Unchecked)
self.tree.blockSignals(False)
# sync subscriptions/curves to the new check state
for key in self._items:
self._sync_key(key)
def _on_item_changed(self, item, col):
if col != 0:
return
key = item.data(0, QtCore.Qt.UserRole)
if key:
self._sync_key(key)
def _sync_key(self, key):
it = self._items[key]
checked = it.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
pen = pg.mkPen(color=color, width=2)
curve = self.plot.plot([], [], name=f"{p.name} ({p.unit})", pen=pen)
curve.setData([], [])
self.curves[key] = curve
def _remove_curve(self, key):
curve = self.curves.pop(key, None)
if curve is not None:
self.plot.removeItem(curve)
legend = self.plot.plotItem.legend
if legend:
try:
legend.removeItem(curve)
except Exception:
pass
# ---- periodic refresh ----
def _tick(self):
if not self.ctl.connected:
return
now = self.ctl.now()
since = (self.ctl.t0 or 0) + max(0.0, now - PLOT_WINDOW_S)
normalize = self.norm_chk.isChecked()
# update browser values
self.tree.blockSignals(True)
for key, it in self._items.items():
v = self.ctl.store.latest(key)
p = self.ctl.reg.get(key)
it.setText(1, "--" if v is None else f"{v:g} {p.unit}".strip())
self.tree.blockSignals(False)
# update plotted curves
for key, curve in self.curves.items():
p = self.ctl.reg.get(key)
series = self.ctl.store.channel(key).series(since=since)
xs, ys = [], []
for t, v in series:
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(v)
curve.setData(xs, ys)
self.plot.setLabel("left", "% of range" if normalize else "value")
def closeEvent(self, ev):
try:
self.timer.stop()
self.ctl.stop()
finally:
super().closeEvent(ev)
def run():
import sys
app = QtWidgets.QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
run()