From f3f0bf2a770450933ddf624c03521f3f23cc63cc Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Tue, 30 Jun 2026 14:34:33 -0400 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs --- docs/gui-p2-profiles.png | Bin 0 -> 113823 bytes gui/controller.py | 13 +- gui/main.py | 553 ++++++++++++++++++++++------- obdcore/__init__.py | 33 +- obdcore/formula.py | 98 +++++ obdcore/profile.py | 155 ++++++++ obdcore/registry.py | 174 ++------- obdcore/store.py | 29 ++ profiles/README.md | 79 +++++ profiles/ford-6.0-powerstroke.json | 60 ++++ profiles/generic-obd2.json | 31 ++ tests/test_obdcore.py | 36 +- 12 files changed, 966 insertions(+), 295 deletions(-) create mode 100644 docs/gui-p2-profiles.png create mode 100644 obdcore/formula.py create mode 100644 obdcore/profile.py create mode 100644 profiles/README.md create mode 100644 profiles/ford-6.0-powerstroke.json create mode 100644 profiles/generic-obd2.json diff --git a/docs/gui-p2-profiles.png b/docs/gui-p2-profiles.png new file mode 100644 index 0000000000000000000000000000000000000000..b92bd62c2e55f67490b4fd63dee41a3b7cd32de6 GIT binary patch literal 113823 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 pif8`Tc@pqM?tgazWrYyN9=g(Tuj;%AfZtG1 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()) diff --git a/obdcore/__init__.py b/obdcore/__init__.py index 5f38efc..3aa7771 100644 --- a/obdcore/__init__.py +++ b/obdcore/__init__.py @@ -1,20 +1,29 @@ -"""obdcore -- headless OBD-II acquisition core for the ford-obd project. +"""obdcore -- headless, vehicle-agnostic OBD-II acquisition core. -Layered, GUI-agnostic foundation shared by the terminal tool and the -forthcoming PySide6 + pyqtgraph Windows app: +Vehicle data (PIDs, scaling, DTCs, presets) lives in JSON profiles under +profiles/ -- loaded at runtime, not hardcoded -- so the app works across +vehicles and others can contribute profiles. - link.py ElmLink -- ELM327 serial transport (+ MockLink in mock.py) - registry.py PidRegistry -- verified Ford 6.0 PID table + DTC database - scheduler.py PollScheduler -- prioritized round-robin polling engine - store.py TimeSeriesStore -- ring buffers, min/max, record/replay + formula.py safe A/B/... scaling-formula evaluator (no code execution) + profile.py load/save/list vehicle profiles (JSON) + registry.py PidRegistry / DtcDatabase model + lookups + link.py ElmLink ELM327 serial transport (+ MockLink in mock.py) + scheduler.py PollScheduler prioritized polling engine + store.py TimeSeriesStore ring buffers + record/replay -See ARCHITECTURE.md for the full design and roadmap. +See ARCHITECTURE.md and profiles/README.md. """ -from .registry import PidRegistry, DtcDatabase, Pid, Dtc, PRESETS -from .store import TimeSeriesStore, CsvRecorder, replay_csv +from .registry import PidRegistry, DtcDatabase, Pid, Dtc +from .profile import (Profile, load_profile, save_profile, list_profiles, + profiles_dir, default_profile_path, load_default) +from .formula import compile_formula, FormulaError +from .store import TimeSeriesStore, CsvRecorder, replay_csv, export_csv from .scheduler import PollScheduler __all__ = [ - "PidRegistry", "DtcDatabase", "Pid", "Dtc", "PRESETS", - "TimeSeriesStore", "CsvRecorder", "replay_csv", "PollScheduler", + "PidRegistry", "DtcDatabase", "Pid", "Dtc", + "Profile", "load_profile", "save_profile", "list_profiles", + "profiles_dir", "default_profile_path", "load_default", + "compile_formula", "FormulaError", + "TimeSeriesStore", "CsvRecorder", "replay_csv", "export_csv", "PollScheduler", ] diff --git a/obdcore/formula.py b/obdcore/formula.py new file mode 100644 index 0000000..d33ac46 --- /dev/null +++ b/obdcore/formula.py @@ -0,0 +1,98 @@ +"""Safe formula evaluator for vehicle-profile PID scaling. + +Profiles are community-contributed data, so decode formulas must NOT be able to +execute arbitrary code. Formulas are arithmetic expressions over named +variables -- the de-facto OBD convention used by Torque / FORScan / ScanGauge: + + raw-mode PIDs: variables A, B, C, ... = response data bytes 0, 1, 2, ... + e.g. "(A*256+B)*0.57" "A-40" "(A>>1)&1" "A//2" + derived PIDs: variables are other PID keys + e.g. "MAP - BARO" + +Only numeric literals, the named variables, arithmetic/bitwise operators, and a +small whitelist of functions are allowed. No names, attributes, subscripts, +comprehensions, or calls outside the whitelist -- anything else raises +FormulaError at compile time, so a bad/hostile profile fails loudly on load. +""" +import ast +import operator + +_BIN = { + ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, + ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod, ast.Pow: operator.pow, + ast.BitAnd: operator.and_, ast.BitOr: operator.or_, ast.BitXor: operator.xor, + ast.LShift: operator.lshift, ast.RShift: operator.rshift, +} +_UNARY = {ast.USub: operator.neg, ast.UAdd: operator.pos, ast.Invert: operator.invert} +_FUNCS = {"min": min, "max": max, "abs": abs, "round": round, + "int": int, "float": float} + + +class FormulaError(ValueError): + pass + + +def _validate(node, allowed): + if isinstance(node, ast.Expression): + return _validate(node.body, allowed) + if isinstance(node, ast.BinOp): + if type(node.op) not in _BIN: + raise FormulaError(f"operator not allowed: {type(node.op).__name__}") + _validate(node.left, allowed) + _validate(node.right, allowed) + return + if isinstance(node, ast.UnaryOp): + if type(node.op) not in _UNARY: + raise FormulaError(f"unary op not allowed: {type(node.op).__name__}") + _validate(node.operand, allowed) + return + if isinstance(node, ast.Constant): + if not isinstance(node.value, (int, float)) or isinstance(node.value, bool): + raise FormulaError("only numeric constants allowed") + return + if isinstance(node, ast.Name): + if node.id not in allowed: + raise FormulaError(f"unknown variable {node.id!r} (allowed: {sorted(allowed)})") + return + if isinstance(node, ast.Call): + if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCS: + raise FormulaError("only min/max/abs/round/int/float calls allowed") + if node.keywords: + raise FormulaError("keyword args not allowed") + for a in node.args: + _validate(a, allowed) + return + raise FormulaError(f"expression not allowed: {type(node).__name__}") + + +def _eval(node, names): + if isinstance(node, ast.Expression): + return _eval(node.body, names) + if isinstance(node, ast.BinOp): + return _BIN[type(node.op)](_eval(node.left, names), _eval(node.right, names)) + if isinstance(node, ast.UnaryOp): + return _UNARY[type(node.op)](_eval(node.operand, names)) + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name): + return names[node.id] + if isinstance(node, ast.Call): + return _FUNCS[node.func.id](*[_eval(a, names) for a in node.args]) + raise FormulaError(f"expression not allowed: {type(node).__name__}") + + +def compile_formula(expr, allowed_names): + """Return fn(names_dict) -> number. Raises FormulaError on disallowed input.""" + try: + tree = ast.parse(expr, mode="eval") + except SyntaxError as e: + raise FormulaError(f"bad formula {expr!r}: {e}") + allowed = set(allowed_names) + _validate(tree, allowed) + + def fn(names): + return _eval(tree, names) + + fn.expr = expr + return fn diff --git a/obdcore/profile.py b/obdcore/profile.py new file mode 100644 index 0000000..f6f3d85 --- /dev/null +++ b/obdcore/profile.py @@ -0,0 +1,155 @@ +"""Vehicle profiles -- load/save/list the JSON files under profiles/. + +A profile is pure data: vehicle metadata, PID definitions (with safe formula +strings), DTC meanings, and named presets (perspectives). Loading a profile +compiles each PID's formula into a decode callable; nothing in a profile can +execute arbitrary code (see formula.py). + +JSON schema (schema=1): +{ + "schema": 1, + "meta": {"name","make","model","years","engine","author","version", + "protocol","notes"}, + "pids": [{"key","name","mode","pid","nbytes","formula","unit","group", + "vmin","vmax","confidence","round","deps","notes"}, ...], + "presets": {"crank":[keys...], ...}, + "dtcs": [{"code","desc","system","no_start","causes"}, ...] +} +""" +import glob +import json +import os +from dataclasses import dataclass, field + +from .formula import compile_formula +from .registry import Pid, Dtc + +SCHEMA = 1 +BYTE_VARS = [chr(65 + i) for i in range(8)] # A..H + + +@dataclass +class Profile: + meta: dict + pids: list + dtcs: list + presets: dict + path: str = None + + @property + def name(self): + return self.meta.get("name", "Unnamed profile") + + +def profiles_dir(): + return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "profiles") + + +def _round(v, rnd): + if rnd is None: + return v + return int(round(v)) if rnd == 0 else round(v, rnd) + + +def _build_decode(d): + mode = d.get("mode", "22") + rnd = d.get("round") + if mode == "atrv": + return None + formula = d.get("formula", "") + if mode == "derived": + deps = tuple(d.get("deps", ())) + fn = compile_formula(formula, deps) + def dec(vals, fn=fn, deps=deps, rnd=rnd): + return _round(fn(dict(zip(deps, vals))), rnd) + return dec + fn = compile_formula(formula, BYTE_VARS) + def dec(raw, fn=fn, rnd=rnd): + names = {BYTE_VARS[i]: raw[i] for i in range(min(len(raw), 8))} + return _round(fn(names), rnd) + return dec + + +def _pid_from_dict(d): + return Pid( + key=d["key"], name=d.get("name", d["key"]), mode=d.get("mode", "22"), + pid=d.get("pid", ""), nbytes=d.get("nbytes", 2), + formula=d.get("formula", ""), decode=_build_decode(d), + unit=d.get("unit", ""), group=d.get("group", "misc"), + vmin=d.get("vmin", 0.0), vmax=d.get("vmax", 100.0), + confidence=d.get("confidence", "verified"), round=d.get("round"), + deps=tuple(d.get("deps", ())), notes=d.get("notes", ""), + ) + + +def load_profile(path): + with open(path) as f: + raw = json.load(f) + if raw.get("schema", 1) != SCHEMA: + raise ValueError(f"unsupported profile schema {raw.get('schema')} in {path}") + pids = [_pid_from_dict(d) for d in raw.get("pids", [])] + dtcs = [Dtc(code=x["code"], desc=x.get("desc", ""), system=x.get("system", "powertrain"), + no_start=x.get("no_start", False), causes=x.get("causes", "")) + for x in raw.get("dtcs", [])] + return Profile(meta=raw.get("meta", {}), pids=pids, dtcs=dtcs, + presets=raw.get("presets", {}), path=path) + + +def _pid_to_dict(p): + d = {"key": p.key, "name": p.name, "mode": p.mode} + if p.pid: + d["pid"] = p.pid + if p.mode in ("01", "22"): + d["nbytes"] = p.nbytes + if p.formula: + d["formula"] = p.formula + if p.deps: + d["deps"] = list(p.deps) + d.update({"unit": p.unit, "group": p.group, "vmin": p.vmin, "vmax": p.vmax, + "confidence": p.confidence}) + if p.round is not None: + d["round"] = p.round + if p.notes: + d["notes"] = p.notes + return d + + +def save_profile(profile, path=None): + path = path or profile.path + out = { + "schema": SCHEMA, + "meta": profile.meta, + "pids": [_pid_to_dict(p) for p in profile.pids], + "presets": profile.presets, + "dtcs": [{"code": d.code, "desc": d.desc, "system": d.system, + "no_start": d.no_start, "causes": d.causes} for d in profile.dtcs], + } + with open(path, "w") as f: + json.dump(out, f, indent=2) + return path + + +def list_profiles(directory=None): + """Return [(path, meta_dict), ...] for every *.json profile in directory.""" + directory = directory or profiles_dir() + out = [] + for p in sorted(glob.glob(os.path.join(directory, "*.json"))): + try: + with open(p) as f: + meta = json.load(f).get("meta", {}) + out.append((p, meta)) + except Exception: + continue + return out + + +DEFAULT_PROFILE = "ford-6.0-powerstroke.json" + + +def default_profile_path(): + return os.path.join(profiles_dir(), DEFAULT_PROFILE) + + +def load_default(): + return load_profile(default_profile_path()) diff --git a/obdcore/registry.py b/obdcore/registry.py index 20bc5d3..4f4ec26 100644 --- a/obdcore/registry.py +++ b/obdcore/registry.py @@ -1,137 +1,49 @@ -"""PID + DTC registry for the Ford 6.0L Power Stroke (plus generic OBD-II). +"""PID + DTC data model and registry, backed by a vehicle Profile. -Canonical home for the verified Mode-22 addresses, scaling, and the DTC -database. Decoders are plain callables on the raw byte list. Confidence: - verified -- multi-source AND confirmed on the truck's scan/crank - doc -- corroborated in sources, not (yet) read on the truck - tentative -- single-source or disputed scaling - -PID numbers/scaling corrected 2026-06-29 by the ford-60-pid-hunt workflow; -see diagnostics/2026-06-29-no-start/pid-research.md. 09D0 (FICM Main) was -confirmed on-truck 2026-06-30 (read 48.0V during a crank, intermittent). +The actual PID numbers, scaling formulas, and DTC meanings live in JSON +vehicle profiles under profiles/ (data, not code) so the app is vehicle- +agnostic and others can contribute profiles. This module is the in-memory +model + lookups; profile.py loads/saves the JSON. """ from dataclasses import dataclass, field from typing import Callable, Tuple -def _u16(b): - return (b[0] << 8) + b[1] - - @dataclass class Pid: key: str name: str - mode: str # "01" | "22" | "atrv" | "derived" + mode: str = "22" # "01" | "22" | "atrv" | "derived" pid: str = "" # hex: "1446" (m22) or "0C" (m01) nbytes: int = 2 - decode: Callable = None # m01/m22: f(raw_bytes); derived: f(dep_values) + formula: str = "" # scaling expr in A/B/... (raw) or dep keys (derived) + decode: Callable = None # built from formula by profile loader unit: str = "" - group: str = "misc" # fuel | ficm | air | engine | driveline | power + group: str = "misc" # fuel | ficm | air | engine | driveline | power | misc vmin: float = 0.0 vmax: float = 100.0 - confidence: str = "verified" - deps: Tuple[str, ...] = () # for derived channels + confidence: str = "verified" # verified | doc | tentative + round: int = None # display rounding (None=raw float, 0=int) + deps: Tuple[str, ...] = () notes: str = "" -def _build(): - P = [] - a = P.append - # ---- Ford-enhanced Mode 22 -- pressures / fuel ---- - a(Pid("ICP", "Injection Control Pressure", "22", "1446", 2, - lambda b: round(_u16(b) * 0.57, 1), "psi", "fuel", 0, 3500, - "verified", notes="need ~500+ psi to fire")) - a(Pid("ICP_V", "ICP Sensor Voltage", "22", "16AD", 2, - lambda b: round(_u16(b) * 0.000072, 4), "V", "fuel", 0, 5, - "tentative", notes="single-source")) - a(Pid("IPR", "Injection Pressure Regulator", "22", "1434", 1, - lambda b: round(b[0] * 13.53 / 35, 1), "%", "fuel", 0, 100, - "tentative", notes="KOEO ~14-15%, cranking ~30-40%")) - a(Pid("MAP", "Manifold Absolute Pressure", "22", "1440", 2, - lambda b: round(_u16(b) * 0.03625, 2), "psia", "air", 0, 60, - "verified")) - a(Pid("BARO", "Barometric Pressure", "22", "1442", 2, - lambda b: round(_u16(b) * 0.03625, 2), "psia", "air", 0, 20, - "verified")) - a(Pid("EBP", "Exhaust Back Pressure", "22", "1445", 2, - lambda b: round(_u16(b) * 0.03625, 2), "psia", "air", 0, 60, - "verified", notes="minus BARO = gauge")) - a(Pid("EOT", "Engine Oil Temperature", "22", "1310", 2, - lambda b: round(_u16(b) / 100.0 - 40, 1), "C", "engine", -40, 160, - "verified")) - # ---- FICM ---- - a(Pid("FICM_M", "FICM Main Power", "22", "09D0", 2, - lambda b: round(_u16(b) / 256.0, 1), "V", "ficm", 0, 55, - "verified", notes="~48V; <45 suspect; reads intermittently while cranking")) - a(Pid("FICM_L", "FICM Logic Power", "22", "09CF", 2, - lambda b: round(_u16(b) / 256.0, 1), "V", "ficm", 0, 16, - "doc")) - a(Pid("FICM_V", "FICM Vehicle Power", "22", "09CE", 2, - lambda b: round(_u16(b) / 256.0, 1), "V", "ficm", 0, 16, - "doc")) - a(Pid("FICM_SYNC", "FICM Sync", "22", "09CD", 1, - lambda b: (b[0] >> 1) & 1, "", "ficm", 0, 1, - "doc", notes="1=in sync, 0=no sync")) - # ---- Driveline ---- - a(Pid("GEAR", "Current Gear", "22", "11B3", 1, - lambda b: b[0] // 2, "", "driveline", 0, 6, "verified")) - a(Pid("TSS", "Trans Input Shaft Speed", "22", "11B4", 2, - lambda b: round(_u16(b) / 4), "rpm", "driveline", 0, 4000, "verified")) - # ---- Generic Mode 01 ---- - a(Pid("RPM", "Engine RPM", "01", "0C", 2, - lambda b: round(_u16(b) / 4), "rpm", "engine", 0, 4000, "verified")) - a(Pid("ECT", "Engine Coolant Temp", "01", "05", 1, - lambda b: b[0] - 40, "C", "engine", -40, 160, "verified")) - a(Pid("IAT", "Intake Air Temp", "01", "0F", 1, - lambda b: b[0] - 40, "C", "air", -40, 160, "verified")) - a(Pid("LOAD", "Engine Load", "01", "04", 1, - lambda b: round(b[0] * 100 / 255), "%", "engine", 0, 100, "verified")) - a(Pid("VPCM", "Module Voltage", "01", "42", 2, - lambda b: round(_u16(b) / 1000.0, 2), "V", "power", 0, 16, "verified")) - # ---- More documented PIDs from the workflow (not yet truck-verified) ---- - a(Pid("VGT", "VGT Duty Cycle", "22", "096D", 2, - lambda b: round(_u16(b) * 100 / 32767, 1), "%", "air", 0, 100, - "doc", notes="turbo vane duty")) - a(Pid("FAN", "Fan Speed", "22", "099F", 2, - lambda b: round(_u16(b) / 4), "rpm", "engine", 0, 4000, - "doc", notes="real ceiling ~3500")) - a(Pid("INJ_TIMING", "Injection Timing", "22", "09CC", 2, - lambda b: round(_u16(b) * 10 / 64, 1), "degBTDC", "fuel", -10, 30, - "tentative", notes="scaling disputed; using *10/64 (ScanGauge), not /10")) - a(Pid("VBAT", "Battery (PCM)", "22", "1172", 1, - lambda b: round(b[0] / 16, 1), "V", "power", 0, 16, - "tentative", notes="PCM-reported B+; distinct from ATRV port voltage")) - a(Pid("FUEL_PUMP", "Fuel Pump Duty (HFCM)", "22", "1672", 1, - lambda b: round(b[0] * 100 / 128, 1), "%", "fuel", 0, 100, - "tentative", notes="sits ~100%, drops on high EOT")) - a(Pid("FUEL_LVL", "Fuel Level", "22", "16C1", 2, - lambda b: round(_u16(b) * 100 / 328, 1), "%", "misc", 0, 100, - "tentative", notes="UNCALIBRATED -- needs per-truck full/empty cal")) - a(Pid("MFDES", "Mass Fuel Desired", "22", "1411", 2, - lambda b: _u16(b), "raw", "fuel", 0, 65535, - "tentative", notes="~mg/stroke internal count; no verified GPH formula")) - # ---- Pseudo / derived ---- - a(Pid("BATT", "Battery (OBD port)", "atrv", "", 0, - None, "V", "power", 0, 16, "verified")) - a(Pid("BOOST", "Boost (MGP)", "derived", "", 0, - lambda vals: round(vals[0] - vals[1], 2), "psi", "air", -5, 40, - "verified", deps=("MAP", "BARO"), notes="MAP - BARO")) - return P - - -# Subscription presets per perspective (key -> default poll Hz set by scheduler) -PRESETS = { - "crank": ["ICP", "FICM_M", "BATT", "RPM"], - "driving": ["BOOST", "VGT", "EOT", "ECT", "EBP", "LOAD", "RPM", "IPR", "BATT"], - "vitals": ["ICP", "FICM_M", "FICM_L", "IPR", "BATT", "RPM", "ECT", "EOT", - "IAT", "VPCM"], -} +@dataclass +class Dtc: + code: str + desc: str + system: str = "powertrain" + no_start: bool = False + causes: str = "" class PidRegistry: - def __init__(self): - self._by_key = {p.key: p for p in _build()} + """In-memory PID set + presets for the active vehicle profile.""" + + def __init__(self, profile): + self.profile = profile + self._by_key = {p.key: p for p in profile.pids} + self.presets = dict(profile.presets) def get(self, key): return self._by_key.get(key) @@ -143,41 +55,15 @@ class PidRegistry: return [p for p in self._by_key.values() if p.group == g] def preset(self, name): - return [self._by_key[k] for k in PRESETS.get(name, []) if k in self._by_key] + return [self._by_key[k] for k in self.presets.get(name, []) if k in self._by_key] - -# --------------------------------------------------------------------------- -# DTC database -- generic SAE + notable Ford 6.0 codes. The full Ford code -# DB is being built by a separate cross-verified workflow; this is the seed. -# --------------------------------------------------------------------------- -@dataclass -class Dtc: - code: str - desc: str - system: str = "powertrain" - no_start: bool = False - causes: str = "" - - -def _dtcs(): - rows = [ - Dtc("P0087", "Fuel rail/system pressure too LOW", "fuel", True), - Dtc("P0088", "Fuel rail/system pressure too HIGH", "fuel"), - Dtc("P0148", "Fuel delivery error (low pressure / HPOP / IPR)", "fuel", True), - Dtc("P0335", "Crankshaft position (CKP) sensor circuit", "engine", True), - Dtc("P0340", "Camshaft position (CMP) sensor circuit", "engine", True), - Dtc("P0611", "FICM performance", "ficm", True), - Dtc("P1316", "Injector circuit/FICM codes detected", "ficm", True), - Dtc("P0606", "PCM processor fault", "power", True), - Dtc("U0100", "Lost communication with PCM/ECM", "network", True), - Dtc("P0670", "Glow plug control module circuit", "engine"), - ] - return {d.code: d for d in rows} + def preset_names(self): + return list(self.presets.keys()) class DtcDatabase: - def __init__(self): - self._db = _dtcs() + def __init__(self, profile): + self._db = {d.code: d for d in profile.dtcs} def get(self, code): return self._db.get(code) or Dtc(code, "(unknown - look up this code)") diff --git a/obdcore/store.py b/obdcore/store.py index ae19e9a..6cd371a 100644 --- a/obdcore/store.py +++ b/obdcore/store.py @@ -87,6 +87,35 @@ class TimeSeriesStore: with self._lock: return list(self._ch.keys()) + def clear(self): + """Empty every channel's history + min/max (start a fresh capture).""" + with self._lock: + chans = list(self._ch.values()) + for c in chans: + with c._lock: + c.buf.clear() + c.lo = c.hi = c.last_v = c.last_t = None + + def snapshot(self): + """Return {key: [(t, v), ...]} of all current channel history.""" + with self._lock: + chans = dict(self._ch) + return {k: c.series() for k, c in chans.items()} + + +def export_csv(store, path): + """Write a store's current buffers to a long-format CSV (t,key,value).""" + rows = [] + for key, series in store.snapshot().items(): + for t, v in series: + rows.append((t, key, v)) + rows.sort(key=lambda r: r[0]) + with open(path, "w") as f: + f.write("t,key,value\n") + for t, key, v in rows: + f.write(f"{t:.3f},{key},{'' if v is None else v}\n") + return path + class CsvRecorder: """Long-format session recorder: one row per sample (t,key,value). diff --git a/profiles/README.md b/profiles/README.md new file mode 100644 index 0000000..2a333c6 --- /dev/null +++ b/profiles/README.md @@ -0,0 +1,79 @@ +# Vehicle Profiles + +Each `*.json` file here is a **vehicle profile** — pure data that makes the +ford-obd app vehicle-agnostic. A profile defines a vehicle's PIDs (with safe +scaling formulas), DTC meanings, and named presets. Load one in the app via +**Profile → Load**, or drop a new file in this folder and it appears in the list. + +**Contributions welcome** — add a profile for your vehicle and open a PR. + +## Current profiles + +| File | Vehicle | Notes | +|---|---|---| +| `ford-6.0-powerstroke.json` | Ford 6.0L Power Stroke (2003–2007) | Verified Mode-22 PIDs (ICP, FICM, EBP, MAP/BARO, EOT, …) + DTCs | +| `generic-obd2.json` | Any OBD-II vehicle (1996+) | Standard SAE Mode-01 PIDs only — a base to fork from | + +## Schema (`schema: 1`) + +```jsonc +{ + "schema": 1, + "meta": { + "name": "Ford 6.0L Power Stroke", // shown in the Profile menu + "make": "Ford", "model": "...", "years": "2003-2007", + "engine": "6.0L Power Stroke diesel", + "author": "you", "version": "1.0.0", + "protocol": "auto", // ELM ATSP target, or "auto" + "notes": "provenance / confidence policy / caveats" + }, + "presets": { "crank": ["ICP","FICM_M","BATT","RPM"], "...": [] }, + "pids": [ /* see below */ ], + "dtcs": [ {"code":"P0087","desc":"...","system":"fuel","no_start":true,"causes":""} ] +} +``` + +### PID fields + +| Field | Meaning | +|---|---| +| `key` | short unique id used in presets/derived (e.g. `ICP`) | +| `name` | display name | +| `mode` | `01` (generic SAE), `22` (manufacturer-enhanced), `atrv` (adapter pin voltage), `derived` (computed from other PIDs) | +| `pid` | request id hex — `0C` (mode 01) or `1446` (mode 22) | +| `nbytes` | expected data bytes in the response | +| `formula` | scaling expression (see below) | +| `round` | display rounding: omit = raw, `0` = integer, `2` = 2 dp | +| `unit`, `group` | display unit; group = `fuel\|ficm\|air\|engine\|driveline\|power\|misc` | +| `vmin`,`vmax` | range (used for gauges + the Normalize overlay) | +| `confidence` | `verified` (multi-source / read on a real vehicle), `doc` (sourced, unconfirmed), `tentative` (single-source / disputed) | +| `deps` | for `derived`: the PID keys the formula references | +| `notes` | freeform; surfaced as a tooltip | + +### Formula language + +Arithmetic over **data-byte variables** `A, B, C, …` (byte 0, 1, 2, …) — the +same convention as Torque/FORScan/ScanGauge: + +``` +(A*256+B)*0.57 # 16-bit * scale (ICP psi) +A-40 # 8-bit temp +(A>>1)&1 # a status bit +A//2 # integer divide (gear) +``` + +For `derived` PIDs the variables are **other PID keys**: `"MAP - BARO"` with +`"deps": ["MAP","BARO"]`. + +Formulas are evaluated by a **safe AST evaluator** (`obdcore/formula.py`): +only numbers, the declared variables, arithmetic/bitwise operators, and +`min/max/abs/round/int/float` are allowed. Anything else (names, attribute +access, arbitrary calls) is rejected at load — so a community profile **cannot +execute code**. + +## Caveats worth recording in `notes` + +- Manufacturer-enhanced (`22`) PIDs vary by model year and PCM strategy. +- Some signals aren't on the OBD stream at all (e.g. the 6.0 has no EGT or + lube-oil-pressure PID — only ICP and EOT). Don't invent them. +- Mark single-source numbers `tentative` and say so in `notes`. diff --git a/profiles/ford-6.0-powerstroke.json b/profiles/ford-6.0-powerstroke.json new file mode 100644 index 0000000..49690b8 --- /dev/null +++ b/profiles/ford-6.0-powerstroke.json @@ -0,0 +1,60 @@ +{ + "schema": 1, + "meta": { + "name": "Ford 6.0L Power Stroke", + "make": "Ford", + "model": "Super Duty / Excursion", + "years": "2003-2007", + "engine": "6.0L Power Stroke diesel", + "author": "ford-obd project", + "version": "1.1.0", + "protocol": "auto", + "notes": "PID addresses + scaling corrected/verified by the ford-60-pid-hunt workflow (2026-06-29) and on-truck reads (2026-06-30). confidence: verified = multi-source or read on a real 6.0; doc = corroborated in sources, not yet read on-vehicle; tentative = single-source / disputed scaling. ICP_DES (desired ICP) has no public Mode-22 DID -> FORScan-only, not included." + }, + "presets": { + "crank": ["ICP", "FICM_M", "BATT", "RPM"], + "driving": ["BOOST", "VGT", "EOT", "ECT", "EBP", "LOAD", "RPM", "IPR", "BATT"], + "vitals": ["ICP", "FICM_M", "FICM_L", "IPR", "BATT", "RPM", "ECT", "EOT", "IAT", "VPCM"] + }, + "pids": [ + {"key": "ICP", "name": "Injection Control Pressure", "mode": "22", "pid": "1446", "nbytes": 2, "formula": "(A*256+B)*0.57", "round": 1, "unit": "psi", "group": "fuel", "vmin": 0, "vmax": 3500, "confidence": "verified", "notes": "need ~500+ psi to fire"}, + {"key": "ICP_V", "name": "ICP Sensor Voltage", "mode": "22", "pid": "16AD", "nbytes": 2, "formula": "(A*256+B)*0.000072", "round": 4, "unit": "V", "group": "fuel", "vmin": 0, "vmax": 5, "confidence": "tentative", "notes": "single-source"}, + {"key": "IPR", "name": "Injection Pressure Regulator", "mode": "22", "pid": "1434", "nbytes": 1, "formula": "A*13.53/35", "round": 1, "unit": "%", "group": "fuel", "vmin": 0, "vmax": 100, "confidence": "tentative", "notes": "KOEO ~14-15%, cranking ~30-40%"}, + {"key": "INJ_TIMING", "name": "Injection Timing", "mode": "22", "pid": "09CC", "nbytes": 2, "formula": "(A*256+B)*10/64", "round": 1, "unit": "degBTDC", "group": "fuel", "vmin": -10, "vmax": 30, "confidence": "tentative", "notes": "scaling disputed; using *10/64 (ScanGauge), not /10"}, + {"key": "FUEL_PUMP", "name": "Fuel Pump Duty (HFCM)", "mode": "22", "pid": "1672", "nbytes": 1, "formula": "A*100/128", "round": 1, "unit": "%", "group": "fuel", "vmin": 0, "vmax": 100, "confidence": "tentative", "notes": "sits ~100%, drops on high EOT"}, + {"key": "MFDES", "name": "Mass Fuel Desired", "mode": "22", "pid": "1411", "nbytes": 2, "formula": "A*256+B", "round": 0, "unit": "raw", "group": "fuel", "vmin": 0, "vmax": 65535, "confidence": "tentative", "notes": "~mg/stroke internal count; no verified GPH formula"}, + {"key": "FICM_M", "name": "FICM Main Power", "mode": "22", "pid": "09D0", "nbytes": 2, "formula": "(A*256+B)/256", "round": 1, "unit": "V", "group": "ficm", "vmin": 0, "vmax": 55, "confidence": "verified", "notes": "~48V; <45 suspect; reads intermittently while cranking"}, + {"key": "FICM_L", "name": "FICM Logic Power", "mode": "22", "pid": "09CF", "nbytes": 2, "formula": "(A*256+B)/256", "round": 1, "unit": "V", "group": "ficm", "vmin": 0, "vmax": 16, "confidence": "doc"}, + {"key": "FICM_V", "name": "FICM Vehicle Power", "mode": "22", "pid": "09CE", "nbytes": 2, "formula": "(A*256+B)/256", "round": 1, "unit": "V", "group": "ficm", "vmin": 0, "vmax": 16, "confidence": "doc"}, + {"key": "FICM_SYNC", "name": "FICM Sync", "mode": "22", "pid": "09CD", "nbytes": 1, "formula": "(A>>1)&1", "round": 0, "unit": "", "group": "ficm", "vmin": 0, "vmax": 1, "confidence": "doc", "notes": "1=in sync, 0=no sync"}, + {"key": "MAP", "name": "Manifold Absolute Pressure", "mode": "22", "pid": "1440", "nbytes": 2, "formula": "(A*256+B)*0.03625", "round": 2, "unit": "psia", "group": "air", "vmin": 0, "vmax": 60, "confidence": "verified"}, + {"key": "BARO", "name": "Barometric Pressure", "mode": "22", "pid": "1442", "nbytes": 2, "formula": "(A*256+B)*0.03625", "round": 2, "unit": "psia", "group": "air", "vmin": 0, "vmax": 20, "confidence": "verified"}, + {"key": "EBP", "name": "Exhaust Back Pressure", "mode": "22", "pid": "1445", "nbytes": 2, "formula": "(A*256+B)*0.03625", "round": 2, "unit": "psia", "group": "air", "vmin": 0, "vmax": 60, "confidence": "verified", "notes": "minus BARO = gauge"}, + {"key": "VGT", "name": "VGT Duty Cycle", "mode": "22", "pid": "096D", "nbytes": 2, "formula": "(A*256+B)*100/32767", "round": 1, "unit": "%", "group": "air", "vmin": 0, "vmax": 100, "confidence": "doc", "notes": "turbo vane duty"}, + {"key": "EOT", "name": "Engine Oil Temperature", "mode": "22", "pid": "1310", "nbytes": 2, "formula": "(A*256+B)/100-40", "round": 1, "unit": "C", "group": "engine", "vmin": -40, "vmax": 160, "confidence": "verified"}, + {"key": "FAN", "name": "Fan Speed", "mode": "22", "pid": "099F", "nbytes": 2, "formula": "(A*256+B)/4", "round": 0, "unit": "rpm", "group": "engine", "vmin": 0, "vmax": 4000, "confidence": "doc", "notes": "real ceiling ~3500"}, + {"key": "GEAR", "name": "Current Gear", "mode": "22", "pid": "11B3", "nbytes": 1, "formula": "A//2", "round": 0, "unit": "", "group": "driveline", "vmin": 0, "vmax": 6, "confidence": "verified"}, + {"key": "TSS", "name": "Trans Input Shaft Speed", "mode": "22", "pid": "11B4", "nbytes": 2, "formula": "(A*256+B)/4", "round": 0, "unit": "rpm", "group": "driveline", "vmin": 0, "vmax": 4000, "confidence": "verified"}, + {"key": "RPM", "name": "Engine RPM", "mode": "01", "pid": "0C", "nbytes": 2, "formula": "(A*256+B)/4", "round": 0, "unit": "rpm", "group": "engine", "vmin": 0, "vmax": 4000, "confidence": "verified"}, + {"key": "ECT", "name": "Engine Coolant Temp", "mode": "01", "pid": "05", "nbytes": 1, "formula": "A-40", "round": 0, "unit": "C", "group": "engine", "vmin": -40, "vmax": 160, "confidence": "verified"}, + {"key": "IAT", "name": "Intake Air Temp", "mode": "01", "pid": "0F", "nbytes": 1, "formula": "A-40", "round": 0, "unit": "C", "group": "air", "vmin": -40, "vmax": 160, "confidence": "verified"}, + {"key": "LOAD", "name": "Engine Load", "mode": "01", "pid": "04", "nbytes": 1, "formula": "A*100/255", "round": 0, "unit": "%", "group": "engine", "vmin": 0, "vmax": 100, "confidence": "verified"}, + {"key": "VPCM", "name": "Module Voltage", "mode": "01", "pid": "42", "nbytes": 2, "formula": "(A*256+B)/1000", "round": 2, "unit": "V", "group": "power", "vmin": 0, "vmax": 16, "confidence": "verified"}, + {"key": "VBAT", "name": "Battery (PCM)", "mode": "22", "pid": "1172", "nbytes": 1, "formula": "A/16", "round": 1, "unit": "V", "group": "power", "vmin": 0, "vmax": 16, "confidence": "tentative", "notes": "PCM-reported B+; distinct from ATRV port voltage"}, + {"key": "FUEL_LVL", "name": "Fuel Level", "mode": "22", "pid": "16C1", "nbytes": 2, "formula": "(A*256+B)*100/328", "round": 1, "unit": "%", "group": "misc", "vmin": 0, "vmax": 100, "confidence": "tentative", "notes": "UNCALIBRATED -- needs per-truck full/empty cal"}, + {"key": "BATT", "name": "Battery (OBD port)", "mode": "atrv", "unit": "V", "group": "power", "vmin": 0, "vmax": 16, "confidence": "verified", "notes": "ELM327 ATRV pin voltage"}, + {"key": "BOOST", "name": "Boost (MGP)", "mode": "derived", "formula": "MAP-BARO", "deps": ["MAP", "BARO"], "round": 2, "unit": "psi", "group": "air", "vmin": -5, "vmax": 40, "confidence": "verified", "notes": "MAP - BARO"} + ], + "dtcs": [ + {"code": "P0087", "desc": "Fuel rail/system pressure too LOW", "system": "fuel", "no_start": true}, + {"code": "P0088", "desc": "Fuel rail/system pressure too HIGH", "system": "fuel"}, + {"code": "P0148", "desc": "Fuel delivery error (low pressure / HPOP / IPR)", "system": "fuel", "no_start": true}, + {"code": "P0335", "desc": "Crankshaft position (CKP) sensor circuit", "system": "engine", "no_start": true}, + {"code": "P0340", "desc": "Camshaft position (CMP) sensor circuit", "system": "engine", "no_start": true}, + {"code": "P0611", "desc": "FICM performance", "system": "ficm", "no_start": true}, + {"code": "P1316", "desc": "Injector circuit/FICM codes detected", "system": "ficm", "no_start": true}, + {"code": "P0606", "desc": "PCM processor fault", "system": "power", "no_start": true}, + {"code": "U0100", "desc": "Lost communication with PCM/ECM", "system": "network", "no_start": true}, + {"code": "P0670", "desc": "Glow plug control module circuit", "system": "engine"} + ] +} diff --git a/profiles/generic-obd2.json b/profiles/generic-obd2.json new file mode 100644 index 0000000..53cf6bb --- /dev/null +++ b/profiles/generic-obd2.json @@ -0,0 +1,31 @@ +{ + "schema": 1, + "meta": { + "name": "Generic OBD-II", + "make": "Any", + "model": "Any OBD-II vehicle (1996+)", + "years": "1996+", + "engine": "any", + "author": "ford-obd project", + "version": "1.0.0", + "protocol": "auto", + "notes": "Standard SAE J1979 Mode-01 PIDs only -- supported by essentially every OBD-II vehicle. Use as a base/starting point for a new vehicle profile, then add manufacturer-enhanced Mode-22 PIDs. Decodes are the SAE-standard formulas." + }, + "presets": { + "basic": ["RPM", "SPEED", "ECT", "IAT", "MAP", "THROTTLE", "LOAD", "BATT"] + }, + "pids": [ + {"key": "RPM", "name": "Engine RPM", "mode": "01", "pid": "0C", "nbytes": 2, "formula": "(A*256+B)/4", "round": 0, "unit": "rpm", "group": "engine", "vmin": 0, "vmax": 8000, "confidence": "verified"}, + {"key": "SPEED", "name": "Vehicle Speed", "mode": "01", "pid": "0D", "nbytes": 1, "formula": "A", "round": 0, "unit": "km/h", "group": "driveline", "vmin": 0, "vmax": 255, "confidence": "verified"}, + {"key": "ECT", "name": "Engine Coolant Temp", "mode": "01", "pid": "05", "nbytes": 1, "formula": "A-40", "round": 0, "unit": "C", "group": "engine", "vmin": -40, "vmax": 215, "confidence": "verified"}, + {"key": "IAT", "name": "Intake Air Temp", "mode": "01", "pid": "0F", "nbytes": 1, "formula": "A-40", "round": 0, "unit": "C", "group": "air", "vmin": -40, "vmax": 215, "confidence": "verified"}, + {"key": "MAP", "name": "Intake Manifold Pressure", "mode": "01", "pid": "0B", "nbytes": 1, "formula": "A", "round": 0, "unit": "kPa", "group": "air", "vmin": 0, "vmax": 255, "confidence": "verified"}, + {"key": "MAF", "name": "Mass Air Flow", "mode": "01", "pid": "10", "nbytes": 2, "formula": "(A*256+B)/100", "round": 2, "unit": "g/s", "group": "air", "vmin": 0, "vmax": 655, "confidence": "verified"}, + {"key": "THROTTLE", "name": "Throttle Position", "mode": "01", "pid": "11", "nbytes": 1, "formula": "A*100/255", "round": 0, "unit": "%", "group": "engine", "vmin": 0, "vmax": 100, "confidence": "verified"}, + {"key": "LOAD", "name": "Calculated Load", "mode": "01", "pid": "04", "nbytes": 1, "formula": "A*100/255", "round": 0, "unit": "%", "group": "engine", "vmin": 0, "vmax": 100, "confidence": "verified"}, + {"key": "TIMING", "name": "Timing Advance", "mode": "01", "pid": "0E", "nbytes": 1, "formula": "A/2-64", "round": 1, "unit": "deg", "group": "engine", "vmin": -64, "vmax": 64, "confidence": "verified"}, + {"key": "VPCM", "name": "Module Voltage", "mode": "01", "pid": "42", "nbytes": 2, "formula": "(A*256+B)/1000", "round": 2, "unit": "V", "group": "power", "vmin": 0, "vmax": 16, "confidence": "verified"}, + {"key": "BATT", "name": "Battery (OBD port)", "mode": "atrv", "unit": "V", "group": "power", "vmin": 0, "vmax": 16, "confidence": "verified", "notes": "ELM327 ATRV pin voltage"} + ], + "dtcs": [] +} diff --git a/tests/test_obdcore.py b/tests/test_obdcore.py index ab5c19e..8257580 100644 --- a/tests/test_obdcore.py +++ b/tests/test_obdcore.py @@ -11,7 +11,9 @@ import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from obdcore import PidRegistry, TimeSeriesStore, PollScheduler, CsvRecorder, replay_csv +from obdcore import (PidRegistry, TimeSeriesStore, PollScheduler, CsvRecorder, + replay_csv, load_default, load_profile, default_profile_path, + list_profiles, compile_formula, FormulaError) from obdcore.mock import MockLink @@ -28,7 +30,7 @@ class FakeClock: def _setup(specs): clk = FakeClock() - reg = PidRegistry() + reg = PidRegistry(load_default()) store = TimeSeriesStore() link = MockLink(clock=clk) sch = PollScheduler(link, reg, store, clock=clk) @@ -36,8 +38,33 @@ def _setup(specs): return clk, reg, store, sch +def test_profiles_load_and_validate(): + profs = list_profiles() + assert any("ford-6.0" in p for p, _ in profs), "ford profile should be listed" + for path, meta in profs: + prof = load_profile(path) # compiles every formula -> raises if bad + assert prof.meta.get("name") + assert all(p.decode or p.mode == "atrv" for p in prof.pids) + print(f" {len(profs)} profiles load + compile clean: OK") + + +def test_formula_is_sandboxed(): + # legit + fn = compile_formula("(A*256+B)*0.57", "ABCDEFGH") + assert abs(fn({"A": 0, "B": 22}) - 12.54) < 0.01 + # hostile / disallowed -> rejected at compile + for bad in ("__import__('os').system('x')", "open('/etc/passwd')", + "A.__class__", "Z+1", "A if B else C"): + try: + compile_formula(bad, "ABC") + raise AssertionError(f"should have rejected: {bad}") + except FormulaError: + pass + print(" formula evaluator rejects code/unknowns: OK") + + def test_registry_decoders_match_truck_bytes(): - reg = PidRegistry() + reg = PidRegistry(load_default()) cases = { "ICP": ([0x00, 0x16], 12.5), "EBP": ([0x01, 0x8F], 14.46), "MAP": ([0x01, 0x89], 14.25), "BARO": ([0x01, 0x88], 14.21), @@ -110,7 +137,8 @@ def test_record_replay_roundtrip(tmp_path=None): if __name__ == "__main__": - for fn in [test_registry_decoders_match_truck_bytes, test_crank_ramp_and_peak, + for fn in [test_profiles_load_and_validate, test_formula_is_sandboxed, + test_registry_decoders_match_truck_bytes, test_crank_ramp_and_peak, test_derived_boost_channel, test_dead_pid_parks_and_revives, test_record_replay_roundtrip]: fn()