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: windows:
runs-on: windows-latest # self-hosted Windows runner (no Python preinstalled) runs-on: windows-latest # self-hosted Windows runner (no Python preinstalled)
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build OBDash.exe (PyInstaller) - name: Build OBDash.exe (PyInstaller)
shell: pwsh shell: pwsh
run: | run: |
@@ -42,18 +42,21 @@ jobs:
& $py -m pip install --no-cache-dir -r requirements-gui.txt pyinstaller & $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 & $py -m PyInstaller --noconfirm --onefile --windowed --name OBDash --add-data "profiles;profiles" run_gui.py
Copy-Item dist/OBDash.exe OBDash-windows.exe 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 - name: Publish to release
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with: with:
files: OBDash-windows.exe files: |
OBDash-windows.exe
OBDash-windows.exe.sha256
env: env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
macos: macos:
runs-on: self-hosted-mac runs-on: self-hosted-mac
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build OBDash.app (PyInstaller) - name: Build OBDash.app (PyInstaller)
shell: bash shell: bash
run: | run: |
@@ -63,19 +66,22 @@ jobs:
pip install -r requirements-gui.txt pyinstaller pip install -r requirements-gui.txt pyinstaller
pyinstaller --noconfirm --windowed --name OBDash --add-data "profiles:profiles" run_gui.py pyinstaller --noconfirm --windowed --name OBDash --add-data "profiles:profiles" run_gui.py
ditto -c -k --keepParent dist/OBDash.app OBDash-macos.zip ditto -c -k --keepParent dist/OBDash.app OBDash-macos.zip
shasum -a 256 OBDash-macos.zip > OBDash-macos.zip.sha256
- name: Publish to release - name: Publish to release
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with: with:
files: OBDash-macos.zip files: |
OBDash-macos.zip
OBDash-macos.zip.sha256
env: env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
linux-amd64: linux-amd64:
runs-on: docker # Linux x86_64 runner 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build OBDash (PyInstaller) - name: Build OBDash (PyInstaller)
shell: bash shell: bash
run: | run: |
@@ -84,19 +90,22 @@ jobs:
pip install -r requirements-gui.txt pyinstaller pip install -r requirements-gui.txt pyinstaller
pyinstaller --noconfirm --onefile --name OBDash --add-data "profiles:profiles" run_gui.py pyinstaller --noconfirm --onefile --name OBDash --add-data "profiles:profiles" run_gui.py
cp dist/OBDash OBDash-linux-x86_64 cp dist/OBDash OBDash-linux-x86_64
sha256sum OBDash-linux-x86_64 > OBDash-linux-x86_64.sha256
- name: Publish to release - name: Publish to release
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with: with:
files: OBDash-linux-x86_64 files: |
OBDash-linux-x86_64
OBDash-linux-x86_64.sha256
env: env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
linux-arm64: linux-arm64:
runs-on: arm64 # Raspberry Pi (aarch64) runner 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build OBDash (PyInstaller) - name: Build OBDash (PyInstaller)
shell: bash shell: bash
run: | run: |
@@ -105,10 +114,13 @@ jobs:
pip install -r requirements-gui.txt pyinstaller pip install -r requirements-gui.txt pyinstaller
pyinstaller --noconfirm --onefile --name OBDash --add-data "profiles:profiles" run_gui.py pyinstaller --noconfirm --onefile --name OBDash --add-data "profiles:profiles" run_gui.py
cp dist/OBDash OBDash-linux-aarch64 cp dist/OBDash OBDash-linux-aarch64
sha256sum OBDash-linux-aarch64 > OBDash-linux-aarch64.sha256
- name: Publish to release - name: Publish to release
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2.2.1
with: with:
files: OBDash-linux-aarch64 files: |
OBDash-linux-aarch64
OBDash-linux-aarch64.sha256
env: env:
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
+129 -118
View File
@@ -1,139 +1,150 @@
# OBDash # OBDash
Minimal **ELM327 OBD-II code reader** with a **Ford 6.0L Power Stroke no-start triage**, **Open-source, vehicle-agnostic OBD-II scanner — Python/Qt, cross-platform.**
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.
Created as a stopgap while [forscan.org](https://forscan.org) was offline — it covers A desktop app that turns a cheap ELM327 adapter into a real diagnostic tool:
reading/clearing codes and the basics, not Ford-enhanced diesel PIDs (see Scope below). 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
## Features 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**.
- 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
![Multi-axis graph](docs/gui-p2-multiaxis.png) ![Multi-axis graph](docs/gui-p2-multiaxis.png)
![Gauge view](docs/gui-p2-gauges.png) ![Gauge view](docs/gui-p2-gauges.png)
The whole app runs against simulated data (`MockLink`), so it can be developed > Validated on real vehicles (1997 Jeep Wrangler 4.0 I6, 1996 Mustang Cobra 4.6
on any machine and only needs the vehicle for real captures. See > DOHC, Ford 6.0L Power Stroke, …) using a QinHeng CH340 ELM327 clone.
[ARCHITECTURE.md](ARCHITECTURE.md) for the roadmap.
--- ## 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 ## Download
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.
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) ```powershell
python obd_reader.py COM5 --dash crank # cranking preset: ICP / FICM main / batt / RPM (fastest) # Windows
python obd_reader.py COM5 --dash full # every PID (Get-FileHash OBDash-windows.exe -Algorithm SHA256).Hash.ToLower()
python obd_reader.py COM5 --dash crank --dash-log crank.csv # + write a CSV while you watch Get-Content OBDash-windows.exe.sha256 # compare the two
``` ```
**No-start use:** run `--dash crank`, then crank. A healthy 6.0 builds ### Unsigned-binary warnings (expected for open source)
**~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.
Note: the FICM PIDs (`09xx`) are `[DOC]` (not yet confirmed on this truck); if The binaries aren't code-signed, so the OS will warn on first launch. They're safe
they read `--`, they auto-drop after a few frames so the refresh rate stays up. — 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 (Code signing removes these warnings but costs money / a hardware token; see the
for codes), then run the tool. 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 ```bash
**not** read Ford-enhanced diesel PIDs (ICP, FICM main/sync voltage, IPR%) — those need pip install -r requirements-gui.txt
FORScan. For FICM/ICP numbers, measure at the FICM with a meter, or use FORScan when it's python run_gui.py # tick "Mock" + Connect to explore with no adapter
available. Default baud is 38400 (measured on the CH340 adapter); try 9600 if you get garbage. ```
## 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

+103 -11
View File
@@ -9,6 +9,7 @@ import time
from obdcore import (PidRegistry, DtcDatabase, TimeSeriesStore, PollScheduler, from obdcore import (PidRegistry, DtcDatabase, TimeSeriesStore, PollScheduler,
CsvRecorder, load_default, load_profile) CsvRecorder, load_default, load_profile)
from obdcore.mock import MockLink 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 # default poll rates (Hz) -- fast for the no-start metrics, slower for the rest
FAST = {"ICP", "FICM_M", "RPM"} FAST = {"ICP", "FICM_M", "RPM"}
@@ -34,6 +35,25 @@ class Controller:
self.sched = None self.sched = None
self.t0 = None self.t0 = None
self.connected = False 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): def load_profile(self, path):
"""Switch the active vehicle profile (only allowed while disconnected).""" """Switch the active vehicle profile (only allowed while disconnected)."""
@@ -41,21 +61,49 @@ class Controller:
self.reg = PidRegistry(self.profile) self.reg = PidRegistry(self.profile)
self.dtcdb = DtcDatabase(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: if mock:
self.link = MockLink(clock=time.time) self.link = MockLink(clock=time.time)
else: else:
from obdcore.link import ElmLink # imported lazily (needs pyserial) from obdcore.link import ElmLink # imported lazily (needs pyserial)
self.link = ElmLink(port, baud) c = conn or {"kind": "serial", "port": port, "baud": baud}
self.link.init() kind = c.get("kind", "serial")
ok = self.link.connect() if kind == "wifi":
try: self.link = ElmLink.tcp(c["host"], c.get("port", 35000))
self.link.fast_timing(True) 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
except Exception: except Exception:
pass try:
self.sched = PollScheduler(self.link, self.reg, self.store, clock=time.time) 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.t0 = time.time()
self.connected = True 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 return ok
def hz_for(self, key): def hz_for(self, key):
@@ -80,9 +128,10 @@ class Controller:
self.store.recorder = CsvRecorder(path) self.store.recorder = CsvRecorder(path)
def stop_record(self): def stop_record(self):
if self.store.recorder: rec = self.store.recorder
self.store.recorder.close() if rec:
self.store.recorder = None self.store.recorder = None # unhook first so the poll thread stops writing
rec.close()
def now(self): def now(self):
return (time.time() - self.t0) if self.t0 else 0.0 return (time.time() - self.t0) if self.t0 else 0.0
@@ -112,6 +161,49 @@ class Controller:
Returns True if the ECU acknowledged.""" Returns True if the ECU acknowledged."""
return bool(self._oneoff(lambda: self.link.clear_dtcs())) 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): def stop(self):
if self.sched: if self.sched:
self.sched.stop() self.sched.stop()
+319 -14
View File
@@ -80,6 +80,16 @@ class MainWindow(QtWidgets.QMainWindow):
"Read stored / pending / permanent trouble codes") "Read stored / pending / permanent trouble codes")
self.clear_dtc_act = self._act(diagm, "Clear Codes…", self._clear_codes, self.clear_dtc_act = self._act(diagm, "Clear Codes…", self._clear_codes,
"Erase stored codes + freeze frame (mode 04)") "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") viewm = mb.addMenu("&View")
self.view_graph = self._act(viewm, "Graph View", lambda: self._set_view(0), 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_graph.setChecked(True)
self.view_gauge = self._act(viewm, "Gauge View", lambda: self._set_view(2), self.view_gauge = self._act(viewm, "Gauge View", lambda: self._set_view(2),
checkable=True) checkable=True)
self.view_trip = self._act(viewm, "Trip / Performance", lambda: self._set_view(3),
checkable=True)
viewm.addSeparator() viewm.addSeparator()
self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock, self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock,
checkable=True) checkable=True)
@@ -147,16 +159,44 @@ class MainWindow(QtWidgets.QMainWindow):
tb = QtWidgets.QToolBar("Connection") tb = QtWidgets.QToolBar("Connection")
tb.setMovable(False) tb.setMovable(False)
self.addToolBar(tb) self.addToolBar(tb)
tb.addWidget(QtWidgets.QLabel(" Port "))
self.port_combo = QtWidgets.QComboBox() self.conn_kind = QtWidgets.QComboBox()
self.port_combo.setMinimumWidth(180) 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() 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) b = QtWidgets.QToolButton(); b.setText(""); b.clicked.connect(self._refresh_ports)
tb.addWidget(b) self._serial_w.append(tb.addWidget(b))
tb.addWidget(QtWidgets.QLabel(" Baud ")) self._serial_w.append(tb.addWidget(QtWidgets.QLabel(" Baud ")))
self.baud_edit = QtWidgets.QLineEdit("38400"); self.baud_edit.setFixedWidth(70) self.baud_edit = QtWidgets.QLineEdit("38400"); self.baud_edit.setFixedWidth(64)
tb.addWidget(self.baud_edit) 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.mock_chk = QtWidgets.QCheckBox("Mock"); tb.addWidget(self.mock_chk)
self.connect_btn = QtWidgets.QPushButton("Connect") self.connect_btn = QtWidgets.QPushButton("Connect")
self.connect_btn.clicked.connect(self._toggle_connect) self.connect_btn.clicked.connect(self._toggle_connect)
@@ -165,6 +205,35 @@ class MainWindow(QtWidgets.QMainWindow):
self._preset_tb = tb self._preset_tb = tb
self._preset_sep = tb.addSeparator() self._preset_sep = tb.addSeparator()
self._preset_buttons = [] 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): def _rebuild_preset_buttons(self):
for b in self._preset_buttons: for b in self._preset_buttons:
@@ -332,6 +401,160 @@ class MainWindow(QtWidgets.QMainWindow):
"Cleared. No codes on re-read.") "Cleared. No codes on re-read.")
self.status.showMessage("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) ---------- # ---------- center (graph + table stack) ----------
def _build_center(self): def _build_center(self):
self.stack = QtWidgets.QStackedWidget() self.stack = QtWidgets.QStackedWidget()
@@ -366,9 +589,47 @@ class MainWindow(QtWidgets.QMainWindow):
self.gauges = GaugeGrid() self.gauges = GaugeGrid()
self.stack.addWidget(self.gauges) self.stack.addWidget(self.gauges)
# trip / performance page (center index 3)
self.stack.addWidget(self._build_trip_page())
self.setCentralWidget(self.stack) self.setCentralWidget(self.stack)
self._apply_theme() 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): def _graph(self):
"""The active graph widget (multi-axis unless Normalize is on).""" """The active graph widget (multi-axis unless Normalize is on)."""
return self.single if self.norm_chk.isChecked() else self.multi return self.single if self.norm_chk.isChecked() else self.multi
@@ -461,16 +722,28 @@ class MainWindow(QtWidgets.QMainWindow):
if not ports: if not ports:
self.port_combo.addItem("(no ports found)", None) self.port_combo.addItem("(no ports found)", None)
def _toggle_connect(self): def _conn_spec(self):
if self.ctl.connected: k = self.conn_kind.currentIndex()
self._disconnect(); return if k == 1:
port = self.port_combo.currentData() 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: try:
baud = int(self.baud_edit.text()) baud = int(self.baud_edit.text())
except ValueError: except ValueError:
baud = 38400 baud = 38400
return {"kind": "serial", "port": self.port_combo.currentData(), "baud": baud}
def _toggle_connect(self):
if self.ctl.connected:
self._disconnect(); return
try: 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: except Exception as e:
QtWidgets.QMessageBox.critical(self, "Connect failed", str(e)); return QtWidgets.QMessageBox.critical(self, "Connect failed", str(e)); return
self.ctl.start(); self.timer.start() self.ctl.start(); self.timer.start()
@@ -502,6 +775,17 @@ class MainWindow(QtWidgets.QMainWindow):
b.setEnabled(False) b.setEnabled(False)
self.status.showMessage("Disconnected.") 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 ---------- # ---------- PID selection ----------
def _apply_preset(self, name): def _apply_preset(self, name):
if not self.ctl.connected: if not self.ctl.connected:
@@ -606,6 +890,7 @@ class MainWindow(QtWidgets.QMainWindow):
self.view_graph.setChecked(idx == 0) self.view_graph.setChecked(idx == 0)
self.view_table.setChecked(idx == 1) self.view_table.setChecked(idx == 1)
self.view_gauge.setChecked(idx == 2) self.view_gauge.setChecked(idx == 2)
self.view_trip.setChecked(idx == 3)
def _toggle_pid_dock(self): def _toggle_pid_dock(self):
self.pid_dock.setVisible(self.show_pids.isChecked()) self.pid_dock.setVisible(self.show_pids.isChecked())
@@ -727,6 +1012,8 @@ class MainWindow(QtWidgets.QMainWindow):
def _tick(self): def _tick(self):
if not self.ctl.connected: if not self.ctl.connected:
if getattr(self.ctl, "poll_error", None) is not None:
self._on_link_lost(self.ctl.poll_error)
return return
self.tree.blockSignals(True) self.tree.blockSignals(True)
for key, it in self._items.items(): 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, 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.table.item(r, 4).setText("--" if dhi is None else f"{dhi:g}")
self.tree.blockSignals(False) 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: for key in self.curves:
p = self.ctl.reg.get(key) p = self.ctl.reg.get(key)
lo, hi = self.ctl.store.minmax(key) lo, hi = self.ctl.store.minmax(key)
self.gauges.set_value(key, self._dval(p, self.ctl.store.latest(key)), self.gauges.set_value(key, self._dval(p, self.ctl.store.latest(key)),
peak=self._dval(p, hi)) peak=self._dval(p, hi))
elif idx == 3: # trip / performance view
self._update_trip_page(spd, maf)
else: else:
self._redraw_curves() 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): def closeEvent(self, ev):
try: try:
self.timer.stop() 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. """Safe formula evaluator for vehicle-profile PID scaling.
Profiles are community-contributed data, so decode formulas must NOT be able to Profiles are community-contributed data, so decode formulas must NOT be able to
execute arbitrary code. Formulas are arithmetic expressions over named execute arbitrary code -- OR exhaust CPU/memory. Formulas are arithmetic
variables -- the de-facto OBD convention used by Torque / FORScan / ScanGauge: 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, ... 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" 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" e.g. "MAP - BARO"
Only numeric literals, the named variables, arithmetic/bitwise operators, and a Only numeric literals, the named variables, arithmetic/bitwise operators, and a
small whitelist of functions are allowed. No names, attributes, subscripts, small whitelist of functions are allowed. Anything else raises FormulaError at
comprehensions, or calls outside the whitelist -- anything else raises compile time. To stop a hostile profile from freezing the acquisition thread
FormulaError at compile time, so a bad/hostile profile fails loudly on load. 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 ast
import operator 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, _FUNCS = {"min": min, "max": max, "abs": abs, "round": round,
"int": int, "float": float} "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): class FormulaError(ValueError):
pass 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): if isinstance(node, ast.Expression):
return _validate(node.body, allowed) return _validate(node.body, allowed, depth + 1)
if isinstance(node, ast.BinOp): if isinstance(node, ast.BinOp):
if type(node.op) not in _BIN: if type(node.op) not in _BIN:
raise FormulaError(f"operator not allowed: {type(node.op).__name__}") raise FormulaError(f"operator not allowed: {type(node.op).__name__}")
_validate(node.left, allowed) _validate(node.left, allowed, depth + 1)
_validate(node.right, allowed) _validate(node.right, allowed, depth + 1)
return return
if isinstance(node, ast.UnaryOp): if isinstance(node, ast.UnaryOp):
if type(node.op) not in _UNARY: if type(node.op) not in _UNARY:
raise FormulaError(f"unary op not allowed: {type(node.op).__name__}") raise FormulaError(f"unary op not allowed: {type(node.op).__name__}")
_validate(node.operand, allowed) _validate(node.operand, allowed, depth + 1)
return return
if isinstance(node, ast.Constant): if isinstance(node, ast.Constant):
if not isinstance(node.value, (int, float)) or isinstance(node.value, bool): if not isinstance(node.value, (int, float)) or isinstance(node.value, bool):
@@ -61,16 +73,42 @@ def _validate(node, allowed):
if node.keywords: if node.keywords:
raise FormulaError("keyword args not allowed") raise FormulaError("keyword args not allowed")
for a in node.args: for a in node.args:
_validate(a, allowed) _validate(a, allowed, depth + 1)
return return
raise FormulaError(f"expression not allowed: {type(node).__name__}") 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): def _eval(node, names):
if isinstance(node, ast.Expression): if isinstance(node, ast.Expression):
return _eval(node.body, names) return _eval(node.body, names)
if isinstance(node, ast.BinOp): 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): if isinstance(node, ast.UnaryOp):
return _UNARY[type(node.op)](_eval(node.operand, names)) return _UNARY[type(node.op)](_eval(node.operand, names))
if isinstance(node, ast.Constant): if isinstance(node, ast.Constant):
@@ -84,9 +122,11 @@ def _eval(node, names):
def compile_formula(expr, allowed_names): def compile_formula(expr, allowed_names):
"""Return fn(names_dict) -> number. Raises FormulaError on disallowed input.""" """Return fn(names_dict) -> number. Raises FormulaError on disallowed input."""
if len(expr) > MAX_EXPR_LEN:
raise FormulaError("formula too long")
try: try:
tree = ast.parse(expr, mode="eval") tree = ast.parse(expr, mode="eval")
except SyntaxError as e: except (SyntaxError, ValueError, RecursionError) as e:
raise FormulaError(f"bad formula {expr!r}: {e}") raise FormulaError(f"bad formula {expr!r}: {e}")
allowed = set(allowed_names) allowed = set(allowed_names)
_validate(tree, allowed) _validate(tree, allowed)
+88 -17
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}" return f"{_LETTER[(b1 >> 6) & 3]}{(b1 >> 4) & 3}{b1 & 0xF:X}{b2:02X}"
_HEX = "0123456789ABCDEFabcdef"
def _line_bytes(ln): def _line_bytes(ln):
ln = ln.replace(" ", "") ln = ln.replace(" ", "")
if len(ln) >= 2 and ln[1] == ":" and ln[0] in "0123456789": if len(ln) >= 2 and ln[1] == ":" and ln[0] in _HEX:
ln = ln[2:] # drop CAN multiframe index "N:" ln = ln[2:] # drop CAN multiframe index "N:" (0-F, cycles)
if not ln or any(c not in "0123456789ABCDEFabcdef" for c in ln): if not ln or any(c not in _HEX for c in ln):
return [] return []
return [int(ln[i:i + 2], 16) for i in range(0, len(ln) - 1, 2)] 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): def parse_dtcs(lines, svc, is_can):
pairs = [] pairs = []
if is_can: if is_can:
data = [b for ln in lines for b in _line_bytes(ln)] # Message-aware: multiple ECUs each reply "<svc> <count> <pairs...>", and
if svc in data: # a DTC's own first byte can equal svc (0x43 == C03xx), so we must NOT
data = data[data.index(svc) + 1:] # blind-scan the flattened stream for svc. A line whose payload starts
data = data[1:] if data else data # with svc begins a new ECU message (drop svc + count byte); an ISO-TP
pairs = data # 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: else:
for ln in lines: for ln in lines:
data = _line_bytes(ln) data = _line_bytes(ln)
@@ -74,25 +90,40 @@ def find_ports():
class ElmLink: class ElmLink:
PROMPT = b">" PROMPT = b">"
def __init__(self, port, baud=38400, verbose=False): def __init__(self, transport, verbose=False):
if serial is None: """transport: any object with write/read/reset_input_buffer/close.
raise RuntimeError("pyserial not installed (pip install pyserial)") Use the .serial() / .tcp() / .ble() factory helpers to build one."""
self.io = transport
self.verbose = verbose self.verbose = verbose
self.ser = serial.Serial(port, baud, timeout=0.2)
self.protocol = "?" self.protocol = "?"
time.sleep(0.3) 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 -- # -- low-level --
def cmd(self, s, settle=0.0, timeout=4.0): def cmd(self, s, settle=0.0, timeout=4.0):
self.ser.reset_input_buffer() self.io.reset_input_buffer()
self.ser.write((s + "\r").encode()) self.io.write((s + "\r").encode())
if settle: if settle:
time.sleep(settle) time.sleep(settle)
buf = bytearray() buf = bytearray()
deadline = time.time() + timeout deadline = time.time() + timeout
while time.time() < deadline: while time.time() < deadline:
chunk = self.ser.read(256) chunk = self.io.read(256)
if chunk: if chunk:
buf += chunk buf += chunk
if self.PROMPT in buf: if self.PROMPT in buf:
@@ -160,6 +191,11 @@ class ElmLink:
except ValueError: except ValueError:
return None 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 -- # -- DTCs --
def read_dtcs(self, mode, svc, timeout=5.0): def read_dtcs(self, mode, svc, timeout=5.0):
lines = self.cmd(mode, timeout=timeout) lines = self.cmd(mode, timeout=timeout)
@@ -172,8 +208,43 @@ class ElmLink:
data = self._bytes(lines) data = self._bytes(lines)
return 0x44 in data or ("OK" in "".join(lines).upper()) return 0x44 in data or ("OK" in "".join(lines).upper())
# -- 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:
out["values"].append((name, dec(payload), unit))
except Exception:
pass
return out
def close(self): def close(self):
try: try:
self.ser.close() self.io.close()
except Exception: except Exception:
pass pass
+32 -2
View File
@@ -42,10 +42,18 @@ class MockLink:
return None # everything else: no response return None # everything else: no response
def read_m01(self, pid, nbytes, timeout=0.6): def read_m01(self, pid, nbytes, timeout=0.6):
if pid == "0C": # RPM 0 at rest if pid == "0C": # RPM ~750 idle
return [0x00, 0x00] v = 750 * 4
return [(v >> 8) & 0xFF, v & 0xFF]
if pid == "05": # ECT 82C if pid == "05": # ECT 82C
return [122] 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 return None
def read_atrv(self, timeout=0.8): def read_atrv(self, timeout=0.8):
@@ -58,5 +66,27 @@ class MockLink:
def clear_dtcs(self): def clear_dtcs(self):
return True 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): def close(self):
pass 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 .formula import compile_formula
from .registry import Pid, Dtc from .registry import Pid, Dtc
from .actions import Action, ActionStep, validate_action
SCHEMA = 1 SCHEMA = 1
BYTE_VARS = [chr(65 + i) for i in range(8)] # A..H BYTE_VARS = [chr(65 + i) for i in range(8)] # A..H
@@ -36,6 +37,7 @@ class Profile:
dtcs: list dtcs: list
presets: dict presets: dict
path: str = None path: str = None
actions: list = None
@property @property
def name(self): 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"), 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", "")) no_start=x.get("no_start", False), causes=x.get("causes", ""))
for x in raw.get("dtcs", [])] 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, 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): 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, "dtcs": [{"code": d.code, "desc": d.desc, "system": d.system,
"no_start": d.no_start, "causes": d.causes} for d in profile.dtcs], "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: with open(path, "w") as f:
json.dump(out, f, indent=2) json.dump(out, f, indent=2)
return path return path
+25 -2
View File
@@ -81,12 +81,35 @@ class PidRegistry:
return list(self.presets.keys()) 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: class DtcDatabase:
def __init__(self, profile): 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): 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): def all(self):
return list(self._db.values()) return list(self._db.values())
+59 -12
View File
@@ -16,14 +16,19 @@ import time
class _OneOff: class _OneOff:
"""A single command to run once on the polling thread (DTC read/clear, """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.""" probe, etc). The submitter blocks on `done` until the thread has run it.
__slots__ = ("fn", "done", "result", "error") `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): def __init__(self, fn):
self.fn = fn self.fn = fn
self.done = threading.Event() self.done = threading.Event()
self.result = None self.result = None
self.error = None self.error = None
self.cancelled = False
self.started = False
self.lock = threading.Lock()
class _Sub: class _Sub:
@@ -39,13 +44,14 @@ class _Sub:
class PollScheduler: class PollScheduler:
def __init__(self, link, registry, store, clock=time.time, dead_after=4, 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.link = link
self.reg = registry self.reg = registry
self.store = store self.store = store
self.clock = clock self.clock = clock
self.dead_after = dead_after self.dead_after = dead_after
self.revive_every = revive_every self.revive_every = revive_every
self.on_error = on_error # called(exc) if the poll thread dies
self._subs = {} self._subs = {}
self._lock = threading.Lock() self._lock = threading.Lock()
self._thread = None self._thread = None
@@ -102,6 +108,10 @@ class PollScheduler:
job = self._oneoffs.get_nowait() job = self._oneoffs.get_nowait()
except queue.Empty: except queue.Empty:
return return
with job.lock: # a timed-out submitter may have cancelled
if job.cancelled:
continue
job.started = True
try: try:
job.result = job.fn() job.result = job.fn()
except Exception as e: # hand the failure back except Exception as e: # hand the failure back
@@ -109,17 +119,40 @@ class PollScheduler:
finally: finally:
job.done.set() 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): def run_oneoff(self, fn, timeout=8.0):
"""Enqueue `fn` to run once on the polling thread and block for its """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 result (or re-raise its exception). When no live polling thread is
running, the job is drained inline on the caller -- still serialized servicing the queue, the job is drained inline on the caller -- still
against tick(), and safe because nothing else is touching the link.""" 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) job = _OneOff(fn)
self._oneoffs.put(job) 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() self._drain_oneoffs()
if not job.done.wait(timeout): 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: if job.error is not None:
raise job.error raise job.error
return job.result return job.result
@@ -167,16 +200,30 @@ class PollScheduler:
self._thread.start() self._thread.start()
def _loop(self): def _loop(self):
while self._running: try:
n = self.tick() while self._running:
if n == 0: n = self.tick()
time.sleep(0.005) # nothing due; yield 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): def stop(self):
self._running = False self._running = False
if self._thread: if self._thread:
self._thread.join(timeout=2.0) self._thread.join(timeout=2.0)
self._thread = None self._thread = None
self._fail_pending_oneoffs(RuntimeError("scheduler stopped"))
def _is_derived(reg, key): def _is_derived(reg, key):
+6
View File
@@ -128,13 +128,19 @@ class CsvRecorder:
self._f = open(path, "w") self._f = open(path, "w")
self._f.write("t,key,value\n") self._f.write("t,key,value\n")
self._lock = threading.Lock() self._lock = threading.Lock()
self._closed = False
def write(self, key, t, v): def write(self, key, t, v):
with self._lock: 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") self._f.write(f"{t:.3f},{key},{'' if v is None else v}\n")
def close(self): def close(self):
with self._lock: with self._lock:
if self._closed:
return
self._closed = True
self._f.close() 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 flags drive-disabling faults (shown bold red). Include generic `P0xxx` plus
manufacturer-specific `P1xxx` you can source. 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 ## 8. Rules for authors / agents
- **Standard Mode-01 PIDs are the reliable backbone** — include the ones this - **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-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 | | `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) | | `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 | | `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 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": "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"} {"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) # GUI dependencies (cross-platform: Windows / macOS / Linux, incl. Apple Silicon)
# pip install -r requirements-gui.txt # pip install -r requirements-gui.txt
# python run_gui.py # python run_gui.py
PySide6>=6.6 # Upper bounds guard the release binaries against a surprise breaking major bump
pyqtgraph>=0.13 # while still resolving to wheels across the range. (bleak is optional — BLE only.)
numpy>=1.24 PySide6>=6.6,<7
pyserial>=3.5 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") 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(): def test_registry_decoders_match_truck_bytes():
reg = PidRegistry(load_default()) reg = PidRegistry(load_default())
cases = { cases = {
@@ -138,6 +156,7 @@ def test_record_replay_roundtrip(tmp_path=None):
if __name__ == "__main__": if __name__ == "__main__":
for fn in [test_profiles_load_and_validate, test_formula_is_sandboxed, 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_registry_decoders_match_truck_bytes, test_crank_ramp_and_peak,
test_derived_boost_channel, test_dead_pid_parks_and_revives, test_derived_boost_channel, test_dead_pid_parks_and_revives,
test_record_replay_roundtrip]: 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")