Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b0ecc96e7 | |||
| 39fcf3fb55 | |||
| fa7225d6dc | |||
| 23c92018c1 | |||
| b5e0c96763 | |||
| 0f029b724a | |||
| d435384b58 | |||
| 74bfa2e146 | |||
| 7bda758f88 | |||
| 6548cf7fbe | |||
| 4a4daf3fa0 | |||
| 6c1ee0c81d | |||
| 310d5a3497 |
@@ -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 }}
|
||||||
|
|||||||
@@ -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 1–2 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
|
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
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 1–2 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 (2003–2007) — 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 <year make model> 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).
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
+103
-11
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 1996–2006 vehicles were built from web research (standard
|
Profiles for the four 1996–2006 vehicles were built from web research (standard
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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."}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user