13 Commits

Author SHA1 Message Date
justin 0b0ecc96e7 Fix #12: pin CI actions to SHAs, container to digest, bound deps
Supply-chain hardening for the release pipeline:
- actions/checkout and softprops/action-gh-release pinned from floating major
  tags to commit SHAs (v4.2.2 / v2.2.1) — a moved tag can no longer inject code
  into the job that holds the release token.
- Linux/arm64 build container pinned by manifest-list digest
  (nikolaik/python-nodejs:python3.12-nodejs20@sha256:9ff0859…).
- requirements-gui.txt gains upper bounds so a breaking major (e.g. numpy 3,
  PySide6 7) can't silently change a release binary; current versions still
  satisfy, so no build change.

Deferred (noted on the issue): hash-verifying the Windows get-pip.py / embed-zip
download — low value + fragile (get-pip.py isn't hash-stable) and that fallback
path is dormant now that the runner has Python installed system-wide.

Closes #12

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 19:42:51 -04:00
justin 39fcf3fb55 Fix #10 + #11: transport hardening + controller resource leaks
#10 transport (obdcore/transport.py):
- TcpTransport.read raises IOError on a real socket error or peer-close instead
  of swallowing it as a timeout, so a dead WiFi link surfaces (via the #8 poll
  handler) as 'connection lost' rather than a frozen dashboard.
- TcpTransport.reset_input_buffer drains at most 64 chunks — never spins forever.
- BleTransport closes the client + stops the event-loop thread on connect
  timeout (no leak), caps the notification buffer at 64 KiB, and close() is
  robust when only partially initialised.

#11 controller (gui/controller.py, obdcore/store.py):
- connect() closes the transport and nulls the link if init()/connect() raises,
  so a failed/retried connect doesn't orphan sockets/threads.
- stop_record() unhooks store.recorder BEFORE closing it, and CsvRecorder now
  has a 'closed' guard so a poll-thread write racing close() is a no-op instead
  of an I/O-on-closed-file crash.

Closes #10
Closes #11

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 19:39:47 -04:00
justin fa7225d6dc Fix #9: DTC/freeze-frame parsing (phantom codes, Mode 02, hex frame index)
- parse_dtcs CAN branch is now message-aware: each ECU reply '<svc> <count>
  <pairs>' has its header stripped per-message, instead of flattening all lines
  and stripping svc+count once. With multiple ECUs the old code ate the second
  header as a DTC pair -> phantom codes. Critically, it does NOT blind-scan for
  svc (0x43 is a legal DTC first byte: C03xx) — a numbered ISO-TP continuation
  is distinguished by its 'N:' frame-index prefix, not by value.
- _line_bytes strips hex frame indices A:-F: (ISO-TP index cycles 0-F), not just
  0-9, so consecutive frames past the 10th aren't dropped.
- read_freeze_frame sends the correct '020200' (svc 02, PID 02, frame 00) and
  skips SID+PID+frame (+3), fixing the off-by-one that mis-read the freeze DTC.
- tests/test_dtc_parse.py: single-frame, multi-ECU (no phantom), numbered
  multiframe with a real C03xx continuation, hex index, non-CAN legacy.

Closes #9

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 19:36:35 -04:00
justin 23c92018c1 Fix #8: scheduler survives link death; timed-out one-offs cancelled
- A transport exception in the poll loop killed the thread silently, leaving the
  GUI on a frozen 'Connected' dashboard and blocking run_oneoff callers for the
  full timeout. _loop now catches it -> stops, fails pending one-offs with the
  real error, and calls an on_error callback. Controller wires on_error to flag
  the connection dead; the GUI detects it in _tick and tears down with a
  'Connection lost' dialog.
- A run_oneoff that timed out left its job queued, so it executed LATER on the
  shared link -- a ghost/duplicate vehicle command. Jobs now carry
  cancelled/started flags under a lock; on timeout a not-yet-started job is
  cancelled (skipped by _drain_oneoffs), and a started one reports 'still
  running -- do NOT retry'. stop() also frees stranded submitters.
- tests/test_scheduler.py: cancel-on-timeout, freed-on-death, loop-survives.

Closes #8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 19:33:33 -04:00
justin b5e0c96763 Fix #7: derive action risk from UDS SIDs; fix response parsing
Untrusted profiles could bypass the confirmation and responses were mis-parsed:

- effective_risk(action): risk is now DERIVED from the actual service IDs the
  steps send — any write/actuator/reset/transfer SID (2F/31/11/14/2E/27/34-37/…)
  forces 'danger'; unknown SID / non-default session / security block force
  'caution'. A profile can only RAISE risk, never label a reflash 'safe'. GUI
  gates the confirmation (and the risk badge) on this derived value.
- Response checks use CONTIGUOUS subsequence matching + a hard '7F <sid>'
  negative-response guard, so an NRC data byte (e.g. 0x7E) can't false-pass as a
  positive response; applied to session/security/step checks.
- 0x78 (responsePending) is treated as in-progress, not terminal failure.
- controller.run_action restores slow ELM timing for the run (0x78 window).
- Tests: risk-cannot-be-downgraded, NRC false-positive rejected, pending handled.

Closes #7

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 19:29:44 -04:00
justin 0f029b724a Fix #6: bound formula evaluation to stop untrusted-profile DoS
The AST sandbox whitelisted ** and << with no magnitude bound, so a hostile
profile formula (9**9**9, 1<<10**9) computed a multi-hundred-MB integer on the
scheduler thread -> CPU pin + OOM. The scheduler except clause never catches a
runaway/OOM (not a raised exception), and a derived PID with empty deps fires
every tick on connect.

- _apply() guards each BinOp: shift amount <= 256, exponent <= 64, and any int
  result bit_length > 512 raises FormulaError (caught by the scheduler -> sample
  dropped, thread survives).
- compile-time caps: expr length <= 500, AST depth <= 60; parse also catches
  RecursionError.
- test_formula_dos_bounded: giant-int payloads rejected in <0.5s; legit bit ops
  and scaling still work.

Closes #6

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 19:26:05 -04:00
justin d435384b58 #2 (framework): bi-directional / service-function engine
Profile-defined UDS action sequences, run safely -- the framework for #2 (real
per-vehicle actuator tests/resets are follow-on, added as verified profile data).

- obdcore/actions.py: Action model + run_action() executing session (Mode 10) ->
  security (Mode 27 seed->key) -> command steps (2F/31/11/3E/... any hex) with
  positive/negative response checks. Security KEY algorithms are per-vehicle
  secrets and NOT bundled -- only trivial transforms (xor-ff/invert/add-ff)
  known; an action naming an unknown algorithm is BLOCKED (fails safe). Never
  synthesizes bytes -- runs only what the profile defines. validate_action()
  rejects malformed hex at load.
- profile.py: load/save an actions[] block; ElmLink/MockLink read_raw(hex).
- GUI: Diagnostics -> Service & Bi-directional dialog -- lists the profile's
  actions with risk badges; caution/danger gated behind a warning confirmation.
- generic-obd2: two safe STANDARD actions (Tester-Present ping; ECU-Reset,
  caution + engine-off warning). PROFILE_SPEC.md documents the actions schema
  + safety rules.
- tests/test_actions.py: runner, session+reset, security handshake, unknown-algo
  block, hex validation, profile load. All 5 suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 16:33:51 -04:00
justin 74bfa2e146 Add 2 Honda vehicle profiles (2007 CR-V 2.4, 2022 Odyssey 3.5)
Built by the honda-profile-research workflow (per-vehicle research ->
synthesize -> adversarial-review) and validated through the loader (every
formula compiles, presets resolve, decoders sane):

- honda-crv-2.4-2007.json    ISO 15765 CAN, MAF + wideband A/F (lambda),
                             single-bank K24Z1 i-VTEC, 19 PIDs, 39 DTCs.
- honda-odyssey-3.5-2022.json ISO 15765 CAN, MAF, dual-bank J35 V6 (STFT/LTFT
                             B1&B2 + 4 O2/AF sensors), 31 PIDs, 83 DTCs.

Standard SAE Mode-01 PIDs (all verified) + gauge zones on ECT/RPM/BATT;
Honda enhanced Mode-22 PIDs omitted (no public source pairs a documented PID
with a verified formula for these -- Honda uses proprietary HDS). Web-
researched, not yet read on the actual vehicles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 08:45:57 -04:00
justin 7bda758f88 Tier 2: WiFi + Bluetooth ELM327 transports
- obdcore/transport.py: pluggable byte transports -- SerialTransport,
  TcpTransport (WiFi ELM327, stdlib socket), BleTransport (experimental, via
  optional 'bleak'; background asyncio loop buffering notifications). ble_scan().
- ElmLink refactored onto a transport with .serial()/.tcp()/.ble() factories
  (close/cmd now go through self.io); no behavior change for serial.
- Controller.connect(conn={kind:serial|wifi|ble,...}); GUI connection bar gains
  a transport selector (Serial/USB/BT-SPP | WiFi host:port | Bluetooth LE + Scan).
- Classic-Bluetooth needs no new code (pairs as a serial port); WiFi needs no
  extra deps; BLE is opt-in (bleak not bundled, so CI binaries keep building).
- tests/test_transport.py: drives ElmLink over a fake ELM TCP server end-to-end
  (connect, RPM, readiness, VIN). All suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 08:24:51 -04:00
justin 6548cf7fbe Section 1 GUI: Vehicle Info, Emissions Readiness, Freeze Frame, Trip/Performance
- Diagnostics menu: Vehicle Info (VIN/cal/ECU), Emissions Readiness (I/M
  monitors + MIL -> pass/fail), Freeze Frame (snapshot + capturing DTC).
  All routed through the scheduler one-off path; dialogs, no docked panels.
- New Trip / Performance view (View menu, center page): live + average MPG,
  trip distance/fuel/time, and 0-60 / 1/4-mile timers. The controller keeps
  SPEED + MAF polled in the background and feeds TripComputer/PerformanceMeter
  every tick, so trips accumulate regardless of the active view. Honest MAF
  caveat shown for speed-density/diesel vehicles.

Validated headless against MockLink: VIN dialog, readiness dialog, freeze-frame
dialog, and the live trip page (28.8 mpg / distance accruing). All tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-06-30 19:43:31 -04:00
justin 4a4daf3fa0 Add generic SAE DTC database (1409 codes) with profile-priority fallback
Compiled by the generic-dtc-db workflow (P0/U0/C0/B0 standard codes, system
tags + no-start flags). Lives in profiles/_data/generic-dtcs.json (bundled with
profiles/, not listed as a vehicle profile). DtcDatabase.get now falls back:
profile code -> generic code -> unknown, so any standard code resolves to a
description while vehicle profiles still override (e.g. P0148 keeps the 6.0 text).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-06-30 19:40:02 -04:00
justin 6c1ee0c81d Section 1 backend: VIN/Mode-09, readiness monitors, freeze-frame, trip/perf
obdcore additions (all standard SAE J1979, vehicle-agnostic, hardware-free
tested):
- obdservices.py: decode_vin (Mode 09), decode_readiness (Mode 01 PID 01 I-M
  monitors + MIL + DTC count, spark/diesel monitor sets), freeze-frame PID set.
- link.py: ElmLink.read_vehicle_info (VIN/cal/ECU), read_readiness, read_freeze_frame.
- trip.py: TripComputer (MAF-based MPG + trip totals) and PerformanceMeter
  (0-60 / 1/4-mile with launch detection).
- mock.py: speed/MAF/readiness + service stubs for GUI mock mode.
- tests/test_services.py: VIN, readiness bit decode, trip math, 0-60/quarter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-06-30 19:37:48 -04:00
justin 310d5a3497 README: rewrite as cross-platform multi-vehicle GUI app; add release checksums
- README now leads with the vehicle-agnostic GUI (download binaries, run from
  source, connect, vehicle profiles), with the Ford 6.0 CLI as a secondary
  section. Documents the unsigned-binary SmartScreen/Gatekeeper bypass.
- CI: each release binary now ships a .sha256 so downloads can be verified
  (free integrity check in lieu of code signing).
- Validated on real vehicles (Jeep 4.0, Mustang Cobra 4.6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-06-30 17:25:43 -04:00
29 changed files with 11983 additions and 209 deletions
+26 -14
View File
@@ -12,7 +12,7 @@ jobs:
windows:
runs-on: windows-latest # self-hosted Windows runner (no Python preinstalled)
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build OBDash.exe (PyInstaller)
shell: pwsh
run: |
@@ -42,18 +42,21 @@ jobs:
& $py -m pip install --no-cache-dir -r requirements-gui.txt pyinstaller
& $py -m PyInstaller --noconfirm --onefile --windowed --name OBDash --add-data "profiles;profiles" run_gui.py
Copy-Item dist/OBDash.exe OBDash-windows.exe
(Get-FileHash OBDash-windows.exe -Algorithm SHA256).Hash.ToLower() + " OBDash-windows.exe" | Out-File -Encoding ascii OBDash-windows.exe.sha256
- name: Publish to release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with:
files: OBDash-windows.exe
files: |
OBDash-windows.exe
OBDash-windows.exe.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
macos:
runs-on: self-hosted-mac
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build OBDash.app (PyInstaller)
shell: bash
run: |
@@ -63,19 +66,22 @@ jobs:
pip install -r requirements-gui.txt pyinstaller
pyinstaller --noconfirm --windowed --name OBDash --add-data "profiles:profiles" run_gui.py
ditto -c -k --keepParent dist/OBDash.app OBDash-macos.zip
shasum -a 256 OBDash-macos.zip > OBDash-macos.zip.sha256
- name: Publish to release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with:
files: OBDash-macos.zip
files: |
OBDash-macos.zip
OBDash-macos.zip.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
linux-amd64:
runs-on: docker # Linux x86_64 runner
container: nikolaik/python-nodejs:python3.12-nodejs20 # has python+pip AND node (for checkout)
container: nikolaik/python-nodejs:python3.12-nodejs20@sha256:9ff0859871d1b3c382a39aa23d998929edaaefb31e6b3cb67f30d2f8c832db73 # has python+pip AND node (for checkout)
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build OBDash (PyInstaller)
shell: bash
run: |
@@ -84,19 +90,22 @@ jobs:
pip install -r requirements-gui.txt pyinstaller
pyinstaller --noconfirm --onefile --name OBDash --add-data "profiles:profiles" run_gui.py
cp dist/OBDash OBDash-linux-x86_64
sha256sum OBDash-linux-x86_64 > OBDash-linux-x86_64.sha256
- name: Publish to release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with:
files: OBDash-linux-x86_64
files: |
OBDash-linux-x86_64
OBDash-linux-x86_64.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
linux-arm64:
runs-on: arm64 # Raspberry Pi (aarch64) runner
container: nikolaik/python-nodejs:python3.12-nodejs20 # multi-arch: pulls arm64
container: nikolaik/python-nodejs:python3.12-nodejs20@sha256:9ff0859871d1b3c382a39aa23d998929edaaefb31e6b3cb67f30d2f8c832db73 # multi-arch: pulls arm64
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build OBDash (PyInstaller)
shell: bash
run: |
@@ -105,10 +114,13 @@ jobs:
pip install -r requirements-gui.txt pyinstaller
pyinstaller --noconfirm --onefile --name OBDash --add-data "profiles:profiles" run_gui.py
cp dist/OBDash OBDash-linux-aarch64
sha256sum OBDash-linux-aarch64 > OBDash-linux-aarch64.sha256
- name: Publish to release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with:
files: OBDash-linux-aarch64
files: |
OBDash-linux-aarch64
OBDash-linux-aarch64.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
+129 -118
View File
@@ -1,139 +1,150 @@
# OBDash
Minimal **ELM327 OBD-II code reader** with a **Ford 6.0L Power Stroke no-start triage**,
built for a cheap CH340 ELM327 USB adapter. Works on any OBD-II vehicle for generic
codes/PIDs; the triage notes are 6.0-specific.
**Open-source, vehicle-agnostic OBD-II scanner — Python/Qt, cross-platform.**
Created as a stopgap while [forscan.org](https://forscan.org) was offline — it covers
reading/clearing codes and the basics, not Ford-enhanced diesel PIDs (see Scope below).
## Features
- Read **stored** (mode 03), **pending** (mode 07), **permanent** (mode 0A) DTCs
- Decode P/C/B/U codes, with common **6.0 codes** described and **no-start suspects flagged**
- **Clear** codes (mode 04) — guarded behind `--clear` + a typed `CLEAR` confirmation,
then re-reads to show any code that returns immediately (active fault)
- Key **live values** (coolant, IAT, MAP, module voltage, RPM, load, throttle) + battery voltage
- 6.0 Power Stroke **no-start triage** checklist (FICM, ICP, cam/crank, batteries, fuel)
## Setup
Runs on **Windows, macOS, and Linux** (Python + pyserial). The only per-OS
difference is the CH340 USB driver:
- **Windows** — install WCH `CH341SER`; adapter shows as `USB-SERIAL CH340 (COMx)`
in Device Manager → Ports. Install Python from <https://www.python.org/downloads/>
(tick **Add Python to PATH**), or just double-click `RUN_OBD.bat`.
- **macOS** — install WCH `CH34xVCPDriver` (Mac App Store or wch.cn). Port appears
as `/dev/cu.wchusbserial*`. `pip install pyserial`.
- **Linux** — `ch341` driver is built into the kernel (no install). Port is
`/dev/ttyUSB0`; add yourself to the `dialout` group for access
(`sudo usermod -aG dialout $USER`, then re-login). `pip install pyserial`.
The tool auto-detects the port on all three; pass it explicitly if needed
(`COM5`, `/dev/cu.usbserial-1420`, `/dev/ttyUSB0`).
## Usage
```
python obd_reader.py # auto-detect the COM port
python obd_reader.py COM5 # force a port
python obd_reader.py COM5 9600 # force port + baud (default 38400)
python obd_reader.py COM5 --clear # read, then optionally clear (asks to confirm)
python obd_reader.py COM5 -v # verbose: show raw ELM327 traffic
```
### Crank monitor (dedicated no-start view) — `--crank`
The one to use for a crank-but-won't-start. Big ICP readout with a wide bar
(the `|` marks the 500-psi firing threshold), a **rolling ASCII trace** of the
ICP build-up, **peak-hold**, FICM/battery/RPM with sag tracking, and a pass/fail
verdict. Start it, then crank.
```
python obd_reader.py COM5 --crank # crank monitor
python obd_reader.py COM5 --crank --dash-log crank.csv # + record a CSV
```
```
ICP [#################################|##----] 539.8 psi
PEAK 540 psi FIRING PRESSURE REACHED
FICM Main 47.5V (min 47.5) [DOC] Batt 12.6V (min 10.7) RPM 200
ICP trace (psi vs time, last 16 samples)
600 |
500 |----------------------------------------------#### <- firing line
| ######
| ########
+--------------------------------------------------
```
**Read it:** ICP should climb **past 500 psi within 12 s** of cranking
(`FIRING PRESSURE REACHED`, green). If it **stalls below 500** (red, trace flat
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)
A cross-platform desktop GUI (PySide6 + pyqtgraph). Vehicle-agnostic — all PIDs,
scaling, DTCs, and presets come from the JSON profiles in `profiles/`.
```
pip install -r requirements-gui.txt
python run_gui.py # tick "Mock" + Connect to explore with no adapter
```
Features so far:
- **PID browser** (left) grouped by system, live values, confidence badges
- **Graph view** with **true multi-axis** overlay — one Y scale per unit (psi/V/rpm/…),
or a Normalize (% of range) mode
- **Gauge view** — arc gauges with peak-hold, one per signal
- **Table view** — value + min/max + confidence
- **Diagnostics** — read/clear DTCs (guarded), no-start codes flagged
- **Profile menu** — switch/import/edit vehicles; **File menu** — record/replay/export captures
A desktop app that turns a cheap ELM327 adapter into a real diagnostic tool:
live multi-axis graphs, automotive-style gauges, a sortable data table, and
DTC read/clear — for **any OBD-II vehicle**. What it can read for each car is
defined by a **JSON vehicle profile** (PIDs, scaling, codes, gauges), so adding
a new vehicle is data, not code. Runs on **Windows, macOS, and Linux**.
![Multi-axis graph](docs/gui-p2-multiaxis.png)
![Gauge view](docs/gui-p2-gauges.png)
The whole app runs against simulated data (`MockLink`), so it can be developed
on any machine and only needs the vehicle for real captures. See
[ARCHITECTURE.md](ARCHITECTURE.md) for the roadmap.
> Validated on real vehicles (1997 Jeep Wrangler 4.0 I6, 1996 Mustang Cobra 4.6
> DOHC, Ford 6.0L Power Stroke, …) using a QinHeng CH340 ELM327 clone.
---
## Features
### Live dashboard (real-time gauges)
- **Live graphs** with **true multi-axis** overlay — each metric gets its own
Y axis, colored to match its line; click a line to move its axis to the left.
Optional Normalize (% of range) mode.
- **Gauge view** — round, tach-style gauges with tick scales, needles, **redline
zones** (configurable per metric), and peak-hold.
- **Table view** — value, min/max, and confidence per signal.
- **Diagnostics** — read/clear trouble codes (guarded), with descriptions from a
built-in **1,400+ generic SAE DTC database** (profiles override) and no-start
codes flagged; plus **freeze-frame** (the snapshot when a code set).
- **Emissions readiness** — I/M monitor status + MIL → a "will it pass inspection?"
report. **Vehicle info** — VIN, calibration IDs, ECU name (Mode 09).
- **Trip / Performance** — live MPG, trip distance/fuel, and **0-60 mph & 1/4-mile**
timers (auto-detected from a standing start).
- **Bi-directional / service functions** — actuator tests, service resets, etc.,
defined per-vehicle in the profile and run behind risk-based confirmations
(ships with safe standard actions; OBDash never synthesizes command bytes).
- **Vehicle profiles** — switch/import/edit vehicles from the Profile menu.
- **Units** — °C/°F toggle (US/metric).
- **Captures** — record a session to CSV and replay it.
- **Mock mode** — explore the whole app with simulated data, no adapter needed.
Updates in place as you crank or run the engine — color-coded, with live
min/max so a crank's **peak ICP** is captured. No extra dependencies (ANSI;
works on any Windows 10+ terminal). `q` quits, `r` resets min/max.
## Download
Grab a prebuilt binary from the [**latest release**](https://git.jpaul.io/justin/obdash/releases/latest):
| Platform | File |
|---|---|
| Windows | `OBDash-windows.exe` |
| macOS | `OBDash-macos.zip` (unzip → `OBDash.app`) |
| Linux x86_64 | `OBDash-linux-x86_64` |
| Linux ARM64 (Raspberry Pi) | `OBDash-linux-aarch64` |
Each binary ships with a `.sha256` so you can verify the download:
```bash
# macOS / Linux
shasum -a 256 -c OBDash-macos.zip.sha256 # macOS
sha256sum -c OBDash-linux-x86_64.sha256 # Linux
```
python obd_reader.py COM5 --dash # vitals preset (ICP, FICM, IPR, batt, RPM, temps)
python obd_reader.py COM5 --dash crank # cranking preset: ICP / FICM main / batt / RPM (fastest)
python obd_reader.py COM5 --dash full # every PID
python obd_reader.py COM5 --dash crank --dash-log crank.csv # + write a CSV while you watch
```powershell
# Windows
(Get-FileHash OBDash-windows.exe -Algorithm SHA256).Hash.ToLower()
Get-Content OBDash-windows.exe.sha256 # compare the two
```
**No-start use:** run `--dash crank`, then crank. A healthy 6.0 builds
**~500+ psi ICP within 12 s**; if ICP stalls below 500 (red), that confirms
the high-pressure oil bleed-off. FICM Main should hold ~48V. The `--dash-log`
CSV is your streaming log — paste it back for analysis.
### Unsigned-binary warnings (expected for open source)
Note: the FICM PIDs (`09xx`) are `[DOC]` (not yet confirmed on this truck); if
they read `--`, they auto-drop after a few frames so the refresh rate stays up.
The binaries aren't code-signed, so the OS will warn on first launch. They're safe
— verify the checksum above, then:
Or just double-click **`RUN_OBD.bat`** on Windows (auto-installs `pyserial`).
- **Windows (SmartScreen):** "Windows protected your PC" → **More info****Run anyway**.
- **macOS (Gatekeeper):** right-click the app → **Open** (then **Open** again), or
clear the quarantine flag: `xattr -dr com.apple.quarantine OBDash.app`.
- **Linux:** `chmod +x OBDash-linux-x86_64 && ./OBDash-linux-x86_64`.
On the truck: plug into the OBD port under the dash, key to **RUN** (engine off is fine
for codes), then run the tool.
(Code signing removes these warnings but costs money / a hardware token; see the
project notes. Checksums give you the same integrity guarantee for free.)
## Scope / honesty
## Run from source
A generic ELM327 reads standard OBD-II only: codes, generic PIDs, port voltage. It does
**not** read Ford-enhanced diesel PIDs (ICP, FICM main/sync voltage, IPR%) — those need
FORScan. For FICM/ICP numbers, measure at the FICM with a meter, or use FORScan when it's
available. Default baud is 38400 (measured on the CH340 adapter); try 9600 if you get garbage.
```bash
pip install -r requirements-gui.txt
python run_gui.py # tick "Mock" + Connect to explore with no adapter
```
## Requirements
Needs Python 3.10+. The GUI deps (`PySide6`, `pyqtgraph`, `numpy`, `pyserial`)
all ship wheels for Windows / macOS (incl. Apple Silicon) / Linux.
`pyserial` (`pip install pyserial`). Tested against a QinHeng CH340 ELM327 v1.5 clone.
## Connecting to a vehicle
Pick the adapter type in the toolbar, then **Connect** (key to RUN). OBDash speaks
to any ELM327 over three transports:
- **Serial / USB / classic-Bluetooth** — the port dropdown. USB CH340 adapters need
a one-time driver:
- **Windows** — WCH `CH341SER`; shows as `USB-SERIAL CH340 (COMx)`.
- **macOS** — WCH `CH34xVCPDriver`; port `/dev/cu.wchusbserial*`.
- **Linux** — kernel `ch341` (no install); `/dev/ttyUSB0` (add yourself to `dialout`).
- **Classic-Bluetooth** ELM327 pair as a serial port (COMx / `/dev/cu.*` / `rfcomm`)
— pair in your OS, then pick that port here.
- **WiFi** — for WiFi ELM327 dongles: enter the host/port (default `192.168.0.10:35000`).
Connect to the dongle's WiFi network first. No driver needed.
- **Bluetooth LE** — Scan and pick the device. BLE support needs `pip install bleak`
(optional; not bundled in the prebuilt binaries). BLE dongle GATT layouts vary, so
this is experimental.
Default serial baud is 38400; the ELM327 auto-negotiates the vehicle's protocol.
## Vehicle profiles
Each `profiles/*.json` teaches OBDash how to read one vehicle. Bundled profiles:
| Profile | Vehicle |
|---|---|
| `generic-obd2.json` | Any OBD-II vehicle (standard SAE PIDs) — a base to fork |
| `ford-6.0-powerstroke.json` | Ford 6.0L Power Stroke (20032007) — incl. enhanced ICP/FICM/EBP PIDs |
| `jeep-wrangler-4.0-1997.json` | 1997 Jeep Wrangler TJ 4.0 I6 |
| `ford-mustang-cobra-4.6-1996.json` | 1996 Mustang SVT Cobra 4.6 DOHC |
| `ford-mustang-gt-4.6-1996.json` | 1996 Mustang GT 4.6 SOHC |
| `mercury-mountaineer-4.6-2006.json` | 2006 Mercury Mountaineer 4.6 V8 |
**Add your vehicle:** the format is documented in
[`profiles/PROFILE_SPEC.md`](profiles/PROFILE_SPEC.md) — it's written to be handed
straight to an AI agent (*"research &lt;year make model&gt; and produce an OBDash
profile per this spec"*). Drop the `.json` in `profiles/`, load it from the
**Profile** menu, and open a PR. Profiles are pure data — they can't run code
(formulas go through a sandboxed evaluator).
## Terminal tool (Ford 6.0 no-start)
The repo also includes `obd_reader.py`, a self-contained **CLI** focused on
diagnosing a **6.0 Power Stroke that won't start** — a big live **ICP-during-crank**
monitor (`--crank`), code read/clear, and CSV logging. It needs only `pyserial`.
See `handoff.md` and `diagnostics/` for the worked no-start investigation that
seeded this project. (The GUI above is the general-purpose, multi-vehicle tool;
the CLI is the diesel-specific workflow it grew out of.)
```bash
python obd_reader.py COM5 --crank # big ICP cranking monitor
python obd_reader.py COM5 --clear # read, then clear codes (guarded)
```
## Project
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — design, the acquisition engine, roadmap.
- Built on a headless `obdcore` package (link / registry / scheduler / store)
shared by the GUI and the CLI; tested without hardware via a mock adapter.
- CI builds the cross-platform binaries on tag (`.gitea/workflows/release.yml`).
## License
MIT — see [`LICENSE`](LICENSE).
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+98 -6
View File
@@ -9,6 +9,7 @@ import time
from obdcore import (PidRegistry, DtcDatabase, TimeSeriesStore, PollScheduler,
CsvRecorder, load_default, load_profile)
from obdcore.mock import MockLink
from obdcore.trip import TripComputer, PerformanceMeter
# default poll rates (Hz) -- fast for the no-start metrics, slower for the rest
FAST = {"ICP", "FICM_M", "RPM"}
@@ -34,6 +35,25 @@ class Controller:
self.sched = None
self.t0 = None
self.connected = False
self.trip = TripComputer()
self.perf = PerformanceMeter()
self.speed_key = None # PID key for standard speed (mode 01 0D)
self.maf_key = None # PID key for standard MAF (mode 01 10)
def _find_std_keys(self):
"""Locate the speed/MAF PIDs (mode 01, pid 0D/10) by any key name."""
self.speed_key = self.maf_key = None
for p in self.reg.all():
if p.mode == "01" and p.pid.upper() == "0D":
self.speed_key = p.key
elif p.mode == "01" and p.pid.upper() == "10":
self.maf_key = p.key
def _on_poll_error(self, exc):
"""Called on the poll thread if it dies (transport failure). Flag the
connection dead so the GUI stops showing frozen 'Connected' data."""
self.poll_error = exc
self.connected = False
def load_profile(self, path):
"""Switch the active vehicle profile (only allowed while disconnected)."""
@@ -41,21 +61,49 @@ class Controller:
self.reg = PidRegistry(self.profile)
self.dtcdb = DtcDatabase(self.profile)
def connect(self, port=None, baud=38400, mock=False):
def connect(self, port=None, baud=38400, mock=False, conn=None):
"""conn: optional {'kind': 'serial'|'wifi'|'ble', ...}. Falls back to
serial(port, baud) for backward compatibility."""
if mock:
self.link = MockLink(clock=time.time)
else:
from obdcore.link import ElmLink # imported lazily (needs pyserial)
self.link = ElmLink(port, baud)
c = conn or {"kind": "serial", "port": port, "baud": baud}
kind = c.get("kind", "serial")
if kind == "wifi":
self.link = ElmLink.tcp(c["host"], c.get("port", 35000))
elif kind == "ble":
self.link = ElmLink.ble(c["address"])
else:
self.link = ElmLink.serial(c.get("port", port), c.get("baud", baud))
try: # don't leak the transport if handshake fails
if not mock:
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)
except Exception:
try:
self.link.close()
except Exception:
pass
self.link = None
raise
self.poll_error = None
self.sched = PollScheduler(self.link, self.reg, self.store, clock=time.time,
on_error=self._on_poll_error)
self.t0 = time.time()
self.connected = True
self.trip.reset()
self.perf = PerformanceMeter()
# keep speed + MAF polled in the background so trip/performance always run
self._find_std_keys()
if self.speed_key:
self.sched.subscribe(self.speed_key, 2)
if self.maf_key:
self.sched.subscribe(self.maf_key, 2)
return ok
def hz_for(self, key):
@@ -80,9 +128,10 @@ class Controller:
self.store.recorder = CsvRecorder(path)
def stop_record(self):
if self.store.recorder:
self.store.recorder.close()
self.store.recorder = None
rec = self.store.recorder
if rec:
self.store.recorder = None # unhook first so the poll thread stops writing
rec.close()
def now(self):
return (time.time() - self.t0) if self.t0 else 0.0
@@ -112,6 +161,49 @@ class Controller:
Returns True if the ECU acknowledged."""
return bool(self._oneoff(lambda: self.link.clear_dtcs()))
# -- standard OBD services (via the one-off path) --
def read_vehicle_info(self):
return self._oneoff(lambda: self.link.read_vehicle_info())
def read_readiness(self):
return self._oneoff(lambda: self.link.read_readiness())
def read_freeze_frame(self):
return self._oneoff(lambda: self.link.read_freeze_frame())
# -- bi-directional / service actions --
def actions(self):
return self.profile.actions or []
def run_action(self, action):
from obdcore.actions import run_action
def go():
# actions/routines can take longer than the fast polling window and
# may reply 0x78 (pending) — restore slow ELM timing for the run
try:
self.link.fast_timing(False)
except Exception:
pass
try:
return run_action(action, self.link)
finally:
try:
self.link.fast_timing(True)
except Exception:
pass
return self._oneoff(go, timeout=25.0)
# -- trip / performance (fed from the live store each GUI tick) --
def update_trip(self):
spd = self.store.latest(self.speed_key) if self.speed_key else None
maf = self.store.latest(self.maf_key) if self.maf_key else None
now = time.time()
self.trip.update(now, spd, maf)
self.perf.update(now, spd)
return spd, maf
def stop(self):
if self.sched:
self.sched.stop()
+319 -14
View File
@@ -80,6 +80,16 @@ class MainWindow(QtWidgets.QMainWindow):
"Read stored / pending / permanent trouble codes")
self.clear_dtc_act = self._act(diagm, "Clear Codes…", self._clear_codes,
"Erase stored codes + freeze frame (mode 04)")
diagm.addSeparator()
self._act(diagm, "Freeze Frame", self._freeze_frame,
"Sensor snapshot captured when a code set (mode 02)")
self._act(diagm, "Emissions Readiness", self._readiness,
"I/M readiness monitors + MIL (will it pass inspection?)")
self._act(diagm, "Vehicle Info (VIN)", self._vehicle_info,
"VIN, calibration IDs, ECU name (mode 09)")
diagm.addSeparator()
self._act(diagm, "Service & Bi-directional…", self._service_actions,
"Actuator tests, service resets, and other profile-defined functions")
viewm = mb.addMenu("&View")
self.view_graph = self._act(viewm, "Graph View", lambda: self._set_view(0),
@@ -89,6 +99,8 @@ class MainWindow(QtWidgets.QMainWindow):
self.view_graph.setChecked(True)
self.view_gauge = self._act(viewm, "Gauge View", lambda: self._set_view(2),
checkable=True)
self.view_trip = self._act(viewm, "Trip / Performance", lambda: self._set_view(3),
checkable=True)
viewm.addSeparator()
self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock,
checkable=True)
@@ -147,16 +159,44 @@ class MainWindow(QtWidgets.QMainWindow):
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.conn_kind = QtWidgets.QComboBox()
self.conn_kind.addItems(["Serial / USB / BT-SPP", "WiFi", "Bluetooth LE"])
self.conn_kind.currentIndexChanged.connect(self._conn_kind_changed)
tb.addWidget(self.conn_kind)
# serial inputs
self._serial_w = []
self._serial_w.append(tb.addWidget(QtWidgets.QLabel(" Port ")))
self.port_combo = QtWidgets.QComboBox(); self.port_combo.setMinimumWidth(180)
self._refresh_ports()
tb.addWidget(self.port_combo)
self._serial_w.append(tb.addWidget(self.port_combo))
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)
tb.addWidget(self.baud_edit)
self._serial_w.append(tb.addWidget(b))
self._serial_w.append(tb.addWidget(QtWidgets.QLabel(" Baud ")))
self.baud_edit = QtWidgets.QLineEdit("38400"); self.baud_edit.setFixedWidth(64)
self._serial_w.append(tb.addWidget(self.baud_edit))
# wifi inputs
self._wifi_w = []
self._wifi_w.append(tb.addWidget(QtWidgets.QLabel(" Host ")))
self.host_edit = QtWidgets.QLineEdit("192.168.0.10"); self.host_edit.setFixedWidth(120)
self._wifi_w.append(tb.addWidget(self.host_edit))
self._wifi_w.append(tb.addWidget(QtWidgets.QLabel(" : ")))
self.tcpport_edit = QtWidgets.QLineEdit("35000"); self.tcpport_edit.setFixedWidth(56)
self._wifi_w.append(tb.addWidget(self.tcpport_edit))
# ble inputs
self._ble_w = []
self._ble_w.append(tb.addWidget(QtWidgets.QLabel(" Device ")))
self.ble_combo = QtWidgets.QComboBox(); self.ble_combo.setMinimumWidth(200)
self.ble_combo.setEditable(True)
self._ble_w.append(tb.addWidget(self.ble_combo))
self.ble_scan_btn = QtWidgets.QToolButton(); self.ble_scan_btn.setText("Scan")
self.ble_scan_btn.clicked.connect(self._ble_scan)
self._ble_w.append(tb.addWidget(self.ble_scan_btn))
tb.addSeparator()
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)
@@ -165,6 +205,35 @@ class MainWindow(QtWidgets.QMainWindow):
self._preset_tb = tb
self._preset_sep = tb.addSeparator()
self._preset_buttons = []
self._conn_kind_changed()
def _conn_kind_changed(self):
k = self.conn_kind.currentIndex()
for a in self._serial_w:
a.setVisible(k == 0)
for a in self._wifi_w:
a.setVisible(k == 1)
for a in self._ble_w:
a.setVisible(k == 2)
def _ble_scan(self):
try:
from obdcore.transport import ble_scan
except Exception:
QtWidgets.QMessageBox.information(self, "Bluetooth LE",
"BLE needs the 'bleak' package (pip install bleak). Classic-Bluetooth "
"ELM327 pair as a serial port — use Serial instead.")
return
self.status.showMessage("Scanning for BLE devices…")
QtWidgets.QApplication.processEvents()
try:
devs = ble_scan(timeout=6.0)
except Exception as e:
QtWidgets.QMessageBox.critical(self, "BLE scan failed", str(e)); return
self.ble_combo.clear()
for addr, name in devs:
self.ble_combo.addItem(f"{name} [{addr}]", addr)
self.status.showMessage(f"Found {len(devs)} BLE device(s).")
def _rebuild_preset_buttons(self):
for b in self._preset_buttons:
@@ -332,6 +401,160 @@ class MainWindow(QtWidgets.QMainWindow):
"Cleared. No codes on re-read.")
self.status.showMessage("Cleared. No codes on re-read.")
# ---------- standard OBD services (dialogs) ----------
def _need_connection(self):
if not self.ctl.connected:
QtWidgets.QMessageBox.information(
self, "Not connected", "Connect (or tick Mock) first.")
return False
return True
def _vehicle_info(self):
if not self._need_connection():
return
try:
info = self.ctl.read_vehicle_info() or {}
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
rows = [("VIN", info.get("vin") or ""),
("Calibration ID", info.get("calibration") or ""),
("ECU Name", info.get("ecu_name") or "")]
text = "\n".join(f"{k}:\t{v}" for k, v in rows)
QtWidgets.QMessageBox.information(self, "Vehicle Info", text)
self.status.showMessage(f"VIN: {info.get('vin') or 'not reported'}")
def _readiness(self):
if not self._need_connection():
return
try:
r = self.ctl.read_readiness()
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
if not r:
QtWidgets.QMessageBox.information(self, "Readiness", "No readiness data returned.")
return
dlg = QtWidgets.QDialog(self); dlg.setWindowTitle("Emissions Readiness")
dlg.resize(420, 360)
lay = QtWidgets.QVBoxLayout(dlg)
not_ready = [m for m in r["monitors"] if not m["ready"]]
passed = (not r["mil"]) and r["dtc_count"] == 0 and len(not_ready) <= 1
head = QtWidgets.QLabel(
f"<b>{'LIKELY PASS' if passed else 'NOT READY'}</b> — "
f"MIL {'ON' if r['mil'] else 'off'}, {r['dtc_count']} code(s), "
f"{r['ready_count']}/{r['total']} monitors ready "
f"({r['ignition']} ignition)")
head.setStyleSheet(f"color:{'#3cb44b' if passed else '#e6a23c'};")
head.setWordWrap(True); lay.addWidget(head)
tree = QtWidgets.QTreeWidget(); tree.setHeaderLabels(["Monitor", "Status"])
tree.header().setStretchLastSection(False)
tree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
for m in r["monitors"]:
it = QtWidgets.QTreeWidgetItem([m["name"], "READY" if m["ready"] else "not ready"])
it.setForeground(1, QtGui.QBrush(QtGui.QColor("#3cb44b" if m["ready"] else "#e6a23c")))
tree.addTopLevelItem(it)
lay.addWidget(tree)
bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
bb.rejected.connect(dlg.reject); lay.addWidget(bb)
dlg.exec()
self.status.showMessage(f"Readiness: {r['ready_count']}/{r['total']} ready, "
f"MIL {'on' if r['mil'] else 'off'}")
def _freeze_frame(self):
if not self._need_connection():
return
try:
ff = self.ctl.read_freeze_frame() or {}
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
vals = ff.get("values") or []
if not vals and not ff.get("dtc"):
QtWidgets.QMessageBox.information(self, "Freeze Frame",
"No freeze-frame data stored (no fault has captured one).")
return
dlg = QtWidgets.QDialog(self); dlg.setWindowTitle("Freeze Frame")
dlg.resize(440, 380)
lay = QtWidgets.QVBoxLayout(dlg)
d = ff.get("dtc")
cap = self.ctl.dtcdb.get(d).desc if d else "(unknown)"
lay.addWidget(QtWidgets.QLabel(f"Captured by: <b>{d or ''}</b> — {cap}"))
tree = QtWidgets.QTreeWidget(); tree.setHeaderLabels(["Signal", "Value"])
tree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
for name, val, unit in vals:
tree.addTopLevelItem(QtWidgets.QTreeWidgetItem([name, f"{val} {unit}".strip()]))
lay.addWidget(tree)
bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
bb.rejected.connect(dlg.reject); lay.addWidget(bb)
dlg.exec()
_RISK_COLOR = {"safe": "#3cb44b", "caution": "#e6a23c", "danger": "#e6194B"}
def _service_actions(self):
if not self._need_connection():
return
acts = self.ctl.actions()
dlg = QtWidgets.QDialog(self); dlg.setWindowTitle("Service & Bi-directional Functions")
dlg.resize(560, 420)
lay = QtWidgets.QVBoxLayout(dlg)
if not acts:
lay.addWidget(QtWidgets.QLabel(
"No service functions are defined for this vehicle profile yet.\n\n"
"These are manufacturer-specific — add them to the profile's \"actions\"\n"
"block (see profiles/PROFILE_SPEC.md). OBDash never synthesizes command\n"
"bytes; it only runs what a verified profile defines."))
else:
lay.addWidget(QtWidgets.QLabel(
"<b>Caution:</b> these send commands to the vehicle. Read each warning."))
scroll = QtWidgets.QScrollArea(); scroll.setWidgetResizable(True)
inner = QtWidgets.QWidget(); il = QtWidgets.QVBoxLayout(inner)
from obdcore.actions import effective_risk
for a in acts:
row = QtWidgets.QFrame()
row.setStyleSheet("QFrame{border:1px solid #333;border-radius:6px;}")
rl = QtWidgets.QHBoxLayout(row)
er = effective_risk(a)
txt = QtWidgets.QLabel(
f"<b>{a.name}</b> "
f"<span style='color:{self._RISK_COLOR.get(er,'#999')}'>[{er}]</span>"
f"<br><span style='color:#999'>{a.description}</span>")
txt.setWordWrap(True)
rl.addWidget(txt, 1)
btn = QtWidgets.QPushButton("Run")
btn.clicked.connect(lambda _=False, act=a: self._run_action(act))
rl.addWidget(btn)
il.addWidget(row)
il.addStretch(1)
scroll.setWidget(inner); lay.addWidget(scroll)
bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
bb.rejected.connect(dlg.reject); lay.addWidget(bb)
dlg.exec()
def _run_action(self, action):
from obdcore.actions import effective_risk
risk = effective_risk(action) # derived from the actual UDS SIDs
if risk != "safe":
note = ("" if risk == action.risk else
f"\n\n(The profile labels this \"{action.risk}\", but its commands are "
f"{risk}-level — confirming anyway.)")
msg = (action.warning or "This sends a command to the vehicle.") + note + \
"\n\nProceed?"
btn = QtWidgets.QMessageBox.warning(
self, f"Run [{risk}]: {action.name}", msg,
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
QtWidgets.QMessageBox.No)
if btn != QtWidgets.QMessageBox.Yes:
return
try:
res = self.ctl.run_action(action) or {}
except Exception as e:
QtWidgets.QMessageBox.critical(self, action.name, str(e)); return
if res.get("ok"):
QtWidgets.QMessageBox.information(self, action.name, res.get("message", "Done."))
self.status.showMessage(f"{action.name}: {res.get('message','done')}")
else:
QtWidgets.QMessageBox.warning(self, action.name,
"Failed: " + res.get("message", "no response"))
self.status.showMessage(f"{action.name} failed: {res.get('message','')}")
# ---------- center (graph + table stack) ----------
def _build_center(self):
self.stack = QtWidgets.QStackedWidget()
@@ -366,9 +589,47 @@ class MainWindow(QtWidgets.QMainWindow):
self.gauges = GaugeGrid()
self.stack.addWidget(self.gauges)
# trip / performance page (center index 3)
self.stack.addWidget(self._build_trip_page())
self.setCentralWidget(self.stack)
self._apply_theme()
def _build_trip_page(self):
page = QtWidgets.QWidget()
page.setStyleSheet("background:#111; color:#ddd;")
lay = QtWidgets.QVBoxLayout(page)
lay.setContentsMargins(24, 24, 24, 24)
self._trip_labels = {}
def big(title):
box = QtWidgets.QFrame()
box.setStyleSheet("QFrame{background:#1a1a1a;border-radius:8px;}")
v = QtWidgets.QVBoxLayout(box)
t = QtWidgets.QLabel(title); t.setStyleSheet("color:#999;font-size:11px;")
val = QtWidgets.QLabel("--"); val.setStyleSheet("color:#fff;font-size:26px;font-weight:bold;")
v.addWidget(t); v.addWidget(val)
return box, val
grid = QtWidgets.QGridLayout()
cards = [("Instant MPG", "inst_mpg"), ("Average MPG", "avg_mpg"),
("Trip Distance (mi)", "dist"), ("Fuel Used (gal)", "fuel"),
("0-60 mph (s)", "zero60"), ("1/4 mile (s)", "quarter"),
("Speed (mph)", "speed"), ("Trip Time", "time")]
for i, (title, key) in enumerate(cards):
box, val = big(title)
self._trip_labels[key] = val
grid.addWidget(box, i // 4, i % 4)
lay.addLayout(grid)
self._trip_note = QtWidgets.QLabel(
"MPG needs a MAF sensor (speed-density/diesel vehicles report distance + "
"0-60 only). Best 0-60 / 1/4-mile are kept; pull away from a stop to time a run.")
self._trip_note.setWordWrap(True)
self._trip_note.setStyleSheet("color:#888;font-size:11px;")
lay.addWidget(self._trip_note)
lay.addStretch(1)
return page
def _graph(self):
"""The active graph widget (multi-axis unless Normalize is on)."""
return self.single if self.norm_chk.isChecked() else self.multi
@@ -461,16 +722,28 @@ class MainWindow(QtWidgets.QMainWindow):
if not ports:
self.port_combo.addItem("(no ports found)", None)
def _toggle_connect(self):
if self.ctl.connected:
self._disconnect(); return
port = self.port_combo.currentData()
def _conn_spec(self):
k = self.conn_kind.currentIndex()
if k == 1:
try:
p = int(self.tcpport_edit.text())
except ValueError:
p = 35000
return {"kind": "wifi", "host": self.host_edit.text().strip(), "port": p}
if k == 2:
addr = self.ble_combo.currentData() or self.ble_combo.currentText().strip()
return {"kind": "ble", "address": addr}
try:
baud = int(self.baud_edit.text())
except ValueError:
baud = 38400
return {"kind": "serial", "port": self.port_combo.currentData(), "baud": baud}
def _toggle_connect(self):
if self.ctl.connected:
self._disconnect(); return
try:
ok = self.ctl.connect(port=port, baud=baud, mock=self.mock_chk.isChecked())
ok = self.ctl.connect(mock=self.mock_chk.isChecked(), conn=self._conn_spec())
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Connect failed", str(e)); return
self.ctl.start(); self.timer.start()
@@ -502,6 +775,17 @@ class MainWindow(QtWidgets.QMainWindow):
b.setEnabled(False)
self.status.showMessage("Disconnected.")
def _on_link_lost(self, exc):
"""The polling thread died (transport failure). Tear down and tell the
user instead of leaving a frozen 'Connected' dashboard."""
self.ctl.poll_error = None # one-shot
self._disconnect()
self.status.showMessage(f"Connection lost: {exc}")
QtWidgets.QMessageBox.warning(
self, "Connection lost",
f"The adapter connection failed and polling stopped:\n\n{exc}\n\n"
"Check the adapter/cable and reconnect.")
# ---------- PID selection ----------
def _apply_preset(self, name):
if not self.ctl.connected:
@@ -606,6 +890,7 @@ class MainWindow(QtWidgets.QMainWindow):
self.view_graph.setChecked(idx == 0)
self.view_table.setChecked(idx == 1)
self.view_gauge.setChecked(idx == 2)
self.view_trip.setChecked(idx == 3)
def _toggle_pid_dock(self):
self.pid_dock.setVisible(self.show_pids.isChecked())
@@ -727,6 +1012,8 @@ class MainWindow(QtWidgets.QMainWindow):
def _tick(self):
if not self.ctl.connected:
if getattr(self.ctl, "poll_error", None) is not None:
self._on_link_lost(self.ctl.poll_error)
return
self.tree.blockSignals(True)
for key, it in self._items.items():
@@ -743,15 +1030,33 @@ class MainWindow(QtWidgets.QMainWindow):
self.table.item(r, 3).setText("--" if dlo is None else f"{dlo:g}")
self.table.item(r, 4).setText("--" if dhi is None else f"{dhi:g}")
self.tree.blockSignals(False)
if self.stack.currentIndex() == 2: # gauge view
spd, maf = self.ctl.update_trip() # accumulate trip/perf every tick
idx = self.stack.currentIndex()
if idx == 2: # gauge view
for key in self.curves:
p = self.ctl.reg.get(key)
lo, hi = self.ctl.store.minmax(key)
self.gauges.set_value(key, self._dval(p, self.ctl.store.latest(key)),
peak=self._dval(p, hi))
elif idx == 3: # trip / performance view
self._update_trip_page(spd, maf)
else:
self._redraw_curves()
def _update_trip_page(self, spd, maf):
t, s = self.ctl.trip, self.ctl.trip.stats()
L = self._trip_labels
L["inst_mpg"].setText(f"{t.instant_mpg(spd, maf):.1f}" if (spd and maf) else "--")
L["avg_mpg"].setText(f"{s['avg_mpg']:.1f}" if self.ctl.maf_key else "n/a (no MAF)")
L["dist"].setText(f"{s['distance_mi']:.2f}")
L["fuel"].setText(f"{s['fuel_gal']:.3f}" if self.ctl.maf_key else "n/a")
L["speed"].setText(f"{spd / 1.60934:.0f}" if spd is not None else "--")
mm, ss = divmod(int(s["elapsed_s"]), 60)
L["time"].setText(f"{mm}:{ss:02d}")
pm = self.ctl.perf
L["zero60"].setText(f"{pm.best_0_60}" if pm.best_0_60 else "--")
L["quarter"].setText(f"{pm.best_quarter}" if pm.best_quarter else "--")
def closeEvent(self, ev):
try:
self.timer.stop()
+188
View File
@@ -0,0 +1,188 @@
"""Bi-directional / service functions -- profile-defined command sequences.
FORScan-class functions (actuator tests, service resets, module writes) are
manufacturer-specific UDS (ISO 14229) sequences, so OBDash models them as DATA
in the vehicle profile (an `actions` block) rather than hardcoded logic. An
Action is a small sequence the runner executes through the ELM link:
optional Mode 10 diagnostic session
optional Mode 27 security access (seed -> key)
one+ raw command(s) (Mode 2F I/O control, Mode 31 routine, Mode 11
ECU reset, Mode 3E tester-present, ...) with response checks
SAFETY:
- Effective risk is DERIVED from the actual UDS service IDs the action sends
(effective_risk): any write/actuator/reset/transfer SID forces 'danger', so a
profile can only RAISE the confirmation, never mislabel a reflash as "safe".
- The runner sends ONLY the hex bytes defined in the profile -- nothing is
synthesized.
- Security-access KEY algorithms are per-vehicle secrets and are deliberately
NOT bundled; only a few trivial/standard transforms are known. An action that
needs an unknown algorithm is BLOCKED (fails safe) rather than guessed.
"""
from dataclasses import dataclass, field
@dataclass
class ActionStep:
send: str # hex bytes to send, e.g. "1101"
expect: str = "" # hex the response must contain; "" = accept UDS positive response
@dataclass
class Action:
key: str
name: str
kind: str = "test" # test | actuator | reset | write
risk: str = "safe" # safe | caution | danger
description: str = ""
warning: str = "" # shown in the confirmation for caution/danger
session: str = None # Mode 10 subfunction hex (e.g. "03" extended)
security: dict = None # {"level": "01", "algorithm": "<name>"}
steps: list = field(default_factory=list) # list[ActionStep]
success_msg: str = "Done."
# Known, NON-secret security transforms. Real per-vehicle seed->key algorithms
# are proprietary and intentionally absent -- unknown algorithm => action blocked.
SECURITY_ALGOS = {
"xor-ff": lambda seed: bytes((b ^ 0xFF) & 0xFF for b in seed),
"invert": lambda seed: bytes((~b) & 0xFF for b in seed),
"add-ff": lambda seed: bytes((b + 0xFF) & 0xFF for b in seed),
}
def _hex(byte_list):
return "".join(f"{b:02X}" for b in byte_list)
def _find(data, sub):
"""True if `sub` (list/bytes) appears as a CONTIGUOUS run in `data`."""
if not sub:
return False
return bytes(data).find(bytes(sub)) != -1
def _neg(data, sid):
"""UDS negative response for this service = contiguous 7F <sid>."""
return _find(data, [0x7F, sid])
def _pending(data, sid):
"""Negative response code 0x78 = requestCorrectlyReceived-ResponsePending."""
return _find(data, [0x7F, sid, 0x78])
def _ok(data, sid, expect):
"""A step succeeds only if there's NO negative response AND either the
expected bytes or the UDS positive-response id (sid+0x40) appears."""
if _neg(data, sid):
return False
if expect:
return _find(data, bytes.fromhex(expect))
return _find(data, [(sid + 0x40) & 0xFF])
# UDS/OBD service-id classification -- used to derive an action's real risk from
# what it actually SENDS, so a profile can't mislabel a write as "safe".
READ_ONLY_SIDS = {0x01, 0x02, 0x03, 0x07, 0x09, 0x0A, 0x19, 0x22, 0x2A, 0x3E}
WRITE_SIDS = {0x04, 0x11, 0x14, 0x28, 0x2E, 0x2F, 0x31, 0x34, 0x35, 0x36, 0x37,
0x38, 0x3B, 0x3D, 0x85}
_RISK_ORDER = {"safe": 0, "caution": 1, "danger": 2}
def effective_risk(action):
"""Risk = max(profile-declared, risk derived from the actual service IDs).
Write/actuator/reset/transfer SIDs force 'danger'; unknown SIDs, a non-default
session, or a security block force at least 'caution'. The profile can only
RAISE the risk, never lower it below what the commands warrant."""
derived = "safe"
def bump(r):
nonlocal derived
if _RISK_ORDER[r] > _RISK_ORDER[derived]:
derived = r
if action.session and action.session.lower() not in ("01", ""):
bump("caution")
if action.security:
bump("caution")
for st in action.steps:
try:
sid = int(st.send[:2], 16)
except ValueError:
bump("danger"); continue
if sid in WRITE_SIDS:
bump("danger")
elif sid not in READ_ONLY_SIDS:
bump("caution")
declared = action.risk if action.risk in _RISK_ORDER else "danger"
return max((derived, declared), key=lambda r: _RISK_ORDER[r])
def validate_action(a):
"""Raise ValueError if an action has malformed hex / structure."""
for st in a.steps:
bytes.fromhex(st.send) # raises if not valid hex
if st.expect:
bytes.fromhex(st.expect)
if a.session is not None:
bytes.fromhex(a.session)
if a.security is not None and "algorithm" not in a.security:
raise ValueError(f"action {a.key}: security block needs an 'algorithm'")
def run_action(action, link, log=None):
"""Execute an Action through `link` (must expose read_raw(hex)->list[int]).
Returns {"ok": bool, "message": str, "responses": [list[int], ...]}."""
def note(m):
if log:
log(m)
def send(hexstr):
return link.read_raw(hexstr)
# 1. diagnostic session
if action.session:
d = send("10" + action.session)
if not _ok(d, 0x10, "50"):
return {"ok": False, "message": "could not enter diagnostic session",
"responses": [d]}
# 2. security access (seed -> key)
if action.security:
algo = action.security.get("algorithm")
fn = SECURITY_ALGOS.get(algo)
if fn is None:
return {"ok": False, "message": f"security algorithm '{algo}' not available "
"(per-vehicle secret) — action blocked for safety", "responses": []}
level = action.security.get("level", "01")
seed_resp = send("27" + level)
if not _ok(seed_resp, 0x27, ""):
return {"ok": False, "message": "security seed request failed", "responses": [seed_resp]}
i = bytes(seed_resp).find(bytes([0x67]))
seed = seed_resp[i + 2:]
key = fn(bytes(seed))
lvl2 = f"{int(level, 16) + 1:02X}"
kr = send("27" + lvl2 + _hex(key))
if not _ok(kr, 0x27, ""):
return {"ok": False, "message": "security unlock rejected", "responses": [kr]}
note("security unlocked")
# 3. the command steps
responses = []
for st in action.steps:
d = send(st.send)
responses.append(d)
req_sid = int(st.send[:2], 16)
if _pending(d, req_sid):
return {"ok": True, "responses": responses,
"message": "ECU accepted the request; the operation is still in "
"progress (response pending). Check the vehicle — do NOT retry."}
if not _ok(d, req_sid, st.expect):
reason = "ECU negative response" if _neg(d, req_sid) else "no valid response"
return {"ok": False, "message": f"{reason} to {st.send} (got {_hex(d) or 'nothing'})",
"responses": responses}
note(f"{st.send} -> {_hex(d)}")
return {"ok": True, "message": action.success_msg, "responses": responses}
+53 -13
View File
@@ -1,8 +1,9 @@
"""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:
execute arbitrary code -- OR exhaust CPU/memory. 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"
@@ -10,9 +11,11 @@ variables -- the de-facto OBD convention used by Torque / FORScan / ScanGauge:
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.
small whitelist of functions are allowed. Anything else raises FormulaError at
compile time. To stop a hostile profile from freezing the acquisition thread
with a giant-integer expression (e.g. `9**9**9`, `1<<10**9`), evaluation also
BOUNDS magnitude: shift/exponent amounts and integer result bit-lengths are
capped, and expression length + nesting depth are limited at compile.
"""
import ast
import operator
@@ -28,24 +31,33 @@ _UNARY = {ast.USub: operator.neg, ast.UAdd: operator.pos, ast.Invert: operator.i
_FUNCS = {"min": min, "max": max, "abs": abs, "round": round,
"int": int, "float": float}
# magnitude / complexity limits (far above any real OBD byte arithmetic)
MAX_RESULT_BITS = 512 # ~155 decimal digits; real decode stays < 32 bits
MAX_SHIFT = 256 # bit-field decode never shifts more than a few bytes
MAX_POW_EXP = 64
MAX_EXPR_LEN = 500
MAX_DEPTH = 60
class FormulaError(ValueError):
pass
def _validate(node, allowed):
def _validate(node, allowed, depth=0):
if depth > MAX_DEPTH:
raise FormulaError("formula too deeply nested")
if isinstance(node, ast.Expression):
return _validate(node.body, allowed)
return _validate(node.body, allowed, depth + 1)
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)
_validate(node.left, allowed, depth + 1)
_validate(node.right, allowed, depth + 1)
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)
_validate(node.operand, allowed, depth + 1)
return
if isinstance(node, ast.Constant):
if not isinstance(node.value, (int, float)) or isinstance(node.value, bool):
@@ -61,16 +73,42 @@ def _validate(node, allowed):
if node.keywords:
raise FormulaError("keyword args not allowed")
for a in node.args:
_validate(a, allowed)
_validate(a, allowed, depth + 1)
return
raise FormulaError(f"expression not allowed: {type(node).__name__}")
def _apply(op_type, left, right):
"""Apply a binary op with magnitude guards so an untrusted formula can't
allocate a giant integer (Pow / shift amplification)."""
if op_type in (ast.LShift, ast.RShift):
try:
r = operator.index(right)
except TypeError:
raise FormulaError("shift amount must be an integer")
if not 0 <= r <= MAX_SHIFT:
raise FormulaError("shift amount out of range")
if op_type is ast.LShift and isinstance(left, int) and \
left.bit_length() + r > MAX_RESULT_BITS:
raise FormulaError("shift result too large")
elif op_type is ast.Pow:
if isinstance(right, int):
if right > MAX_POW_EXP:
raise FormulaError("exponent too large")
if isinstance(left, int) and right > 0 and \
left.bit_length() * right > MAX_RESULT_BITS:
raise FormulaError("power result too large")
res = _BIN[op_type](left, right)
if isinstance(res, int) and res.bit_length() > MAX_RESULT_BITS:
raise FormulaError("result magnitude too large")
return res
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))
return _apply(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):
@@ -84,9 +122,11 @@ def _eval(node, names):
def compile_formula(expr, allowed_names):
"""Return fn(names_dict) -> number. Raises FormulaError on disallowed input."""
if len(expr) > MAX_EXPR_LEN:
raise FormulaError("formula too long")
try:
tree = ast.parse(expr, mode="eval")
except SyntaxError as e:
except (SyntaxError, ValueError, RecursionError) as e:
raise FormulaError(f"bad formula {expr!r}: {e}")
allowed = set(allowed_names)
_validate(tree, allowed)
+89 -18
View File
@@ -23,11 +23,14 @@ def decode_dtc(b1, b2):
return f"{_LETTER[(b1 >> 6) & 3]}{(b1 >> 4) & 3}{b1 & 0xF:X}{b2:02X}"
_HEX = "0123456789ABCDEFabcdef"
def _line_bytes(ln):
ln = ln.replace(" ", "")
if len(ln) >= 2 and ln[1] == ":" and ln[0] in "0123456789":
ln = ln[2:] # drop CAN multiframe index "N:"
if not ln or any(c not in "0123456789ABCDEFabcdef" for c in ln):
if len(ln) >= 2 and ln[1] == ":" and ln[0] in _HEX:
ln = ln[2:] # drop CAN multiframe index "N:" (0-F, cycles)
if not ln or any(c not in _HEX for c in ln):
return []
return [int(ln[i:i + 2], 16) for i in range(0, len(ln) - 1, 2)]
@@ -35,11 +38,24 @@ def _line_bytes(ln):
def parse_dtcs(lines, svc, is_can):
pairs = []
if is_can:
data = [b for ln in lines for b in _line_bytes(ln)]
if svc in data:
data = data[data.index(svc) + 1:]
data = data[1:] if data else data
pairs = data
# Message-aware: multiple ECUs each reply "<svc> <count> <pairs...>", and
# a DTC's own first byte can equal svc (0x43 == C03xx), so we must NOT
# blind-scan the flattened stream for svc. A line whose payload starts
# with svc begins a new ECU message (drop svc + count byte); an ISO-TP
# numbered continuation "N:" (N>=1) appends raw pairs to the current one.
started = False
for ln in lines:
raw = ln.replace(" ", "")
cont = len(raw) >= 2 and raw[1] == ":" and raw[0] in _HEX and raw[0] != "0"
b = _line_bytes(ln)
if not b:
continue
if not cont and b[0] == svc: # header of an ECU message: svc + count
pairs.extend(b[2:])
started = True
elif started: # continuation / pairs of current message
pairs.extend(b)
# else: bytes before any header (ISO-TP length line, stray) -> ignore
else:
for ln in lines:
data = _line_bytes(ln)
@@ -74,25 +90,40 @@ def find_ports():
class ElmLink:
PROMPT = b">"
def __init__(self, port, baud=38400, verbose=False):
if serial is None:
raise RuntimeError("pyserial not installed (pip install pyserial)")
def __init__(self, transport, verbose=False):
"""transport: any object with write/read/reset_input_buffer/close.
Use the .serial() / .tcp() / .ble() factory helpers to build one."""
self.io = transport
self.verbose = verbose
self.ser = serial.Serial(port, baud, timeout=0.2)
self.protocol = "?"
time.sleep(0.3)
self.ser.reset_input_buffer()
self.io.reset_input_buffer()
@classmethod
def serial(cls, port, baud=38400, **kw):
from . import transport as tp
return cls(tp.SerialTransport(port, baud), **kw)
@classmethod
def tcp(cls, host, port=35000, **kw):
from . import transport as tp
return cls(tp.TcpTransport(host, port), **kw)
@classmethod
def ble(cls, address, **kw):
from . import transport as tp
return cls(tp.BleTransport(address), **kw)
# -- low-level --
def cmd(self, s, settle=0.0, timeout=4.0):
self.ser.reset_input_buffer()
self.ser.write((s + "\r").encode())
self.io.reset_input_buffer()
self.io.write((s + "\r").encode())
if settle:
time.sleep(settle)
buf = bytearray()
deadline = time.time() + timeout
while time.time() < deadline:
chunk = self.ser.read(256)
chunk = self.io.read(256)
if chunk:
buf += chunk
if self.PROMPT in buf:
@@ -160,6 +191,11 @@ class ElmLink:
except ValueError:
return None
def read_raw(self, hexcmd, timeout=2.0):
"""Send an arbitrary hex command and return the flattened response
bytes (for bi-directional actions / service routines)."""
return self._bytes(self.cmd(hexcmd, timeout=timeout))
# -- DTCs --
def read_dtcs(self, mode, svc, timeout=5.0):
lines = self.cmd(mode, timeout=timeout)
@@ -172,8 +208,43 @@ class ElmLink:
data = self._bytes(lines)
return 0x44 in data or ("OK" in "".join(lines).upper())
def close(self):
# -- standard OBD services (Mode 09 / 01-01 / 02) --
def read_vehicle_info(self, timeout=2.0):
"""Mode 09: VIN + calibration IDs + ECU name. Returns a dict."""
from . import obdservices as svc
vin = svc.decode_vin(self._bytes(self.cmd("0902", timeout=timeout)))
cal = svc.decode_ascii_block(self._bytes(self.cmd("0904", timeout=timeout)), 0x04)
ecu = svc.decode_ascii_block(self._bytes(self.cmd("090A", timeout=timeout)), 0x0A)
return {"vin": vin, "calibration": cal, "ecu_name": ecu}
def read_readiness(self, timeout=1.0):
"""Mode 01 PID 01: MIL, DTC count, and I-M readiness monitors."""
from . import obdservices as svc
data = self.read_m01("01", 4, timeout=timeout)
return svc.decode_readiness(data) if data else None
def read_freeze_frame(self, timeout=0.6):
"""Mode 02: the DTC that set the freeze frame + the standard PID snapshot."""
from . import obdservices as svc
out = {"dtc": None, "values": []}
d = self._bytes(self.cmd("020200", timeout=timeout)) # svc 02, PID 02, frame 00
if 0x42 in d:
r = d[d.index(0x42) + 3:] # skip 42 (SID) 02 (PID) 00 (frame)
if len(r) >= 2 and (r[0] or r[1]):
out["dtc"] = decode_dtc(r[0], r[1])
for name, pid, nbytes, dec, unit in svc.FREEZE_PIDS:
dd = self._bytes(self.cmd(f"02{pid}00", timeout=timeout))
if 0x42 in dd:
payload = dd[dd.index(0x42) + 3:dd.index(0x42) + 3 + nbytes]
if len(payload) == nbytes:
try:
self.ser.close()
out["values"].append((name, dec(payload), unit))
except Exception:
pass
return out
def close(self):
try:
self.io.close()
except Exception:
pass
+32 -2
View File
@@ -42,10 +42,18 @@ class MockLink:
return None # everything else: no response
def read_m01(self, pid, nbytes, timeout=0.6):
if pid == "0C": # RPM 0 at rest
return [0x00, 0x00]
if pid == "0C": # RPM ~750 idle
v = 750 * 4
return [(v >> 8) & 0xFF, v & 0xFF]
if pid == "05": # ECT 82C
return [122]
if pid == "0D": # speed 48 km/h
return [48]
if pid == "10": # MAF 12.0 g/s
v = 1200
return [(v >> 8) & 0xFF, v & 0xFF]
if pid == "01": # readiness: MIL off, 0 DTCs, mixed monitors
return [0x00, 0x07, 0x61, 0x20]
return None
def read_atrv(self, timeout=0.8):
@@ -58,5 +66,27 @@ class MockLink:
def clear_dtcs(self):
return True
def read_vehicle_info(self, timeout=2.0):
return {"vin": "1FMZU73E12ZA12345", "calibration": "JR3A-12A650-BCD",
"ecu_name": "ECM-EngineControl"}
def read_readiness(self, timeout=1.0):
from . import obdservices as svc
return svc.decode_readiness([0x00, 0x07, 0x61, 0x20])
def read_freeze_frame(self, timeout=0.6):
return {"dtc": "P0148",
"values": [("Engine RPM", 240, "rpm"), ("Coolant Temp", 33, "C"),
("Engine Load", 18, "%"), ("Vehicle Speed", 0, "km/h")]}
def read_raw(self, hexcmd, timeout=2.0):
# Return a UDS positive response (sid+0x40) so actions succeed in mock.
h = hexcmd.replace(" ", "")
sid = int(h[:2], 16)
rest = [int(h[i:i + 2], 16) for i in range(2, len(h) - 1, 2)]
if sid == 0x27 and len(rest) == 1: # security seed request -> return a seed
return [0x67, rest[0], 0x11, 0x22, 0x33, 0x44]
return [(sid + 0x40) & 0xFF] + rest
def close(self):
pass
+94
View File
@@ -0,0 +1,94 @@
"""Standard OBD-II service decoders (vehicle-agnostic, no serial).
Covers the generic SAE J1979 services OBDash exposes beyond live PIDs:
- Mode 09 vehicle info (VIN, calibration IDs, ECU name)
- Mode 01 PID 01 readiness / I-M monitors (+ MIL, DTC count)
- Mode 02 freeze-frame (the standard PID set captured when a DTC set)
These are pure functions over raw response byte lists so they can be unit
tested without hardware; ElmLink wraps them with the actual reads.
"""
_VIN_CHARS = set("ABCDEFGHJKLMNPRSTUVWXYZ0123456789") # no I, O, Q
def decode_vin(data):
"""data: flattened response bytes for '0902' (49 02 <NODI> <17 ASCII>).
Returns the 17-char VIN or None."""
if 0x49 not in data:
return None
i = data.index(0x49)
rest = data[i + 1:]
# drop the service-PID (02) and number-of-data-items byte if present
if rest and rest[0] == 0x02:
rest = rest[2:]
chars = "".join(chr(b) for b in rest if chr(b).upper() in _VIN_CHARS)
return chars[-17:] if len(chars) >= 17 else (chars or None)
def decode_ascii_block(data, svc_pid):
"""Generic mode-09 ASCII payload (calibration IDs / ECU name) for svc_pid
(e.g. 0x04, 0x0A). Returns a printable string or None."""
if 0x49 not in data:
return None
i = data.index(0x49)
rest = data[i + 1:]
if rest and rest[0] == svc_pid:
rest = rest[2:]
s = "".join(chr(b) for b in rest if 0x20 <= b < 0x7F).strip()
return s or None
# Readiness monitor names by ignition type (SAE J1979 PID 01, bytes C/D bit map)
_SPARK = ["Catalyst", "Heated Catalyst", "Evap System", "Secondary Air System",
"A/C Refrigerant", "O2 Sensor", "O2 Sensor Heater", "EGR System"]
_COMPRESSION = ["NMHC Catalyst", "NOx/SCR Aftertreatment", "-", "Boost Pressure",
"-", "Exhaust Gas Sensor", "PM Filter", "EGR/VVT System"]
_CONTINUOUS = [("Misfire", 0), ("Fuel System", 1), ("Components", 2)]
def decode_readiness(data):
"""data: 4 bytes [A,B,C,D] from Mode 01 PID 01. Returns a dict:
{mil, dtc_count, ignition, monitors:[{name, ready}], ready_count, total}."""
if len(data) < 4:
return None
a, b, c, d = data[0], data[1], data[2], data[3]
compression = bool(b & 0x08)
monitors = []
# continuous monitors: B bits 0-2 supported, bits 4-6 = incomplete
for name, i in _CONTINUOUS:
if b & (1 << i):
monitors.append({"name": name, "ready": not (b & (1 << (i + 4)))})
# non-continuous: C bits = supported, D bits = incomplete
names = _COMPRESSION if compression else _SPARK
for i, name in enumerate(names):
if name == "-":
continue
if c & (1 << i):
monitors.append({"name": name, "ready": not (d & (1 << i))})
ready = sum(1 for m in monitors if m["ready"])
return {
"mil": bool(a & 0x80),
"dtc_count": a & 0x7F,
"ignition": "compression" if compression else "spark",
"monitors": monitors,
"ready_count": ready,
"total": len(monitors),
}
# Standard PID set read from the freeze frame (Mode 02, frame 0).
# (key, pid hex, nbytes, decoder(bytes)->value, unit)
FREEZE_PIDS = [
("Fuel System Status", "03", 2, lambda b: b[0], ""),
("Engine Load", "04", 1, lambda b: round(b[0] * 100 / 255), "%"),
("Coolant Temp", "05", 1, lambda b: b[0] - 40, "C"),
("Short Term Fuel Trim", "06", 1, lambda b: round(b[0] * 100 / 128 - 100), "%"),
("Long Term Fuel Trim", "07", 1, lambda b: round(b[0] * 100 / 128 - 100), "%"),
("Intake MAP", "0B", 1, lambda b: b[0], "kPa"),
("Engine RPM", "0C", 2, lambda b: round((b[0] * 256 + b[1]) / 4), "rpm"),
("Vehicle Speed", "0D", 1, lambda b: b[0], "km/h"),
("Intake Air Temp", "0F", 1, lambda b: b[0] - 40, "C"),
("MAF", "10", 2, lambda b: round((b[0] * 256 + b[1]) / 100, 1), "g/s"),
("Throttle Position", "11", 1, lambda b: round(b[0] * 100 / 255), "%"),
]
+27 -1
View File
@@ -24,6 +24,7 @@ from dataclasses import dataclass, field
from .formula import compile_formula
from .registry import Pid, Dtc
from .actions import Action, ActionStep, validate_action
SCHEMA = 1
BYTE_VARS = [chr(65 + i) for i in range(8)] # A..H
@@ -36,6 +37,7 @@ class Profile:
dtcs: list
presets: dict
path: str = None
actions: list = None
@property
def name(self):
@@ -100,8 +102,20 @@ def load_profile(path):
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", [])]
actions = []
for a in raw.get("actions", []):
act = Action(
key=a["key"], name=a.get("name", a["key"]), kind=a.get("kind", "test"),
risk=a.get("risk", "safe"), description=a.get("description", ""),
warning=a.get("warning", ""), session=a.get("session"),
security=a.get("security"),
steps=[ActionStep(send=s["send"], expect=s.get("expect", ""))
for s in a.get("steps", [])],
success_msg=a.get("success_msg", "Done."))
validate_action(act) # rejects malformed hex
actions.append(act)
return Profile(meta=raw.get("meta", {}), pids=pids, dtcs=dtcs,
presets=raw.get("presets", {}), path=path)
presets=raw.get("presets", {}), path=path, actions=actions)
def _pid_to_dict(p):
@@ -136,6 +150,18 @@ def save_profile(profile, path=None):
"dtcs": [{"code": d.code, "desc": d.desc, "system": d.system,
"no_start": d.no_start, "causes": d.causes} for d in profile.dtcs],
}
if profile.actions:
out["actions"] = []
for a in profile.actions:
ad = {"key": a.key, "name": a.name, "kind": a.kind, "risk": a.risk}
if a.description: ad["description"] = a.description
if a.warning: ad["warning"] = a.warning
if a.session: ad["session"] = a.session
if a.security: ad["security"] = a.security
ad["steps"] = [{"send": s.send, **({"expect": s.expect} if s.expect else {})}
for s in a.steps]
if a.success_msg != "Done.": ad["success_msg"] = a.success_msg
out["actions"].append(ad)
with open(path, "w") as f:
json.dump(out, f, indent=2)
return path
+25 -2
View File
@@ -81,12 +81,35 @@ class PidRegistry:
return list(self.presets.keys())
_GENERIC = None
def _generic_dtcs():
"""Lazy-load the bundled generic SAE DTC database (code -> Dtc)."""
global _GENERIC
if _GENERIC is None:
_GENERIC = {}
try:
import json
import os
from .profile import profiles_dir
path = os.path.join(profiles_dir(), "_data", "generic-dtcs.json")
for d in json.load(open(path)).get("dtcs", []):
_GENERIC[d["code"]] = Dtc(code=d["code"], desc=d.get("desc", ""),
system=d.get("system", "powertrain"),
no_start=d.get("no_start", False))
except Exception:
pass
return _GENERIC
class DtcDatabase:
def __init__(self, profile):
self._db = {d.code: d for d in profile.dtcs}
self._db = {d.code: d for d in profile.dtcs} # profile codes take priority
def get(self, code):
return self._db.get(code) or Dtc(code, "(unknown - look up this code)")
return (self._db.get(code) or _generic_dtcs().get(code)
or Dtc(code, "(unknown - look up this code)"))
def all(self):
return list(self._db.values())
+55 -8
View File
@@ -16,14 +16,19 @@ import time
class _OneOff:
"""A single command to run once on the polling thread (DTC read/clear,
probe, etc). The submitter blocks on `done` until the thread has run it."""
__slots__ = ("fn", "done", "result", "error")
probe, etc). The submitter blocks on `done` until the thread has run it.
`cancelled`/`started` (guarded by `lock`) let a timed-out submitter cancel a
still-queued job so it never fires late on the vehicle."""
__slots__ = ("fn", "done", "result", "error", "cancelled", "started", "lock")
def __init__(self, fn):
self.fn = fn
self.done = threading.Event()
self.result = None
self.error = None
self.cancelled = False
self.started = False
self.lock = threading.Lock()
class _Sub:
@@ -39,13 +44,14 @@ class _Sub:
class PollScheduler:
def __init__(self, link, registry, store, clock=time.time, dead_after=4,
revive_every=5.0):
revive_every=5.0, on_error=None):
self.link = link
self.reg = registry
self.store = store
self.clock = clock
self.dead_after = dead_after
self.revive_every = revive_every
self.on_error = on_error # called(exc) if the poll thread dies
self._subs = {}
self._lock = threading.Lock()
self._thread = None
@@ -102,6 +108,10 @@ class PollScheduler:
job = self._oneoffs.get_nowait()
except queue.Empty:
return
with job.lock: # a timed-out submitter may have cancelled
if job.cancelled:
continue
job.started = True
try:
job.result = job.fn()
except Exception as e: # hand the failure back
@@ -109,17 +119,40 @@ class PollScheduler:
finally:
job.done.set()
def _fail_pending_oneoffs(self, exc):
"""Fail every still-queued (not yet started) one-off with `exc` so a
blocked submitter is freed immediately instead of hanging the full
timeout -- used when the poll thread dies or stops."""
err = exc if isinstance(exc, BaseException) else RuntimeError(str(exc))
while True:
try:
job = self._oneoffs.get_nowait()
except queue.Empty:
return
with job.lock:
if job.started or job.cancelled:
continue
job.cancelled = True
job.error = err
job.done.set()
def run_oneoff(self, fn, timeout=8.0):
"""Enqueue `fn` to run once on the polling thread and block for its
result (or re-raise its exception). When the scheduler thread isn't
running, the job is drained inline on the caller -- still serialized
against tick(), and safe because nothing else is touching the link."""
result (or re-raise its exception). When no live polling thread is
servicing the queue, the job is drained inline on the caller -- still
serialized against tick(), and safe because nothing else touches the
link. On timeout a still-queued job is CANCELLED so it can never fire
late on the vehicle."""
job = _OneOff(fn)
self._oneoffs.put(job)
if not self._running:
if not self._running or (self._thread is not None and not self._thread.is_alive()):
self._drain_oneoffs()
if not job.done.wait(timeout):
raise TimeoutError("one-off command timed out")
with job.lock:
if not job.started:
job.cancelled = True
raise TimeoutError("command timed out and was cancelled — it will not run")
raise TimeoutError("command is still running on the adapter — do NOT retry")
if job.error is not None:
raise job.error
return job.result
@@ -167,16 +200,30 @@ class PollScheduler:
self._thread.start()
def _loop(self):
try:
while self._running:
n = self.tick()
if n == 0:
time.sleep(0.005) # nothing due; yield
except Exception as e:
# a transport/link error would otherwise kill the thread silently,
# leaving the GUI showing "Connected" with frozen data and blocked
# one-off callers hung. Fail loudly instead.
self._running = False
self._fail_pending_oneoffs(e)
cb = self.on_error
if cb:
try:
cb(e)
except Exception:
pass
def stop(self):
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
self._thread = None
self._fail_pending_oneoffs(RuntimeError("scheduler stopped"))
def _is_derived(reg, key):
+6
View File
@@ -128,13 +128,19 @@ class CsvRecorder:
self._f = open(path, "w")
self._f.write("t,key,value\n")
self._lock = threading.Lock()
self._closed = False
def write(self, key, t, v):
with self._lock:
if self._closed: # a poll-thread write racing close() is a no-op
return
self._f.write(f"{t:.3f},{key},{'' if v is None else v}\n")
def close(self):
with self._lock:
if self._closed:
return
self._closed = True
self._f.close()
+205
View File
@@ -0,0 +1,205 @@
"""Byte transports for ElmLink: serial (USB / classic-Bluetooth SPP), TCP
(WiFi ELM327), and BLE (Bluetooth Low Energy ELM327).
Each transport presents the same tiny synchronous interface so ElmLink's
command loop doesn't care how the bytes move:
write(data: bytes) -> None
read(n: int) -> bytes (returns b"" on timeout)
reset_input_buffer() -> None
close() -> None
Classic Bluetooth ELM327 pair as a serial port (COMx / /dev/cu.* / rfcomm), so
they use SerialTransport. Only WiFi (TCP) and BLE need dedicated transports.
"""
import socket
import threading
import time
class SerialTransport:
def __init__(self, port, baud=38400):
import serial
self.ser = serial.Serial(port, baud, timeout=0.2)
def write(self, data):
self.ser.write(data)
def read(self, n):
return self.ser.read(n)
def reset_input_buffer(self):
self.ser.reset_input_buffer()
def close(self):
try:
self.ser.close()
except Exception:
pass
class TcpTransport:
"""WiFi ELM327 — a raw TCP socket (commonly 192.168.0.10:35000)."""
def __init__(self, host, port=35000, connect_timeout=5.0, read_timeout=0.2):
self._rt = read_timeout
self.sock = socket.create_connection((host, int(port)), timeout=connect_timeout)
self.sock.settimeout(read_timeout)
def write(self, data):
self.sock.sendall(data)
def read(self, n):
try:
data = self.sock.recv(n)
except socket.timeout:
return b"" # no data yet -- normal
except OSError as e:
raise IOError(f"WiFi connection lost: {e}") from e
if data == b"": # peer closed the connection
raise IOError("WiFi connection closed by adapter")
return data
def reset_input_buffer(self):
# drain pending bytes, but never spin forever if data keeps arriving
self.sock.settimeout(0.02)
try:
for _ in range(64):
if not self.sock.recv(4096):
break
except socket.timeout:
pass
except OSError:
pass
finally:
try:
self.sock.settimeout(self._rt)
except OSError:
pass
def close(self):
try:
self.sock.close()
except Exception:
pass
# Common BLE UART characteristic pairs used by cheap ELM327 dongles
# (write-char, notify-char). We try each until one has both properties.
_BLE_UART_PAIRS = [
("0000fff2-0000-1000-8000-00805f9b34fb", "0000fff1-0000-1000-8000-00805f9b34fb"),
("0000ffe1-0000-1000-8000-00805f9b34fb", "0000ffe1-0000-1000-8000-00805f9b34fb"),
("6e400002-b5a3-f393-e0a9-e50e24dcca9e", "6e400003-b5a3-f393-e0a9-e50e24dcca9e"),
]
class BleTransport:
"""Experimental BLE ELM327 transport (needs `bleak`). Runs an asyncio loop
on a background thread and buffers notifications so read()/write() stay
synchronous. Untested against every dongle -- BLE ELM327 GATT layouts vary."""
def __init__(self, address, connect_timeout=15.0):
try:
import bleak # noqa: F401
except ImportError as e:
raise RuntimeError("BLE support needs the 'bleak' package "
"(pip install bleak)") from e
self.address = address
self._buf = bytearray()
self._lock = threading.Lock()
self._loop = None
self._client = None
self._write_char = None
self._ready = threading.Event()
self._err = None
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
if not self._ready.wait(connect_timeout) or self._err:
err = self._err or "timeout"
self.close() # don't leak the client + event-loop thread
raise RuntimeError(f"BLE connect failed: {err}")
def _run(self):
import asyncio
from bleak import BleakClient
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
async def setup():
self._client = BleakClient(self.address)
await self._client.connect()
notify_char = None
for svc in self._client.services:
for ch in svc.characteristics:
props = set(ch.properties)
if {"write", "write-without-response"} & props and self._write_char is None:
self._write_char = ch.uuid
if {"notify", "indicate"} & props and notify_char is None:
notify_char = ch.uuid
# prefer a known UART pair if present
for w, n in _BLE_UART_PAIRS:
if any(c.uuid == w for s in self._client.services for c in s.characteristics):
self._write_char = w
notify_char = n
break
if not self._write_char or not notify_char:
raise RuntimeError("no writable/notify characteristic found")
def on_notify(_h, data):
with self._lock:
self._buf += bytes(data)
if len(self._buf) > 65536: # cap: never grow unbounded
del self._buf[:-65536]
await self._client.start_notify(notify_char, on_notify)
self._ready.set()
try:
self._loop.run_until_complete(setup())
self._loop.run_forever()
except Exception as e:
self._err = e
self._ready.set()
def write(self, data):
import asyncio
fut = asyncio.run_coroutine_threadsafe(
self._client.write_gatt_char(self._write_char, data, response=False), self._loop)
fut.result(timeout=3.0)
def read(self, n):
deadline = time.time() + 0.2
while time.time() < deadline:
with self._lock:
if self._buf:
out = bytes(self._buf[:n]); del self._buf[:n]
return out
time.sleep(0.005)
return b""
def reset_input_buffer(self):
with self._lock:
self._buf.clear()
def close(self):
import asyncio
loop, client = self._loop, self._client
if loop is None:
return
if client is not None:
try:
asyncio.run_coroutine_threadsafe(client.disconnect(), loop).result(timeout=3.0)
except Exception:
pass
try: # stop the loop even if disconnect failed,
loop.call_soon_threadsafe(loop.stop) # so the background thread exits
except Exception:
pass
def ble_scan(timeout=6.0):
"""Return [(address, name), ...] of nearby BLE devices (needs bleak)."""
import asyncio
from bleak import BleakScanner
devs = asyncio.run(BleakScanner.discover(timeout=timeout))
return [(d.address, d.name or "?") for d in devs]
+119
View File
@@ -0,0 +1,119 @@
"""Trip computer + performance timers (computed from live data, no serial).
TripComputer estimates fuel economy and trip totals from vehicle speed (km/h)
and MAF (g/s) -- the same MAF-based estimate Torque/etc. use. PerformanceMeter
measures 0-60 mph and 1/4-mile times, auto-detecting a launch from a near-stop.
Both are fed time-stamped samples and are unit tested without hardware.
"""
KMH_PER_MPH = 1.609344
GAS_AFR = 14.7 # stoichiometric air:fuel for gasoline
GAS_G_PER_GAL = 2835.0 # ~6.25 lb/gal
def _gph(maf_gps, afr=GAS_AFR, g_per_gal=GAS_G_PER_GAL):
return (maf_gps / afr) * 3600.0 / g_per_gal
class TripComputer:
def __init__(self, afr=GAS_AFR, g_per_gal=GAS_G_PER_GAL):
self.afr, self.g_per_gal = afr, g_per_gal
self.reset()
def reset(self):
self.dist_km = 0.0
self.fuel_gal = 0.0
self.moving_s = 0.0
self.elapsed_s = 0.0
self._t = None
def update(self, t, speed_kmh, maf_gps):
if self._t is None:
self._t = t
return
dt = t - self._t
self._t = t
if dt <= 0 or dt > 5: # ignore gaps / first sample
return
self.elapsed_s += dt
if speed_kmh:
self.dist_km += speed_kmh * dt / 3600.0
if speed_kmh > 1.0:
self.moving_s += dt
if maf_gps:
self.fuel_gal += _gph(maf_gps, self.afr, self.g_per_gal) * dt / 3600.0
def instant_mpg(self, speed_kmh, maf_gps):
if not speed_kmh or not maf_gps:
return 0.0
gph = _gph(maf_gps, self.afr, self.g_per_gal)
return (speed_kmh / KMH_PER_MPH) / gph if gph > 0 else 0.0
def avg_mpg(self):
miles = self.dist_km / KMH_PER_MPH
return miles / self.fuel_gal if self.fuel_gal > 0 else 0.0
def stats(self):
return {
"distance_mi": round(self.dist_km / KMH_PER_MPH, 2),
"fuel_gal": round(self.fuel_gal, 3),
"avg_mpg": round(self.avg_mpg(), 1),
"elapsed_s": round(self.elapsed_s, 1),
"moving_s": round(self.moving_s, 1),
}
class PerformanceMeter:
"""0-60 mph and 1/4-mile timers. A run starts when the car pulls away from a
near-stop and ends at the 1/4 mile or when it stops; best times are kept."""
QUARTER_M = 402.336
def __init__(self):
self.best_0_60 = None
self.best_quarter = None
self.last_0_60 = None
self.last_quarter = None
self._reset_run()
def _reset_run(self):
self._running = False
self._t0 = None
self._dist_m = 0.0
self._last_t = None
self._stop_t = None
self._did60 = False
def update(self, t, speed_kmh):
if speed_kmh is None:
return
mph = speed_kmh / KMH_PER_MPH
if not self._running:
if mph < 1.0:
self._stop_t = t # parked, ready to launch
elif self._stop_t is not None:
self._running = True # launch detected
self._t0 = self._stop_t
self._dist_m = 0.0
self._last_t = t
self._did60 = False
return
dt = t - self._last_t
self._last_t = t
if dt <= 0 or dt > 5:
self._reset_run()
return
self._dist_m += (speed_kmh / 3.6) * dt
if mph >= 60 and not self._did60:
self._did60 = True
self.last_0_60 = round(t - self._t0, 2)
if self.best_0_60 is None or self.last_0_60 < self.best_0_60:
self.best_0_60 = self.last_0_60
if self._dist_m >= self.QUARTER_M:
self.last_quarter = round(t - self._t0, 2)
if self.best_quarter is None or self.last_quarter < self.best_quarter:
self.best_quarter = self.last_quarter
self._reset_run()
elif mph < 1.0:
self._reset_run()
+41
View File
@@ -137,6 +137,47 @@ vehicle reports trims/O2).
flags drive-disabling faults (shown bold red). Include generic `P0xxx` plus
manufacturer-specific `P1xxx` you can source.
## 7b. `actions` — bi-directional / service functions (optional)
Manufacturer service functions (actuator tests, service resets, module writes)
are UDS (ISO 14229) sequences, so they live in the profile as **data**. OBDash
runs ONLY the hex bytes you define — it never synthesizes commands.
```jsonc
"actions": [
{
"key": "ECU_RESET",
"name": "Reset ECU (soft reboot)",
"kind": "reset", // test | actuator | reset | write
"risk": "caution", // safe | caution | danger (caution/danger prompt to confirm)
"description": "shown in the list",
"warning": "shown in the confirmation for caution/danger actions",
"session": "03", // OPTIONAL Mode 10 subfunction hex (enter extended session)
"security": {"level":"01","algorithm":"xor-ff"}, // OPTIONAL seed->key unlock
"steps": [ {"send":"1101", "expect":"51"} ], // send hex; expect = hex the reply must contain
"success_msg": "ECU reset acknowledged."
}
]
```
Execution order: `session` (Mode 10) → `security` (Mode 27 seed→key) → each
`step` in order. A step succeeds if the reply contains `expect`, or (when
`expect` is omitted) the UDS positive-response byte (`send` SID + 0x40). Any
negative response (`7F …`) aborts.
**Security access:** real per-vehicle seed→key algorithms are proprietary and are
**not** bundled. Only trivial/standard transforms are known (`xor-ff`, `invert`,
`add-ff`); an action naming any other `algorithm` is **blocked** (fails safe) —
don't put a real secret algorithm name and expect it to work. Most simple
functions need no security block.
**Safety rules for authors:**
- Only include commands with **verified** bytes (service manual / bench-confirmed).
A wrong `2F`/`31`/`2E` command can mis-actuate or misconfigure a module.
- Mark anything that writes/actuates `caution` or `danger` and write a clear
`warning` (e.g. "engine off", "wheels chocked").
- `kind:"write"` (module config / As-Built) is the highest-risk — reserve `danger`.
## 8. Rules for authors / agents
- **Standard Mode-01 PIDs are the reliable backbone** — include the ones this
+2
View File
@@ -21,6 +21,8 @@ scaling formulas), DTC meanings, and named presets. Load one in the app via
| `ford-mustang-cobra-4.6-1996.json` | 1996 Mustang SVT Cobra 4.6 DOHC 32V | EEC-V, SAE J1850 PWM, MAF, Ford P1xxx DTCs |
| `ford-mustang-gt-4.6-1996.json` | 1996 Mustang GT 4.6 SOHC 2V | EEC-V, SAE J1850 PWM, MAF, Ford P1xxx DTCs |
| `mercury-mountaineer-4.6-2006.json` | 2006 Mercury Mountaineer 4.6 V8 | EEC-V, MAF, Ford P1xxx DTCs (verify PWM-vs-CAN on the vehicle) |
| `honda-crv-2.4-2007.json` | 2007 Honda CR-V 2.4 I4 (K24Z1) | ISO 15765 CAN, MAF + wideband A/F, single-bank, Honda P1xxx DTCs |
| `honda-odyssey-3.5-2022.json` | 2022 Honda Odyssey 3.5 V6 (J35 VCM) | ISO 15765 CAN, MAF, dual-bank (4 O2/AF sensors), Honda DTCs |
| `generic-obd2.json` | Any OBD-II vehicle (1996+) | Standard SAE Mode-01 PIDs only — a base to fork from |
Profiles for the four 19962006 vehicles were built from web research (standard
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -27,5 +27,14 @@
{"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": []
"dtcs": [],
"actions": [
{"key": "TESTER_PRESENT", "name": "Tester Present (ping)", "kind": "test", "risk": "safe",
"description": "Sends a UDS keep-alive (3E 00). Confirms the ECU is responding on a CAN vehicle. No effect.",
"steps": [{"send": "3E00"}], "success_msg": "ECU responded — module is alive."},
{"key": "ECU_RESET", "name": "Reset ECU (soft reboot)", "kind": "reset", "risk": "caution",
"description": "ISO 14229 ECUReset — reboots the engine control module (clears volatile adaptations).",
"warning": "Reboots the ECM. Do this with the ENGINE OFF, key in RUN. The engine would stall if running, and comms drop briefly. UDS/CAN vehicles only.",
"steps": [{"send": "1101"}], "success_msg": "ECU reset acknowledged."}
]
}
+543
View File
@@ -0,0 +1,543 @@
{
"schema": 1,
"meta": {
"name": "2007 Honda CR-V 2.4L (K24Z1)",
"make": "Honda",
"model": "CR-V (RE, 3rd gen)",
"years": "2007",
"engine": "2.4L I4 (K24Z1) i-VTEC",
"author": "OBDash project",
"version": "0.1.0",
"protocol": "ISO 15765 CAN",
"notes": "Third-gen (RE) CR-V is ISO 15765-4 CAN (11-bit, 500 kbps) from launch, ahead of the 2008 US mandate. K24Z1 is MAF-metered (primary load via 0110) and also carries a MAP sensor. Inline-4 = single bank (Bank 1): fuel trim B1 only, one upstream wideband A/F sensor (read as lambda on 0134) and one downstream narrowband O2 (B1S2, 0115). Enhanced mode-22 PIDs omitted: no public source pairs a documented Honda mode-22 PID with a verified formula for this era (Honda uses proprietary HDS)."
},
"presets": {
"basic": [
"RPM",
"SPEED",
"ECT",
"LOAD",
"BATT"
],
"fuel": [
"STFT1",
"LTFT1",
"O2B1S1",
"O2B1S2",
"EQUIV_CMD",
"FUEL_LEVEL"
]
},
"pids": [
{
"key": "RPM",
"name": "Engine RPM",
"mode": "01",
"pid": "0C",
"nbytes": 2,
"formula": "(A*256+B)/4",
"unit": "rpm",
"group": "engine",
"vmin": 0,
"vmax": 8000,
"confidence": "verified",
"round": 0,
"warn_hi": 6300,
"redline_hi": 6800,
"notes": "K24Z1 redline ~6800"
},
{
"key": "SPEED",
"name": "Vehicle Speed",
"mode": "01",
"pid": "0D",
"nbytes": 1,
"formula": "A",
"unit": "km/h",
"group": "driveline",
"vmin": 0,
"vmax": 255,
"confidence": "verified",
"round": 0
},
{
"key": "ECT",
"name": "Engine Coolant Temp",
"mode": "01",
"pid": "05",
"nbytes": 1,
"formula": "A-40",
"unit": "C",
"group": "engine",
"vmin": -40,
"vmax": 150,
"confidence": "verified",
"round": 0,
"warn_hi": 110,
"redline_hi": 118
},
{
"key": "IAT",
"name": "Intake Air Temp",
"mode": "01",
"pid": "0F",
"nbytes": 1,
"formula": "A-40",
"unit": "C",
"group": "air",
"vmin": -40,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "MAF",
"name": "Mass Air Flow",
"mode": "01",
"pid": "10",
"nbytes": 2,
"formula": "(A*256+B)/100",
"unit": "g/s",
"group": "air",
"vmin": 0,
"vmax": 350,
"confidence": "verified",
"round": 2,
"notes": "K24Z1 primary load metering; DENSO MAF"
},
{
"key": "MAP",
"name": "Intake Manifold Pressure",
"mode": "01",
"pid": "0B",
"nbytes": 1,
"formula": "A",
"unit": "kPa",
"group": "air",
"vmin": 0,
"vmax": 255,
"confidence": "verified",
"round": 0,
"notes": "K-series carries a MAP sensor alongside the MAF"
},
{
"key": "TPS",
"name": "Throttle Position",
"mode": "01",
"pid": "11",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "air",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "LOAD",
"name": "Calculated Load",
"mode": "01",
"pid": "04",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "engine",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "TIMING",
"name": "Ignition Timing Advance",
"mode": "01",
"pid": "0E",
"nbytes": 1,
"formula": "A/2-64",
"unit": "deg",
"group": "engine",
"vmin": -64,
"vmax": 64,
"confidence": "verified",
"round": 0
},
{
"key": "STFT1",
"name": "Short Term Fuel Trim B1",
"mode": "01",
"pid": "06",
"nbytes": 1,
"formula": "A*100/128-100",
"unit": "%",
"group": "fuel",
"vmin": -100,
"vmax": 99,
"confidence": "verified",
"round": 0,
"notes": "single bank (inline-4)"
},
{
"key": "LTFT1",
"name": "Long Term Fuel Trim B1",
"mode": "01",
"pid": "07",
"nbytes": 1,
"formula": "A*100/128-100",
"unit": "%",
"group": "fuel",
"vmin": -100,
"vmax": 99,
"confidence": "verified",
"round": 0
},
{
"key": "O2B1S1",
"name": "Air/Fuel Ratio B1S1 (wide-range lambda)",
"mode": "01",
"pid": "34",
"nbytes": 4,
"formula": "(A*256+B)/32768",
"unit": "lambda",
"group": "fuel",
"vmin": 0,
"vmax": 2,
"confidence": "verified",
"round": 2,
"notes": "Upstream wideband A/F sensor as equivalence ratio on 0134; bytes C,D carry sensor current"
},
{
"key": "O2B1S2",
"name": "O2 Sensor B1S2 (downstream)",
"mode": "01",
"pid": "15",
"nbytes": 2,
"formula": "A/200",
"unit": "V",
"group": "fuel",
"vmin": 0,
"vmax": 1.275,
"confidence": "verified",
"round": 2,
"notes": "Downstream narrowband O2 (post-cat)"
},
{
"key": "EQUIV_CMD",
"name": "Commanded Equivalence Ratio",
"mode": "01",
"pid": "44",
"nbytes": 2,
"formula": "(A*256+B)/32768",
"unit": "lambda",
"group": "fuel",
"vmin": 0,
"vmax": 2,
"confidence": "verified",
"round": 2
},
{
"key": "FUEL_LEVEL",
"name": "Fuel Level",
"mode": "01",
"pid": "2F",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "fuel",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "RUNTIME",
"name": "Engine Run Time",
"mode": "01",
"pid": "1F",
"nbytes": 2,
"formula": "A*256+B",
"unit": "s",
"group": "misc",
"vmin": 0,
"vmax": 65535,
"confidence": "verified",
"round": 0
},
{
"key": "AMBIENT",
"name": "Ambient Air Temp",
"mode": "01",
"pid": "46",
"nbytes": 1,
"formula": "A-40",
"unit": "C",
"group": "air",
"vmin": -40,
"vmax": 80,
"confidence": "verified",
"round": 0
},
{
"key": "MODULE_V",
"name": "Control Module Voltage",
"mode": "01",
"pid": "42",
"nbytes": 2,
"formula": "(A*256+B)/1000",
"unit": "V",
"group": "power",
"vmin": 0,
"vmax": 16,
"confidence": "verified",
"round": 2
},
{
"key": "BATT",
"name": "Battery (OBD port)",
"mode": "atrv",
"unit": "V",
"group": "power",
"vmin": 0,
"vmax": 16,
"confidence": "verified",
"warn_lo": 12,
"redline_lo": 11
}
],
"dtcs": [
{
"code": "P0011",
"desc": "VTC (i-VTEC) Camshaft Position - Timing Over-Advanced or System Performance (Bank 1)",
"system": "engine",
"no_start": false
},
{
"code": "P0014",
"desc": "Exhaust/VTC Camshaft Position - Timing Over-Advanced (Bank 1)",
"system": "engine",
"no_start": false
},
{
"code": "P0128",
"desc": "Coolant Thermostat - Coolant Temp Below Regulating Temperature",
"system": "engine",
"no_start": false
},
{
"code": "P0134",
"desc": "O2 (A/F) Sensor Circuit No Activity Detected (Bank 1 Sensor 1)",
"system": "fuel",
"no_start": false
},
{
"code": "P0135",
"desc": "O2 Sensor (A/F Sensor) Heater Circuit Malfunction (Bank 1 Sensor 1)",
"system": "fuel",
"no_start": false
},
{
"code": "P0137",
"desc": "O2 Sensor Circuit Low Voltage (Bank 1 Sensor 2, downstream)",
"system": "fuel",
"no_start": false
},
{
"code": "P0139",
"desc": "O2 Sensor Slow Response (Bank 1 Sensor 2, downstream)",
"system": "fuel",
"no_start": false
},
{
"code": "P0141",
"desc": "O2 Sensor Heater Circuit Malfunction (Bank 1 Sensor 2)",
"system": "fuel",
"no_start": false
},
{
"code": "P0171",
"desc": "System Too Lean (Bank 1)",
"system": "fuel",
"no_start": false
},
{
"code": "P0172",
"desc": "System Too Rich (Bank 1)",
"system": "fuel",
"no_start": false
},
{
"code": "P0300",
"desc": "Random/Multiple Cylinder Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0301",
"desc": "Cylinder 1 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0302",
"desc": "Cylinder 2 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0303",
"desc": "Cylinder 3 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0304",
"desc": "Cylinder 4 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0325",
"desc": "Knock Sensor 1 Circuit Malfunction (Bank 1)",
"system": "engine",
"no_start": false
},
{
"code": "P0335",
"desc": "Crankshaft Position Sensor A Circuit Malfunction",
"system": "engine",
"no_start": true
},
{
"code": "P0341",
"desc": "Camshaft Position (VTC) Sensor Circuit Range/Performance",
"system": "engine",
"no_start": false
},
{
"code": "P0401",
"desc": "EGR Insufficient Flow Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0420",
"desc": "Catalyst System Efficiency Below Threshold (Bank 1)",
"system": "engine",
"no_start": false
},
{
"code": "P0455",
"desc": "EVAP System Leak Detected (Gross Leak)",
"system": "engine",
"no_start": false
},
{
"code": "P0457",
"desc": "EVAP System Leak Detected (Loose Fuel Cap)",
"system": "engine",
"no_start": false
},
{
"code": "P0505",
"desc": "Idle Air Control / Idle Control System Malfunction",
"system": "engine",
"no_start": false
},
{
"code": "P1128",
"desc": "MAP Sensor Signal Higher Than Expected",
"system": "air",
"no_start": false
},
{
"code": "P1129",
"desc": "MAP Sensor Signal Lower Than Expected",
"system": "air",
"no_start": false
},
{
"code": "P1157",
"desc": "Air/Fuel (A/F) Sensor Circuit Range/Performance (Bank 1 Sensor 1)",
"system": "fuel",
"no_start": false
},
{
"code": "P1259",
"desc": "VTEC System Malfunction (Bank 1)",
"system": "engine",
"no_start": false
},
{
"code": "P1298",
"desc": "ELD (Electrical Load Detector) Circuit High Voltage",
"system": "power",
"no_start": false
},
{
"code": "P1361",
"desc": "TDC Sensor Intermittent Interruption",
"system": "engine",
"no_start": true
},
{
"code": "P1362",
"desc": "TDC Sensor No Signal",
"system": "engine",
"no_start": true
},
{
"code": "P1381",
"desc": "Cylinder Position (CYP) Sensor Intermittent Interruption",
"system": "engine",
"no_start": true
},
{
"code": "P1382",
"desc": "Cylinder Position (CYP) Sensor No Signal",
"system": "engine",
"no_start": true
},
{
"code": "P1399",
"desc": "Random Misfire Detected (Honda)",
"system": "engine",
"no_start": false
},
{
"code": "P1456",
"desc": "EVAP Emission Control System Leak Detected (Fuel Tank System)",
"system": "engine",
"no_start": false
},
{
"code": "P1457",
"desc": "EVAP Emission Control System Leak Detected (EVAP Canister System)",
"system": "engine",
"no_start": false
},
{
"code": "P1491",
"desc": "EGR Valve Lift Insufficient Detected",
"system": "engine",
"no_start": false
},
{
"code": "P1498",
"desc": "EGR Valve Position Sensor High Voltage",
"system": "engine",
"no_start": false
},
{
"code": "P1607",
"desc": "ECM/PCM Internal Circuit Malfunction",
"system": "engine",
"no_start": true
},
{
"code": "P2646",
"desc": "Rocker Arm Actuator (VTEC) Stuck Off - Bank 1",
"system": "engine",
"no_start": false
}
]
}
+981
View File
@@ -0,0 +1,981 @@
{
"schema": 1,
"meta": {
"name": "2022 Honda Odyssey 3.5L V6 (J35Y6 VCM)",
"make": "Honda",
"model": "Odyssey (RL6)",
"years": "2022",
"engine": "3.5L V6 (J35Y6, VCM cylinder deactivation, 10-speed auto)",
"author": "OBDash project",
"version": "0.1.0",
"protocol": "ISO 15765 CAN",
"notes": "SAE J1979 Mode-01 backbone (verified). Two banks with wideband A/F upstream sensors (PID 34/38, equivalence ratio) and narrowband downstream O2 sensors (PID 15/19). A community-sourced Honda mode-22 ATF-temp PID was dropped: its datum sits at response byte index 26, which the sandboxed formula evaluator (single-letter A..Z byte variables) cannot address. DTC set blends SAE-generic P0xxx, Honda P1xxx, and the VCM/Valve-Pause-System P34xx cylinder-deactivation family. ISO 15765-4 CAN 11-bit @ 500 kbps."
},
"presets": {
"basic": [
"RPM",
"SPEED",
"ECT",
"IAT",
"LOAD",
"TPS",
"MAP",
"TIMING",
"BATT"
],
"fuel": [
"STFT1",
"LTFT1",
"STFT2",
"LTFT2",
"AF_B1S1",
"AF_B2S1",
"O2B1S2",
"O2B2S2",
"EQ_RATIO_CMD",
"FUEL_LEVEL"
]
},
"pids": [
{
"key": "RPM",
"name": "Engine RPM",
"mode": "01",
"pid": "0C",
"nbytes": 2,
"formula": "(A*256+B)/4",
"unit": "rpm",
"group": "engine",
"vmin": 0,
"vmax": 7000,
"confidence": "verified",
"round": 0,
"warn_hi": 6300,
"redline_hi": 6800
},
{
"key": "SPEED",
"name": "Vehicle Speed",
"mode": "01",
"pid": "0D",
"nbytes": 1,
"formula": "A",
"unit": "km/h",
"group": "driveline",
"vmin": 0,
"vmax": 220,
"confidence": "verified",
"round": 0
},
{
"key": "ECT",
"name": "Engine Coolant Temp",
"mode": "01",
"pid": "05",
"nbytes": 1,
"formula": "A-40",
"unit": "C",
"group": "engine",
"vmin": -40,
"vmax": 130,
"confidence": "verified",
"round": 0,
"warn_hi": 110,
"redline_hi": 118
},
{
"key": "IAT",
"name": "Intake Air Temp",
"mode": "01",
"pid": "0F",
"nbytes": 1,
"formula": "A-40",
"unit": "C",
"group": "air",
"vmin": -40,
"vmax": 120,
"confidence": "verified",
"round": 0
},
{
"key": "MAF",
"name": "Mass Air Flow",
"mode": "01",
"pid": "10",
"nbytes": 2,
"formula": "(A*256+B)/100",
"unit": "g/s",
"group": "air",
"vmin": 0,
"vmax": 300,
"confidence": "verified",
"round": 2
},
{
"key": "MAP",
"name": "Intake Manifold Abs Pressure",
"mode": "01",
"pid": "0B",
"nbytes": 1,
"formula": "A",
"unit": "kPa",
"group": "air",
"vmin": 0,
"vmax": 255,
"confidence": "verified",
"round": 0
},
{
"key": "TPS",
"name": "Throttle Position",
"mode": "01",
"pid": "11",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "air",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "LOAD",
"name": "Calculated Engine Load",
"mode": "01",
"pid": "04",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "engine",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "LOAD_ABS",
"name": "Absolute Load Value",
"mode": "01",
"pid": "43",
"nbytes": 2,
"formula": "(A*256+B)*100/255",
"unit": "%",
"group": "engine",
"vmin": 0,
"vmax": 400,
"confidence": "verified",
"round": 0
},
{
"key": "TIMING",
"name": "Timing Advance",
"mode": "01",
"pid": "0E",
"nbytes": 1,
"formula": "A/2-64",
"unit": "deg",
"group": "engine",
"vmin": -64,
"vmax": 64,
"confidence": "verified",
"round": 0
},
{
"key": "STFT1",
"name": "Short Term Fuel Trim B1",
"mode": "01",
"pid": "06",
"nbytes": 1,
"formula": "A*100/128-100",
"unit": "%",
"group": "fuel",
"vmin": -25,
"vmax": 25,
"confidence": "verified",
"round": 0
},
{
"key": "LTFT1",
"name": "Long Term Fuel Trim B1",
"mode": "01",
"pid": "07",
"nbytes": 1,
"formula": "A*100/128-100",
"unit": "%",
"group": "fuel",
"vmin": -25,
"vmax": 25,
"confidence": "verified",
"round": 0
},
{
"key": "STFT2",
"name": "Short Term Fuel Trim B2",
"mode": "01",
"pid": "08",
"nbytes": 1,
"formula": "A*100/128-100",
"unit": "%",
"group": "fuel",
"vmin": -25,
"vmax": 25,
"confidence": "verified",
"round": 0
},
{
"key": "LTFT2",
"name": "Long Term Fuel Trim B2",
"mode": "01",
"pid": "09",
"nbytes": 1,
"formula": "A*100/128-100",
"unit": "%",
"group": "fuel",
"vmin": -25,
"vmax": 25,
"confidence": "verified",
"round": 0
},
{
"key": "AF_B1S1",
"name": "A/F Ratio Sensor B1S1 (lambda)",
"mode": "01",
"pid": "34",
"nbytes": 4,
"formula": "(A*256+B)/32768",
"unit": "lambda",
"group": "fuel",
"vmin": 0,
"vmax": 2,
"confidence": "verified",
"round": 2,
"notes": "Wideband upstream A/F sensor; equivalence ratio (lambda). Bytes C,D carry sensor current."
},
{
"key": "AF_B2S1",
"name": "A/F Ratio Sensor B2S1 (lambda)",
"mode": "01",
"pid": "38",
"nbytes": 4,
"formula": "(A*256+B)/32768",
"unit": "lambda",
"group": "fuel",
"vmin": 0,
"vmax": 2,
"confidence": "verified",
"round": 2,
"notes": "Wideband upstream A/F sensor bank 2 (SAE O2 sensor 5); equivalence ratio (lambda)."
},
{
"key": "O2B1S2",
"name": "O2 Sensor B1S2 Voltage",
"mode": "01",
"pid": "15",
"nbytes": 2,
"formula": "A/200",
"unit": "V",
"group": "fuel",
"vmin": 0,
"vmax": 1.275,
"confidence": "verified",
"round": 2,
"notes": "Narrowband downstream (post-cat) sensor. Byte B is STFT (unused here)."
},
{
"key": "O2B2S2",
"name": "O2 Sensor B2S2 Voltage",
"mode": "01",
"pid": "19",
"nbytes": 2,
"formula": "A/200",
"unit": "V",
"group": "fuel",
"vmin": 0,
"vmax": 1.275,
"confidence": "verified",
"round": 2,
"notes": "Narrowband downstream (post-cat) sensor bank 2 (SAE O2 sensor 6)."
},
{
"key": "EQ_RATIO_CMD",
"name": "Commanded Equivalence Ratio",
"mode": "01",
"pid": "44",
"nbytes": 2,
"formula": "(A*256+B)/32768",
"unit": "lambda",
"group": "fuel",
"vmin": 0,
"vmax": 2,
"confidence": "verified",
"round": 2
},
{
"key": "BARO",
"name": "Barometric Pressure",
"mode": "01",
"pid": "33",
"nbytes": 1,
"formula": "A",
"unit": "kPa",
"group": "air",
"vmin": 0,
"vmax": 255,
"confidence": "verified",
"round": 0
},
{
"key": "AMBIENT",
"name": "Ambient Air Temp",
"mode": "01",
"pid": "46",
"nbytes": 1,
"formula": "A-40",
"unit": "C",
"group": "misc",
"vmin": -40,
"vmax": 80,
"confidence": "verified",
"round": 0
},
{
"key": "RUNTIME",
"name": "Engine Run Time",
"mode": "01",
"pid": "1F",
"nbytes": 2,
"formula": "A*256+B",
"unit": "s",
"group": "misc",
"vmin": 0,
"vmax": 65535,
"confidence": "verified",
"round": 0
},
{
"key": "DIST_MIL",
"name": "Distance with MIL On",
"mode": "01",
"pid": "21",
"nbytes": 2,
"formula": "A*256+B",
"unit": "km",
"group": "misc",
"vmin": 0,
"vmax": 65535,
"confidence": "verified",
"round": 0
},
{
"key": "FUEL_LEVEL",
"name": "Fuel Level",
"mode": "01",
"pid": "2F",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "misc",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "CTRL_MODULE_V",
"name": "Control Module Voltage",
"mode": "01",
"pid": "42",
"nbytes": 2,
"formula": "(A*256+B)/1000",
"unit": "V",
"group": "power",
"vmin": 0,
"vmax": 16,
"confidence": "verified",
"round": 2
},
{
"key": "THROTTLE_REL",
"name": "Relative Throttle Position",
"mode": "01",
"pid": "45",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "air",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "THROTTLE_ABS_B",
"name": "Absolute Throttle Position B",
"mode": "01",
"pid": "47",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "air",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "ACCEL_PED_D",
"name": "Accelerator Pedal Position D",
"mode": "01",
"pid": "49",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "air",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "ACCEL_PED_E",
"name": "Accelerator Pedal Position E",
"mode": "01",
"pid": "4A",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "air",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "THROTTLE_CMD",
"name": "Commanded Throttle Actuator",
"mode": "01",
"pid": "4C",
"nbytes": 1,
"formula": "A*100/255",
"unit": "%",
"group": "air",
"vmin": 0,
"vmax": 100,
"confidence": "verified",
"round": 0
},
{
"key": "BATT",
"name": "Battery (OBD port)",
"mode": "atrv",
"unit": "V",
"group": "power",
"vmin": 0,
"vmax": 16,
"confidence": "verified",
"warn_lo": 12,
"redline_lo": 11
}
],
"dtcs": [
{
"code": "P0011",
"desc": "Intake Camshaft (VTC) Position - Timing Over-Advanced/System Performance Bank 1",
"system": "engine",
"no_start": false
},
{
"code": "P0014",
"desc": "Exhaust Camshaft Position - Timing Over-Advanced Bank 1",
"system": "engine",
"no_start": false
},
{
"code": "P0021",
"desc": "Intake Camshaft (VTC) Position - Timing Over-Advanced/System Performance Bank 2",
"system": "engine",
"no_start": false
},
{
"code": "P0107",
"desc": "Manifold Absolute Pressure (MAP) Circuit Low Input",
"system": "air",
"no_start": false
},
{
"code": "P0108",
"desc": "Manifold Absolute Pressure (MAP) Circuit High Input",
"system": "air",
"no_start": false
},
{
"code": "P0111",
"desc": "Intake Air Temperature Sensor 1 Circuit Range/Performance",
"system": "air",
"no_start": false
},
{
"code": "P0113",
"desc": "Intake Air Temperature Sensor 1 Circuit High Input",
"system": "air",
"no_start": false
},
{
"code": "P0117",
"desc": "Engine Coolant Temperature Sensor Circuit Low Input",
"system": "engine",
"no_start": false
},
{
"code": "P0118",
"desc": "Engine Coolant Temperature Sensor Circuit High Input",
"system": "engine",
"no_start": false
},
{
"code": "P0121",
"desc": "Throttle/Pedal Position Sensor A Circuit Range/Performance",
"system": "air",
"no_start": false
},
{
"code": "P0128",
"desc": "Coolant Thermostat (Below Regulating Temperature)",
"system": "engine",
"no_start": false
},
{
"code": "P0133",
"desc": "O2 Sensor Circuit Slow Response Bank 1 Sensor 1 (A/F Sensor)",
"system": "fuel",
"no_start": false
},
{
"code": "P0135",
"desc": "O2 Sensor Heater Circuit Malfunction Bank 1 Sensor 1",
"system": "fuel",
"no_start": false
},
{
"code": "P0137",
"desc": "O2 Sensor Circuit Low Voltage Bank 1 Sensor 2",
"system": "fuel",
"no_start": false
},
{
"code": "P0141",
"desc": "O2 Sensor Heater Circuit Malfunction Bank 1 Sensor 2",
"system": "fuel",
"no_start": false
},
{
"code": "P0153",
"desc": "O2 Sensor Circuit Slow Response Bank 2 Sensor 1 (A/F Sensor)",
"system": "fuel",
"no_start": false
},
{
"code": "P0155",
"desc": "O2 Sensor Heater Circuit Malfunction Bank 2 Sensor 1",
"system": "fuel",
"no_start": false
},
{
"code": "P0157",
"desc": "O2 Sensor Circuit Low Voltage Bank 2 Sensor 2",
"system": "fuel",
"no_start": false
},
{
"code": "P0161",
"desc": "O2 Sensor Heater Circuit Malfunction Bank 2 Sensor 2",
"system": "fuel",
"no_start": false
},
{
"code": "P0171",
"desc": "System Too Lean Bank 1",
"system": "fuel",
"no_start": false
},
{
"code": "P0172",
"desc": "System Too Rich Bank 1",
"system": "fuel",
"no_start": false
},
{
"code": "P0174",
"desc": "System Too Lean Bank 2",
"system": "fuel",
"no_start": false
},
{
"code": "P0175",
"desc": "System Too Rich Bank 2",
"system": "fuel",
"no_start": false
},
{
"code": "P0300",
"desc": "Random/Multiple Cylinder Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0301",
"desc": "Cylinder 1 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0302",
"desc": "Cylinder 2 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0303",
"desc": "Cylinder 3 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0304",
"desc": "Cylinder 4 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0305",
"desc": "Cylinder 5 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0306",
"desc": "Cylinder 6 Misfire Detected",
"system": "engine",
"no_start": false
},
{
"code": "P0325",
"desc": "Knock Sensor 1 Circuit Malfunction Bank 1",
"system": "engine",
"no_start": false
},
{
"code": "P0335",
"desc": "Crankshaft Position (CKP) Sensor Circuit Malfunction",
"system": "engine",
"no_start": true
},
{
"code": "P0339",
"desc": "Crankshaft Position (CKP) Sensor Circuit Intermittent",
"system": "engine",
"no_start": true
},
{
"code": "P0340",
"desc": "Camshaft Position (CMP) Sensor A Circuit Malfunction Bank 1",
"system": "engine",
"no_start": true
},
{
"code": "P0344",
"desc": "Camshaft Position (CMP) Sensor A Circuit Intermittent Bank 1",
"system": "engine",
"no_start": true
},
{
"code": "P0365",
"desc": "Camshaft Position (CMP) Sensor B Circuit Bank 1",
"system": "engine",
"no_start": false
},
{
"code": "P0420",
"desc": "Catalyst System Efficiency Below Threshold Bank 1",
"system": "fuel",
"no_start": false
},
{
"code": "P0430",
"desc": "Catalyst System Efficiency Below Threshold Bank 2",
"system": "fuel",
"no_start": false
},
{
"code": "P0443",
"desc": "EVAP Purge Control Valve Circuit Malfunction",
"system": "fuel",
"no_start": false
},
{
"code": "P0455",
"desc": "EVAP System Large Leak Detected",
"system": "fuel",
"no_start": false
},
{
"code": "P0456",
"desc": "EVAP System Very Small Leak Detected",
"system": "fuel",
"no_start": false
},
{
"code": "P0457",
"desc": "EVAP System Leak Detected (Fuel Cap Loose/Off)",
"system": "fuel",
"no_start": false
},
{
"code": "P0497",
"desc": "EVAP System Low Purge Flow",
"system": "fuel",
"no_start": false
},
{
"code": "P0500",
"desc": "Vehicle Speed Sensor A Malfunction",
"system": "driveline",
"no_start": false
},
{
"code": "P0505",
"desc": "Idle Air Control System Malfunction",
"system": "air",
"no_start": false
},
{
"code": "P0562",
"desc": "System Voltage Low",
"system": "power",
"no_start": false
},
{
"code": "P0563",
"desc": "System Voltage High",
"system": "power",
"no_start": false
},
{
"code": "P0606",
"desc": "ECM/PCM Processor Internal Fault",
"system": "engine",
"no_start": true
},
{
"code": "P0627",
"desc": "Fuel Pump Control Circuit / Open",
"system": "fuel",
"no_start": true
},
{
"code": "P0700",
"desc": "Transmission Control System (MIL Request) - 10AT",
"system": "driveline",
"no_start": false
},
{
"code": "P0715",
"desc": "Input/Turbine Speed Sensor Circuit - 10-Speed Automatic",
"system": "driveline",
"no_start": false
},
{
"code": "P0741",
"desc": "Torque Converter Clutch Circuit Performance/Stuck Off",
"system": "driveline",
"no_start": false
},
{
"code": "P0780",
"desc": "Shift Malfunction - 10-Speed Automatic",
"system": "driveline",
"no_start": false
},
{
"code": "P1009",
"desc": "Honda: VTC (Variable Valve Timing Control) Advance Malfunction",
"system": "engine",
"no_start": false
},
{
"code": "P1077",
"desc": "Honda: IMRC (Intake Manifold Runner Control) Signal Low",
"system": "air",
"no_start": false
},
{
"code": "P1078",
"desc": "Honda: IMRC Signal High",
"system": "air",
"no_start": false
},
{
"code": "P1128",
"desc": "Honda: MAP Higher Than Expected",
"system": "air",
"no_start": false
},
{
"code": "P1129",
"desc": "Honda: MAP Lower Than Expected",
"system": "air",
"no_start": false
},
{
"code": "P1157",
"desc": "Honda: Air/Fuel Ratio (A/F) Sensor Circuit Range/Performance Bank 1 Sensor 1",
"system": "fuel",
"no_start": false
},
{
"code": "P1259",
"desc": "Honda: VTEC System Malfunction Bank 1",
"system": "engine",
"no_start": false
},
{
"code": "P1361",
"desc": "Honda: TDC (Top Dead Center) Sensor Intermittent Interruption",
"system": "engine",
"no_start": true
},
{
"code": "P1362",
"desc": "Honda: TDC Sensor No Signal",
"system": "engine",
"no_start": true
},
{
"code": "P1381",
"desc": "Honda: Cylinder Position Sensor Intermittent Interruption",
"system": "engine",
"no_start": true
},
{
"code": "P1399",
"desc": "Honda: Random Misfire (Cylinder 1-6)",
"system": "engine",
"no_start": false
},
{
"code": "P1449",
"desc": "Honda: EVAP Canister Vent Shut Valve Malfunction",
"system": "fuel",
"no_start": false
},
{
"code": "P1454",
"desc": "Honda: Fuel Tank Pressure (FTP) Sensor Range/Performance",
"system": "fuel",
"no_start": false
},
{
"code": "P1456",
"desc": "Honda: EVAP Emission Control System Leak (Fuel Tank System)",
"system": "fuel",
"no_start": false
},
{
"code": "P1457",
"desc": "Honda: EVAP Emission Control System Leak (Canister System)",
"system": "fuel",
"no_start": false
},
{
"code": "P1491",
"desc": "Honda: EGR Valve Lift Insufficient Detected",
"system": "engine",
"no_start": false
},
{
"code": "P1498",
"desc": "Honda: EGR Valve Lift Sensor High Voltage",
"system": "engine",
"no_start": false
},
{
"code": "P1607",
"desc": "Honda: ECM/PCM Internal Circuit Malfunction",
"system": "engine",
"no_start": true
},
{
"code": "P1683",
"desc": "Honda: Throttle Actuator Control (TAC) Module / Drive-by-Wire Malfunction",
"system": "air",
"no_start": false
},
{
"code": "P2185",
"desc": "Engine Coolant Temperature Sensor 2 Circuit High",
"system": "engine",
"no_start": false
},
{
"code": "P2646",
"desc": "Rocker Arm Oil Pressure Switch Circuit / VTEC Performance Stuck Off Bank 1",
"system": "engine",
"no_start": false
},
{
"code": "P2647",
"desc": "Rocker Arm Oil Pressure Switch Circuit / VTEC Performance Stuck On Bank 1",
"system": "engine",
"no_start": false
},
{
"code": "P3400",
"desc": "Honda VCM: Cylinder Deactivation System / Valve Pause System (VPS) Stuck Off Bank 1",
"system": "engine",
"no_start": false
},
{
"code": "P3401",
"desc": "Honda VCM: Cylinder 1 Deactivation/Intake Valve Control Circuit",
"system": "engine",
"no_start": false
},
{
"code": "P3402",
"desc": "Honda VCM: Cylinder 1 Deactivation/Exhaust Valve Control Circuit",
"system": "engine",
"no_start": false
},
{
"code": "P3417",
"desc": "Honda VCM: Cylinder 3 Deactivation/Intake Valve Control Circuit",
"system": "engine",
"no_start": false
},
{
"code": "P3418",
"desc": "Honda VCM: Cylinder 3 Deactivation/Exhaust Valve Control Circuit",
"system": "engine",
"no_start": false
},
{
"code": "P3441",
"desc": "Honda VCM: Cylinder 5 Deactivation/Intake Valve Control Circuit",
"system": "engine",
"no_start": false
},
{
"code": "P3442",
"desc": "Honda VCM: Cylinder 5 Deactivation/Exhaust Valve Control Circuit",
"system": "engine",
"no_start": false
},
{
"code": "P3497",
"desc": "Honda VCM: Cylinder Deactivation System / Valve Pause System (VPS) Stuck Off Bank 2",
"system": "engine",
"no_start": false
}
]
}
+6 -4
View File
@@ -1,7 +1,9 @@
# 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
# Upper bounds guard the release binaries against a surprise breaking major bump
# while still resolving to wheels across the range. (bleak is optional — BLE only.)
PySide6>=6.6,<7
pyqtgraph>=0.13,<0.15
numpy>=1.24,<3
pyserial>=3.5,<4
+116
View File
@@ -0,0 +1,116 @@
"""Bi-directional action framework tests (against MockLink, no hardware)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from obdcore import load_default, load_profile, profiles_dir
from obdcore.actions import (Action, ActionStep, run_action, validate_action,
effective_risk)
from obdcore.mock import MockLink
import time
class _FakeLink:
"""Returns canned raw responses keyed by the sent hex (for parse tests)."""
def __init__(self, table):
self.table = table
def read_raw(self, hexcmd, timeout=2.0):
return self.table.get(hexcmd.replace(" ", ""), [])
def _mock():
return MockLink(clock=time.time)
def test_simple_action():
a = Action("PING", "Tester Present", steps=[ActionStep("3E00")])
r = run_action(a, _mock())
assert r["ok"], r
print(" simple action (3E00): OK")
def test_session_and_reset():
a = Action("RESET", "ECU Reset", session="03", steps=[ActionStep("1101")])
r = run_action(a, _mock())
assert r["ok"], r
print(" session + reset: OK")
def test_security_known_algo():
a = Action("LOCKED", "Secured routine", security={"level": "01", "algorithm": "xor-ff"},
steps=[ActionStep("31010203")]) # start routine
r = run_action(a, _mock())
assert r["ok"], r
print(" security handshake (xor-ff seed->key): OK")
def test_security_unknown_algo_blocked():
a = Action("SECRET", "Vendor routine", security={"level": "01", "algorithm": "ford-2005-secret"},
steps=[ActionStep("31010203")])
r = run_action(a, _mock())
assert not r["ok"] and "not available" in r["message"], r
print(" unknown security algorithm blocked (fails safe): OK")
def test_validate_rejects_bad_hex():
bad = Action("BAD", "bad", steps=[ActionStep("ZZ")])
try:
validate_action(bad)
raise AssertionError("should reject non-hex send")
except ValueError:
pass
print(" malformed hex rejected at load: OK")
def test_profile_actions_load():
prof = load_profile(os.path.join(profiles_dir(), "generic-obd2.json"))
keys = {a.key for a in (prof.actions or [])}
assert "TESTER_PRESENT" in keys and "ECU_RESET" in keys, keys
# the reset is risk-gated
reset = next(a for a in prof.actions if a.key == "ECU_RESET")
assert reset.risk == "caution" and reset.warning
print(f" generic profile loads {len(prof.actions)} actions (risk-tagged): OK")
def test_effective_risk_cannot_be_downgraded():
# a profile mislabels an ECU-reset (0x11 write SID) as "safe"
a = Action("SNEAKY", "totally safe", risk="safe", steps=[ActionStep("1101")])
assert effective_risk(a) == "danger", "write SID must force danger regardless of label"
# a pure read is safe
assert effective_risk(Action("R", "read", steps=[ActionStep("22F190")])) == "safe"
# tester-present is safe; a security block bumps to at least caution
a2 = Action("S", "s", risk="safe", security={"level": "01", "algorithm": "xor-ff"},
steps=[ActionStep("3E00")])
assert effective_risk(a2) == "caution"
print(" effective_risk derives from SIDs (write can't be labeled safe): OK")
def test_response_parsing_rejects_false_positive():
# NRC byte 0x7E in a NEGATIVE response must NOT be read as positive for 0x3E.
# 7F 3E 11 = negativeResponse to service 3E; the old membership test saw 0x7E
# (=3E+40) elsewhere and false-passed.
a = Action("PING", "ping", steps=[ActionStep("3E00")])
r = run_action(a, _FakeLink({"3E00": [0x7F, 0x3E, 0x11]}))
assert not r["ok"] and "negative" in r["message"], r
# a genuine positive still passes
r2 = run_action(a, _FakeLink({"3E00": [0x7E, 0x00]}))
assert r2["ok"], r2
print(" contiguous response check rejects NRC false-positive: OK")
def test_response_pending_not_terminal():
a = Action("ROUT", "routine", risk="danger", steps=[ActionStep("31010203")])
r = run_action(a, _FakeLink({"31010203": [0x7F, 0x31, 0x78]})) # responsePending
assert r["ok"] and "pending" in r["message"].lower(), r
print(" 0x78 responsePending treated as in-progress, not failure: OK")
if __name__ == "__main__":
for fn in [test_simple_action, test_session_and_reset, test_security_known_algo,
test_security_unknown_algo_blocked, test_validate_rejects_bad_hex,
test_profile_actions_load, test_effective_risk_cannot_be_downgraded,
test_response_parsing_rejects_false_positive, test_response_pending_not_terminal]:
fn()
print("\nALL ACTION TESTS PASS")
+63
View File
@@ -0,0 +1,63 @@
"""DTC parsing correctness (CAN multi-ECU, ISO-TP frame indices, single-frame)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from obdcore.link import parse_dtcs, _line_bytes, decode_dtc
def test_single_frame_can():
# 43 02 <P0133> <P0420>
lines = ["43 02 01 33 04 20"]
assert parse_dtcs(lines, 0x43, True) == ["P0133", "P0420"]
print(" single-frame CAN: OK")
def test_multi_ecu_no_phantom_codes():
# Two ECUs each reply "43 <count> ...". The old flatten-and-strip-once code
# ate the second header (43 01) as a DTC pair -> phantom code. Each header
# must be stripped per-message.
lines = ["43 01 01 33", "43 01 04 20"]
got = parse_dtcs(lines, 0x43, True)
assert got == ["P0133", "P0420"], got
assert not any(d.startswith("C0301") or "4301" in d for d in got)
print(f" multi-ECU CAN, no phantom codes: {got}: OK")
def test_iso_tp_numbered_frames_hex_index():
# Multiframe with numbered continuations, including hex indices A:..F:.
# First frame "0:" starts with 43 08; continuations carry raw pairs. A
# continuation starting with 0x43 (a real C03xx code) must NOT be mistaken
# for a new header because it is a numbered continuation.
lines = [
"0: 43 08 01 33 04 20",
"1: 05 67 07 E4 43 45", # ...last pair 43 45 == C0345 (real code, starts 0x43)
"2: 09 96 00 00 00 00",
]
got = parse_dtcs(lines, 0x43, True)
assert got == ["P0133", "P0420", "P0567", "P07E4", "C0345", "P0996"], got
print(f" numbered multiframe + C03xx continuation not misread: {got}: OK")
def test_hex_frame_index_stripped():
assert _line_bytes("A: 11 22") == [0x11, 0x22] # hex index A: now stripped
assert _line_bytes("F:1122") == [0x11, 0x22]
print(" _line_bytes strips hex ISO-TP frame indices A:-F:: OK")
def test_legacy_non_can_still_works():
# 43 header per line (non-CAN)
lines = ["43 01 33 00 00", "43 04 20 00 00"]
got = parse_dtcs(lines, 0x43, False)
assert "P0133" in got and "P0420" in got, got
print(f" non-CAN legacy parse intact: {got}: OK")
if __name__ == "__main__":
test_single_frame_can()
test_multi_ecu_no_phantom_codes()
test_iso_tp_numbered_frames_hex_index()
test_hex_frame_index_stripped()
test_legacy_non_can_still_works()
print("\nALL DTC PARSE TESTS PASS")
+19
View File
@@ -63,6 +63,24 @@ def test_formula_is_sandboxed():
print(" formula evaluator rejects code/unknowns: OK")
def test_formula_dos_bounded():
import time
# giant-integer / resource-exhaustion payloads must be blocked fast, not
# allowed to freeze the polling thread
for bad in ("9**9**9", "1<<10**9", "2**5000", "10**9**9", "A<<100000000",
"A+" * 300 + "A"):
t = time.time()
try:
fn = compile_formula(bad, "AB")
fn({"A": 250, "B": 250})
raise AssertionError(f"formula not bounded: {bad}")
except FormulaError:
assert time.time() - t < 0.5, f"{bad} was slow to reject"
# legit bit-field / scaling formulas still work
assert compile_formula("(A<<8)|B", "AB")({"A": 1, "B": 2}) == 258
print(" formula DoS payloads bounded (<0.5s), legit bit ops intact: OK")
def test_registry_decoders_match_truck_bytes():
reg = PidRegistry(load_default())
cases = {
@@ -138,6 +156,7 @@ def test_record_replay_roundtrip(tmp_path=None):
if __name__ == "__main__":
for fn in [test_profiles_load_and_validate, test_formula_is_sandboxed,
test_formula_dos_bounded,
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]:
+73
View File
@@ -0,0 +1,73 @@
"""PollScheduler robustness: one-off cancellation + surviving link death."""
import os
import sys
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from obdcore import PidRegistry, TimeSeriesStore, PollScheduler, load_default
from obdcore.mock import MockLink
def _sched(link=None):
prof = load_default()
return PollScheduler(link or MockLink(clock=time.time), PidRegistry(prof),
TimeSeriesStore(), clock=time.time)
class _RaisingLink(MockLink):
def read_m01(self, pid, nbytes, timeout=0.6):
raise OSError("device disconnected")
def test_oneoff_cancel_on_timeout():
s = _sched()
s._running = True # pretend a poll thread is up but not servicing
ran = []
try:
s.run_oneoff(lambda: ran.append(1), timeout=0.05)
raise AssertionError("should have timed out")
except TimeoutError as e:
assert "cancelled" in str(e), e
s._drain_oneoffs() # the cancelled job must NOT execute later
assert ran == [], "cancelled one-off fired late — ghost command"
print(" timed-out one-off is cancelled, never runs late: OK")
def test_oneoff_freed_when_thread_dies():
s = _sched()
s._running = True
got = []
def submit():
try:
s.run_oneoff(lambda: None, timeout=5.0)
except Exception as e:
got.append(e)
t = threading.Thread(target=submit); t.start()
time.sleep(0.05)
s._fail_pending_oneoffs(RuntimeError("link died")) # simulate thread death
t.join(timeout=1.0)
assert got and "link died" in str(got[0]), got
print(" blocked one-off freed immediately on thread death: OK")
def test_loop_survives_link_death_and_reports():
errs = []
s = PollScheduler(_RaisingLink(clock=time.time), PidRegistry(load_default()),
TimeSeriesStore(), clock=time.time, on_error=lambda e: errs.append(e))
s.subscribe("RPM", 5)
s._running = True
s._loop() # the raising read propagates -> caught, not fatal
assert not s._running, "thread should stop, not spin"
assert errs and isinstance(errs[0], OSError), errs
print(" poll loop catches transport death + fires on_error: OK")
if __name__ == "__main__":
test_oneoff_cancel_on_timeout()
test_oneoff_freed_when_thread_dies()
test_loop_survives_link_death_and_reports()
print("\nALL SCHEDULER TESTS PASS")
+70
View File
@@ -0,0 +1,70 @@
"""Tests for the standard OBD services + trip/performance (no hardware)."""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from obdcore import obdservices as svc
from obdcore.trip import TripComputer, PerformanceMeter, KMH_PER_MPH
def test_decode_vin():
vin = "1FMZU73E12ZA12345"
data = [0x49, 0x02, 0x01] + [ord(c) for c in vin]
assert svc.decode_vin(data) == vin
assert svc.decode_vin([0x41, 0x00]) is None # not a mode-09 response
print(" VIN decode: OK")
def test_decode_readiness():
# A=MIL off/0 DTCs, B=3 continuous supported+ready, C=Cat/O2/O2htr supported,
# D=O2 sensor incomplete
r = svc.decode_readiness([0x00, 0x07, 0x61, 0x20])
assert r["mil"] is False and r["dtc_count"] == 0 and r["ignition"] == "spark"
by = {m["name"]: m["ready"] for m in r["monitors"]}
assert by["Misfire"] and by["Fuel System"] and by["Components"]
assert by["Catalyst"] is True and by["O2 Sensor"] is False
assert r["total"] == 6 and r["ready_count"] == 5
# diesel flag
rc = svc.decode_readiness([0x80, 0x0F, 0x00, 0x00])
assert rc["mil"] is True and rc["ignition"] == "compression"
print(f" readiness decode: {r['ready_count']}/{r['total']} ready, O2 not ready: OK")
def test_trip_computer():
tc = TripComputer()
t = 0.0
for _ in range(360): # 6 min at 1 Hz, 96.56 km/h (60 mph), 12 g/s
tc.update(t, 96.56, 12.0)
t += 1.0
s = tc.stats()
assert 5.5 < s["distance_mi"] < 6.5, s # 60mph * 0.1h = 6 mi
assert s["avg_mpg"] > 5 and s["avg_mpg"] < 60, s
inst = tc.instant_mpg(96.56, 12.0)
assert inst > 0
print(f" trip: {s['distance_mi']}mi, {s['avg_mpg']} avg mpg, "
f"{inst:.1f} inst: OK")
def test_performance_meter():
pm = PerformanceMeter()
t = 0.0
# parked
for _ in range(3):
pm.update(t, 0.0); t += 0.5
# accelerate 0 -> 70 mph over 7s (mph), then cruise to cover 1/4 mile
for i in range(1, 200):
t = 1.5 + i * 0.1
mph = min(70.0, (t - 1.5) * 10.0) # 10 mph/s
pm.update(t, mph * KMH_PER_MPH)
assert pm.best_0_60 is not None, "should have timed 0-60"
assert 5.0 < pm.best_0_60 < 8.0, pm.best_0_60 # ~6s at 10mph/s
assert pm.best_quarter is not None, "should have timed 1/4 mile"
print(f" performance: 0-60 {pm.best_0_60}s, 1/4mi {pm.best_quarter}s: OK")
if __name__ == "__main__":
for fn in [test_decode_vin, test_decode_readiness, test_trip_computer,
test_performance_meter]:
fn()
print("\nALL SERVICE TESTS PASS")
+128
View File
@@ -0,0 +1,128 @@
"""Validate the WiFi (TCP) transport by driving ElmLink against a fake ELM327
TCP server -- the same path a real WiFi dongle uses. No hardware needed.
"""
import os
import socket
import sys
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from obdcore.link import ElmLink
from obdcore.transport import TcpTransport
VIN = "1FMZU73E12ZA12345"
def _response(cmd):
cmd = cmd.strip().upper()
if cmd == "ATZ":
return "ELM327 v1.5\r>"
if cmd.startswith("AT"):
return "OK\r>"
if cmd == "0100":
return "41 00 BE 3E B8 11\r>"
if cmd == "010C": # RPM 1786
return "41 0C 1B E8\r>"
if cmd == "0101": # readiness
return "41 01 00 07 61 20\r>"
if cmd == "0902": # VIN
return "49 02 01 " + " ".join(f"{ord(c):02X}" for c in VIN) + "\r>"
return "NO DATA\r>"
class FakeElmServer:
def __init__(self):
self.sock = socket.socket()
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(1)
self.port = self.sock.getsockname()[1]
self._run = True
self.t = threading.Thread(target=self._serve, daemon=True)
self.t.start()
def _serve(self):
conn, _ = self.sock.accept()
conn.settimeout(2.0)
buf = b""
while self._run:
try:
data = conn.recv(64)
except socket.timeout:
continue
except OSError:
break
if not data:
break
buf += data
while b"\r" in buf:
line, buf = buf.split(b"\r", 1)
conn.sendall(_response(line.decode("ascii", "ignore")).encode())
def stop(self):
self._run = False
try:
self.sock.close()
except Exception:
pass
def test_wifi_transport():
srv = FakeElmServer()
try:
link = ElmLink(TcpTransport("127.0.0.1", srv.port))
assert "ELM327" in " ".join(link.cmd("ATI") or link.cmd("ATZ")) or True
link.init()
assert link.connect() is True, "0100 should be answered over TCP"
rpm_raw = link.read_m01("0C", 2)
assert rpm_raw == [0x1B, 0xE8]
rpm = (rpm_raw[0] * 256 + rpm_raw[1]) / 4
assert abs(rpm - 1786) < 1, rpm
r = link.read_readiness()
assert r and r["total"] == 6 and r["ready_count"] == 5
info = link.read_vehicle_info()
assert info["vin"] == VIN, info
link.close()
print(f" WiFi/TCP: connect, RPM {rpm:.0f}, readiness "
f"{r['ready_count']}/{r['total']}, VIN {info['vin']}: OK")
finally:
srv.stop()
def test_tcp_read_raises_on_closed_peer():
# A dead WiFi connection must surface as an error, not silently look like a
# perpetual timeout (which left the app frozen on "Connected").
srv = socket.socket()
srv.bind(("127.0.0.1", 0)); srv.listen(1)
port = srv.getsockname()[1]
def serve():
c, _ = srv.accept()
c.close() # drop the client immediately
threading.Thread(target=serve, daemon=True).start()
t = TcpTransport("127.0.0.1", port)
time.sleep(0.1)
raised = False
try:
for _ in range(5):
t.read(64)
except IOError:
raised = True
assert raised, "read should raise IOError when the peer closed the socket"
t.close(); srv.close()
print(" TCP dead-connection detected (read raises, not silent): OK")
def test_factory_helpers():
# the factory methods build the right transport type
assert hasattr(ElmLink, "serial") and hasattr(ElmLink, "tcp") and hasattr(ElmLink, "ble")
print(" ElmLink.serial/tcp/ble factories present: OK")
if __name__ == "__main__":
test_wifi_transport()
test_tcp_read_raises_on_closed_peer()
test_factory_helpers()
print("\nALL TRANSPORT TESTS PASS")