- A transport exception in the poll loop killed the thread silently, leaving the
GUI on a frozen 'Connected' dashboard and blocking run_oneoff callers for the
full timeout. _loop now catches it -> stops, fails pending one-offs with the
real error, and calls an on_error callback. Controller wires on_error to flag
the connection dead; the GUI detects it in _tick and tears down with a
'Connection lost' dialog.
- A run_oneoff that timed out left its job queued, so it executed LATER on the
shared link -- a ghost/duplicate vehicle command. Jobs now carry
cancelled/started flags under a lock; on timeout a not-yet-started job is
cancelled (skipped by _drain_oneoffs), and a started one reports 'still
running -- do NOT retry'. stop() also frees stranded submitters.
- tests/test_scheduler.py: cancel-on-timeout, freed-on-death, loop-survives.
Closes#8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
Untrusted profiles could bypass the confirmation and responses were mis-parsed:
- effective_risk(action): risk is now DERIVED from the actual service IDs the
steps send — any write/actuator/reset/transfer SID (2F/31/11/14/2E/27/34-37/…)
forces 'danger'; unknown SID / non-default session / security block force
'caution'. A profile can only RAISE risk, never label a reflash 'safe'. GUI
gates the confirmation (and the risk badge) on this derived value.
- Response checks use CONTIGUOUS subsequence matching + a hard '7F <sid>'
negative-response guard, so an NRC data byte (e.g. 0x7E) can't false-pass as a
positive response; applied to session/security/step checks.
- 0x78 (responsePending) is treated as in-progress, not terminal failure.
- controller.run_action restores slow ELM timing for the run (0x78 window).
- Tests: risk-cannot-be-downgraded, NRC false-positive rejected, pending handled.
Closes#7
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
- obdcore/transport.py: pluggable byte transports -- SerialTransport,
TcpTransport (WiFi ELM327, stdlib socket), BleTransport (experimental, via
optional 'bleak'; background asyncio loop buffering notifications). ble_scan().
- ElmLink refactored onto a transport with .serial()/.tcp()/.ble() factories
(close/cmd now go through self.io); no behavior change for serial.
- Controller.connect(conn={kind:serial|wifi|ble,...}); GUI connection bar gains
a transport selector (Serial/USB/BT-SPP | WiFi host:port | Bluetooth LE + Scan).
- Classic-Bluetooth needs no new code (pairs as a serial port); WiFi needs no
extra deps; BLE is opt-in (bleak not bundled, so CI binaries keep building).
- tests/test_transport.py: drives ElmLink over a fake ELM TCP server end-to-end
(connect, RPM, readiness, VIN). All suites pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
- Diagnostics menu: Vehicle Info (VIN/cal/ECU), Emissions Readiness (I/M
monitors + MIL -> pass/fail), Freeze Frame (snapshot + capturing DTC).
All routed through the scheduler one-off path; dialogs, no docked panels.
- New Trip / Performance view (View menu, center page): live + average MPG,
trip distance/fuel/time, and 0-60 / 1/4-mile timers. The controller keeps
SPEED + MAF polled in the background and feeds TripComputer/PerformanceMeter
every tick, so trips accumulate regardless of the active view. Honest MAF
caveat shown for speed-density/diesel vehicles.
Validated headless against MockLink: VIN dialog, readiness dialog, freeze-frame
dialog, and the live trip page (28.8 mpg / distance accruing). All tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
Gauges:
- Optional per-metric warning zones (warn_hi/redline_hi/warn_lo/redline_lo) in
the profile schema; gauges draw colored redline/warn bands and color the
needle + readout by zone. Default neutral when unset (no false redline).
- Removed the value progress-arc fill (it dominated the dial / looked wrong) ->
clean tach face: bezel, ticks, numeric scale, needle, redline band, readout.
- Auto-derivation rejected: bad direction/threshold vary per metric, so zones
are config (with a sensible neutral default).
Units:
- New Units menu: Temperature C / F. Converts gauges, graph, table, and PID
browser (values, scale, zones, unit labels) at display time; data stays C.
Ford 6.0 profile: zones for ICP (red<500), FICM (red<40/amber40-48/green48+),
ECT/EOT (high redline), RPM (redline 3800), boost, battery; tightened FICM
(38-52) and battery (9-15) ranges so redline bands land sensibly.
Docs: profiles/PROFILE_SPEC.md -- canonical, AI-agent-ready profile spec
(schema, formula language, zones, confidence, rules); README points to it.
Validated headless: zones parse/classify, F conversion (112C->233.6F, zones
converted), gauges render; obdcore + diagnostics tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
Rename: the app is vehicle-agnostic, so 'ford-obd' was wrong. Rebranded all
code/docs/profile authors to OBDash; Gitea repo renamed justin/ford-obd ->
justin/obdash (remote + description updated). Ford the make and the
ford-6.0-powerstroke profile are unchanged (that vehicle really is a Ford).
Multi-axis upgrade (per request):
- MultiAxisPlot now gives each METRIC its own Y axis, each axis colored to
match its line; the primary metric owns the LEFT axis, others stack right.
- Click a line to promote it to the left axis (sigClicked -> set_primary).
- Cleaner teardown (no removeItem warnings); axis label no longer doubles the
unit; Normalize round-trips.
Validated headless: colored per-metric axes, promote-to-left, gauge view,
normalize toggle, profile switch; obdcore + diagnostics tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
Diagnostics: removed the always-on right-side Diagnostics dock (it ate screen
space on every view). Read Codes now opens a modal dialog listing codes
grouped stored/pending/permanent with descriptions (no-start codes bold red);
Clear Codes keeps its confirmation, and on re-read pops a dialog only if codes
returned. Dropped the View > Show Diagnostics Panel toggle.
Gauges: replaced the plain progress-arc with a proper automotive dial -- round
face + bezel, major/minor tick marks, a numeric scale (rounded labels), a red
needle + center hub, a colored rim arc for the value, and an amber peak tick.
Digital readout + name/unit below.
Validated headless: no Diagnostics dock; Read Codes opens the Trouble Codes
dialog; gauge view renders. obdcore + diagnostics tests still pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
gui/widgets.py:
- MultiAxisPlot -- overlay with one Y axis PER UNIT (psi/V/rpm/C/%), linked
ViewBoxes on X, so mixed-scale signals are readable at true values
(base left axis + up to 4 right axes).
- SinglePlot -- one shared axis for the Normalize (% of range) mode.
- ArcGauge -- 270deg arc gauge with peak tick + numeric readout, own dark bg.
- GaugeGrid -- scrollable grid of gauges.
gui/main.py:
- Graph page is now a multi-axis/single-axis sub-stack; Normalize toggles
between true multi-axis (raw) and single-axis (%). curves map key->color;
plot ops route to the active graph widget.
- Gauge View menu enabled (3rd center page); gauges update on tick with peak.
- Theme applies to both plot widgets; profile switch clears graphs/gauges.
Fix: ArcGauge QPen built via setCapStyle (the QPen(...cap=...) kwarg segfaults
PySide6). Validated headless: driving preset -> 6 unit groups across 5 axes,
gauge view renders, normalize round-trips, profile-switch clears cleanly.
obdcore + diagnostics tests still pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
The polling thread owns the ELM327, so reading/clearing trouble codes from
the GUI thread would race PID reads and corrupt the stream. Add a one-off
command path that serializes ad-hoc link work onto the polling thread.
obdcore/scheduler.py:
- PollScheduler.run_oneoff(fn, timeout) enqueues a callable (queue.Queue +
threading.Event) and blocks for its result, re-raising the callable's
exception. tick() drains queued one-offs at its very top, so they run on
the same thread that does PID reads -- never concurrently. When the
scheduler thread isn't running, the job is drained inline on the caller
(still serialized vs tick(), safe because nothing else touches the link).
gui/controller.py:
- Controller.read_dtcs() -> {"stored","pending","permanent"} (modes 03/07/0A,
svc 0x43/0x47/0x4A) and clear_dtcs() -> bool. Both route through the
scheduler one-off when a scheduler exists, else call the link directly.
gui/main.py:
- Diagnostics menu (Read Codes / Clear Codes...) and a right-side QDockWidget
listing codes grouped Stored/Pending/Permanent. Each row is code +
description + system from DtcDatabase; no_start codes are flagged bold red.
- Clear is guarded by a confirmation warning (erases codes + freeze frame;
honest "the code comes right back" / permanent-codes-won't-clear tone from
run_clear in obd_reader.py). On confirm: clear, then re-read immediately and
show whatever returned, reporting active faults that came straight back.
tests/test_diagnostics.py:
- one-off returns its value, re-raises exceptions, is drained before a tick's
PID reads, and runs on a live background thread while polling continues.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
Vehicle data is now DATA, not code. PIDs/scaling/DTCs/presets live in
profiles/*.json; the app loads them at runtime, so it works across vehicles
and others can contribute profiles (open source).
Core:
- obdcore/formula.py: safe AST evaluator for scaling formulas (A/B/... byte
vars, Torque/FORScan convention). Only arithmetic/bitwise + min/max/abs/
round/int/float; names/attrs/arbitrary calls rejected at load -> a community
profile CANNOT execute code.
- obdcore/profile.py: load/save/list profiles; compiles each formula into a
decode callable. registry.py now profile-backed (PidRegistry/DtcDatabase
take a Profile); hardcoded Ford table removed.
- store.py: clear()/snapshot()/export_csv() for capture management.
Profiles:
- profiles/ford-6.0-powerstroke.json (27 PIDs, verified formulas, DTCs)
- profiles/generic-obd2.json (standard SAE Mode-01 base, any vehicle)
- profiles/README.md (schema + formula language + contributing)
GUI:
- Menu bar: File (new/record/export/replay capture, quit), Profile (switch/
load/import/reload/edit-JSON/export, live profile list), View (Graph/Table
views, gauges P2, toggle PID dock, normalize, light/dark theme), Help
(about/confidence legend/profile info).
- PID browser + presets rebuild on profile switch; added Table view; raw-JSON
profile editor dialog (validates schema+formulas before saving).
Tests: profiles load+compile, formula sandbox rejects hostile input, decoders
still match real truck bytes, crank/derived/dead-PID/replay -- all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs