Make app vehicle-agnostic: JSON vehicle profiles + menu bar

Vehicle data is now DATA, not code. PIDs/scaling/DTCs/presets live in
profiles/*.json; the app loads them at runtime, so it works across vehicles
and others can contribute profiles (open source).

Core:
- obdcore/formula.py: safe AST evaluator for scaling formulas (A/B/... byte
  vars, Torque/FORScan convention). Only arithmetic/bitwise + min/max/abs/
  round/int/float; names/attrs/arbitrary calls rejected at load -> a community
  profile CANNOT execute code.
- obdcore/profile.py: load/save/list profiles; compiles each formula into a
  decode callable. registry.py now profile-backed (PidRegistry/DtcDatabase
  take a Profile); hardcoded Ford table removed.
- store.py: clear()/snapshot()/export_csv() for capture management.

Profiles:
- profiles/ford-6.0-powerstroke.json (27 PIDs, verified formulas, DTCs)
- profiles/generic-obd2.json (standard SAE Mode-01 base, any vehicle)
- profiles/README.md (schema + formula language + contributing)

GUI:
- Menu bar: File (new/record/export/replay capture, quit), Profile (switch/
  load/import/reload/edit-JSON/export, live profile list), View (Graph/Table
  views, gauges P2, toggle PID dock, normalize, light/dark theme), Help
  (about/confidence legend/profile info).
- PID browser + presets rebuild on profile switch; added Table view; raw-JSON
  profile editor dialog (validates schema+formulas before saving).

Tests: profiles load+compile, formula sandbox rejects hostile input, decoders
still match real truck bytes, crank/derived/dead-PID/replay -- all pass.

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:34:33 -04:00
parent 45691334e1
commit f3f0bf2a77
12 changed files with 966 additions and 295 deletions
+11 -2
View File
@@ -6,7 +6,8 @@ a timer; the scheduler thread does the serial work.
"""
import time
from obdcore import PidRegistry, TimeSeriesStore, PollScheduler, CsvRecorder
from obdcore import (PidRegistry, DtcDatabase, TimeSeriesStore, PollScheduler,
CsvRecorder, load_default, load_profile)
from obdcore.mock import MockLink
# default poll rates (Hz) -- fast for the no-start metrics, slower for the rest
@@ -17,13 +18,21 @@ FAST_HZ = 5
class Controller:
def __init__(self):
self.reg = PidRegistry()
self.profile = load_default()
self.reg = PidRegistry(self.profile)
self.dtcdb = DtcDatabase(self.profile)
self.store = TimeSeriesStore()
self.link = None
self.sched = None
self.t0 = None
self.connected = False
def load_profile(self, path):
"""Switch the active vehicle profile (only allowed while disconnected)."""
self.profile = load_profile(path)
self.reg = PidRegistry(self.profile)
self.dtcdb = DtcDatabase(self.profile)
def connect(self, port=None, baud=38400, mock=False):
if mock:
self.link = MockLink(clock=time.time)
+420 -133
View File
@@ -1,111 +1,182 @@
"""ford-obd GUI -- P1 shell: connection bar, PID browser (side), live overlay plot.
"""ford-obd GUI -- vehicle-agnostic scanner shell.
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.
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.
Built to run against MockLink with no hardware -- pick "Mock" and Connect.
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 time
import os
import shutil
from PySide6 import QtCore, QtGui, QtWidgets
import pyqtgraph as pg
from obdcore import PRESETS
from obdcore import (list_profiles, profiles_dir, save_profile, load_profile,
export_csv, replay_csv, TimeSeriesStore)
from .controller import Controller
PLOT_WINDOW_S = 60.0 # seconds of history shown
REFRESH_MS = 100 # GUI redraw rate (10 Hz)
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",
"#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",
}
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.setWindowTitle("ford-obd -- 6.0 Power Stroke scanner")
self.resize(1100, 680)
self.ctl = Controller()
self.curves = {} # key -> PlotDataItem
self.curves = {}
self._color_i = 0
self._theme = "dark"
self._build_menubar()
self._build_connection_bar()
self._build_pid_browser()
self._build_plot()
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)
# ---- UI construction ----
# ---------- 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()
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)
gauges = self._act(viewm, "Gauge View (P2)", lambda: None, checkable=True)
gauges.setEnabled(False)
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)
helpm = mb.addMenu("&Help")
self._act(helpm, "About ford-obd", 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)
refresh = QtWidgets.QToolButton()
refresh.setText("")
refresh.setToolTip("Rescan serial ports")
refresh.clicked.connect(self._refresh_ports)
tb.addWidget(refresh)
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)
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.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()
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)
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):
dock = QtWidgets.QDockWidget("PIDs", self)
dock.setAllowedAreas(QtCore.Qt.LeftDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
self.pid_dock = QtWidgets.QDockWidget("PIDs", self)
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()
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 = {} # key -> QTreeWidgetItem
self._items = {}
groups = {}
for p in self.ctl.reg.all():
g = groups.get(p.group)
@@ -118,45 +189,124 @@ class MainWindow(QtWidgets.QMainWindow):
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}")
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)
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)
# ---------- center (graph + table stack) ----------
def _build_center(self):
self.stack = QtWidgets.QStackedWidget()
# graph page
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.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.norm_chk.toggled.connect(lambda v: self.norm_act.setChecked(v))
bar.addWidget(self.norm_chk); 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")
self.plot.setLabel("left", "value")
lay.addWidget(self.plot)
self.setCentralWidget(central)
gl.addWidget(self.plot)
self.stack.addWidget(gpage)
# table page
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)
self.setCentralWidget(self.stack)
self._apply_theme()
def _build_statusbar(self):
self.status = self.statusBar()
self.status.showMessage("Not connected. Pick a port (or Mock) and Connect.")
# ---- connection ----
# ---------- 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"ford-obd — {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._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:
@@ -171,73 +321,65 @@ class MainWindow(QtWidgets.QMainWindow):
def _toggle_connect(self):
if self.ctl.connected:
self._disconnect()
return
mock = self.mock_chk.isChecked()
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=mock)
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()
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")
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()
# uncheck everything
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, "--")
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)
b.setEnabled(False)
self.status.showMessage("Disconnected.")
# ---- PID selection ----
# ---------- PID selection ----------
def _apply_preset(self, name):
if not self.ctl.connected:
return
wanted = set(PRESETS.get(name, []))
wanted = set(self.ctl.reg.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)
it.setCheckState(0, QtCore.Qt.Checked if key in wanted 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)
if col == 0:
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
checked = self._items[key].checkState(0) == QtCore.Qt.Checked
has = key in self.curves
if checked and not has:
if self.ctl.connected:
@@ -249,46 +391,134 @@ 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
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
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))
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:
c = self.curves.pop(key, None)
if c is not None:
self.plot.removeItem(c)
leg = self.plot.plotItem.legend
if leg:
try:
legend.removeItem(curve)
leg.removeItem(c)
except Exception:
pass
# ---- periodic refresh ----
def _tick(self):
if not self.ctl.connected:
# ---------- view ----------
def _set_view(self, idx):
self.stack.setCurrentIndex(idx)
self.view_graph.setChecked(idx == 0)
self.view_table.setChecked(idx == 1)
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.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)
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(p.unit))
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
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
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, 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())
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)")
# update plotted curves
# ---------- help ----------
def _about(self):
QtWidgets.QMessageBox.about(self, "About ford-obd",
"ford-obd — vehicle-agnostic OBD-II scanner\n\n"
"Open source. Vehicle data lives in JSON profiles you can add/share.\n"
"git.jpaul.io/justin/ford-obd")
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()
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:
for t, v in self.ctl.store.channel(key).series(since=since):
if v is None:
continue
xs.append(t - self.ctl.t0)
@@ -299,6 +529,24 @@ class MainWindow(QtWidgets.QMainWindow):
curve.setData(xs, ys)
self.plot.setLabel("left", "% of range" if normalize else "value")
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)
txt = "--" if v is None else f"{v:g} {p.unit}".strip()
it.setText(1, txt)
if key in self._table_row:
r = self._table_row[key]
lo, hi = self.ctl.store.minmax(key)
self.table.item(r, 1).setText("--" if v is None else f"{v:g}")
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()
def closeEvent(self, ev):
try:
self.timer.stop()
@@ -307,10 +555,49 @@ class MainWindow(QtWidgets.QMainWindow):
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())