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:
@@ -73,6 +73,24 @@ 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)
|
||||
|
||||
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.).
|
||||
|
||||
```
|
||||
pip install -r requirements-gui.txt
|
||||
python run_gui.py # tick "Mock" + Connect to explore with no adapter
|
||||
```
|
||||
|
||||

|
||||
|
||||
The whole app runs against simulated data (`MockLink`) so it can be developed
|
||||
on any machine and only needs the truck for real captures.
|
||||
|
||||
---
|
||||
|
||||
### Live dashboard (real-time gauges)
|
||||
|
||||
Updates in place as you crank or run the engine — color-coded, with live
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,4 @@
|
||||
"""PySide6 + pyqtgraph GUI for ford-obd (P1 shell).
|
||||
|
||||
Run: python run_gui.py (or) python -m gui
|
||||
"""
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Controller -- owns the obdcore link/registry/store/scheduler for the GUI.
|
||||
|
||||
Keeps all acquisition concerns out of the widgets. The GUI subscribes/
|
||||
unsubscribes PIDs (== what's polled == what's plotted) and reads the store on
|
||||
a timer; the scheduler thread does the serial work.
|
||||
"""
|
||||
import time
|
||||
|
||||
from obdcore import PidRegistry, TimeSeriesStore, PollScheduler, CsvRecorder
|
||||
from obdcore.mock import MockLink
|
||||
|
||||
# default poll rates (Hz) -- fast for the no-start metrics, slower for the rest
|
||||
FAST = {"ICP", "FICM_M", "RPM"}
|
||||
DEFAULT_HZ = 2
|
||||
FAST_HZ = 5
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self):
|
||||
self.reg = PidRegistry()
|
||||
self.store = TimeSeriesStore()
|
||||
self.link = None
|
||||
self.sched = None
|
||||
self.t0 = None
|
||||
self.connected = False
|
||||
|
||||
def connect(self, port=None, baud=38400, mock=False):
|
||||
if mock:
|
||||
self.link = MockLink(clock=time.time)
|
||||
else:
|
||||
from obdcore.link import ElmLink # imported lazily (needs pyserial)
|
||||
self.link = ElmLink(port, baud)
|
||||
self.link.init()
|
||||
ok = self.link.connect()
|
||||
try:
|
||||
self.link.fast_timing(True)
|
||||
except Exception:
|
||||
pass
|
||||
self.sched = PollScheduler(self.link, self.reg, self.store, clock=time.time)
|
||||
self.t0 = time.time()
|
||||
self.connected = True
|
||||
return ok
|
||||
|
||||
def hz_for(self, key):
|
||||
return FAST_HZ if key in FAST else DEFAULT_HZ
|
||||
|
||||
def subscribe(self, key):
|
||||
if self.sched:
|
||||
self.sched.subscribe(key, self.hz_for(key))
|
||||
|
||||
def unsubscribe(self, key):
|
||||
if self.sched:
|
||||
self.sched.unsubscribe(key)
|
||||
|
||||
def subscribed(self):
|
||||
return set(self.sched.subscriptions()) if self.sched else set()
|
||||
|
||||
def start(self):
|
||||
if self.sched:
|
||||
self.sched.start()
|
||||
|
||||
def record(self, path):
|
||||
self.store.recorder = CsvRecorder(path)
|
||||
|
||||
def stop_record(self):
|
||||
if self.store.recorder:
|
||||
self.store.recorder.close()
|
||||
self.store.recorder = None
|
||||
|
||||
def now(self):
|
||||
return (time.time() - self.t0) if self.t0 else 0.0
|
||||
|
||||
def stop(self):
|
||||
if self.sched:
|
||||
self.sched.stop()
|
||||
self.sched = None
|
||||
self.stop_record()
|
||||
if self.link:
|
||||
try:
|
||||
self.link.fast_timing(False)
|
||||
except Exception:
|
||||
pass
|
||||
self.link.close()
|
||||
self.link = None
|
||||
self.connected = False
|
||||
+319
@@ -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()
|
||||
+18
-9
@@ -10,7 +10,11 @@ from collections import deque
|
||||
|
||||
|
||||
class Channel:
|
||||
"""One PID's rolling history plus session min/max."""
|
||||
"""One PID's rolling history plus session min/max.
|
||||
|
||||
Lock-guarded so the acquisition thread can push while the GUI thread
|
||||
reads series()/snapshots without a 'deque mutated during iteration' race.
|
||||
"""
|
||||
|
||||
def __init__(self, key, maxlen=3600):
|
||||
self.key = key
|
||||
@@ -19,22 +23,27 @@ class Channel:
|
||||
self.hi = None
|
||||
self.last_t = None
|
||||
self.last_v = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def push(self, t, v):
|
||||
self.buf.append((t, v))
|
||||
self.last_t, self.last_v = t, v
|
||||
if v is not None:
|
||||
self.lo = v if self.lo is None else min(self.lo, v)
|
||||
self.hi = v if self.hi is None else max(self.hi, v)
|
||||
with self._lock:
|
||||
self.buf.append((t, v))
|
||||
self.last_t, self.last_v = t, v
|
||||
if v is not None:
|
||||
self.lo = v if self.lo is None else min(self.lo, v)
|
||||
self.hi = v if self.hi is None else max(self.hi, v)
|
||||
|
||||
def reset_minmax(self):
|
||||
self.lo = self.hi = self.last_v
|
||||
with self._lock:
|
||||
self.lo = self.hi = self.last_v
|
||||
|
||||
def series(self, since=None):
|
||||
"""Return [(t, v), ...]; if since given, only samples with t >= since."""
|
||||
with self._lock:
|
||||
items = list(self.buf) # snapshot under lock
|
||||
if since is None:
|
||||
return list(self.buf)
|
||||
return [(t, v) for (t, v) in self.buf if t >= since]
|
||||
return items
|
||||
return [(t, v) for (t, v) in items if t >= since]
|
||||
|
||||
|
||||
class TimeSeriesStore:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# GUI dependencies (cross-platform: Windows / macOS / Linux, incl. Apple Silicon)
|
||||
# pip install -r requirements-gui.txt
|
||||
# python run_gui.py
|
||||
PySide6>=6.6
|
||||
pyqtgraph>=0.13
|
||||
numpy>=1.24
|
||||
pyserial>=3.5
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launcher for the ford-obd GUI.
|
||||
|
||||
pip install PySide6 pyqtgraph numpy pyserial
|
||||
python run_gui.py
|
||||
|
||||
Tick "Mock" + Connect to explore with simulated data (no adapter needed).
|
||||
"""
|
||||
from gui.main import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user