0fea0908c8
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
149 lines
5.3 KiB
Python
149 lines
5.3 KiB
Python
"""Hardware-free tests for the Diagnostics (DTC) one-off command path.
|
|
|
|
The polling thread owns the serial link, so DTC reads/clears can't hit the link
|
|
directly -- they go through PollScheduler.run_oneoff, which serializes them onto
|
|
the same thread that does PID reads. These tests prove (a) a one-off returns
|
|
its callable's value (and re-raises its exceptions), (b) one-offs are drained at
|
|
the top of tick() without disturbing normal polling, and (c) the same path runs
|
|
on a live background thread while polling continues.
|
|
|
|
Run: python tests/test_diagnostics.py
|
|
"""
|
|
import os
|
|
import sys
|
|
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
|
|
|
|
|
|
class FakeClock:
|
|
def __init__(self):
|
|
self.t = 0.0
|
|
|
|
def __call__(self):
|
|
return self.t
|
|
|
|
def advance(self, dt):
|
|
self.t += dt
|
|
|
|
|
|
def _setup(specs):
|
|
clk = FakeClock()
|
|
reg = PidRegistry(load_default())
|
|
store = TimeSeriesStore()
|
|
link = MockLink(clock=clk)
|
|
sch = PollScheduler(link, reg, store, clock=clk)
|
|
sch.set_subscriptions(specs)
|
|
return clk, reg, store, link, sch
|
|
|
|
|
|
def test_oneoff_returns_value_and_polling_continues():
|
|
"""Single-threaded (disconnected-style) path: run_oneoff drains inline when
|
|
no background thread is running, and normal tick() polling is untouched."""
|
|
clk, reg, store, link, sch = _setup([("ICP", 5), ("FICM_M", 2)])
|
|
|
|
# A one-off returns exactly what its callable returns -- here, the MockLink's
|
|
# stored DTCs (mode 03 -> ["P0148"]).
|
|
stored = sch.run_oneoff(lambda: link.read_dtcs("03", 0x43))
|
|
assert stored == ["P0148"], stored
|
|
# Pending / permanent are empty on the mock.
|
|
assert sch.run_oneoff(lambda: link.read_dtcs("07", 0x47)) == []
|
|
assert sch.run_oneoff(lambda: link.read_dtcs("0A", 0x4A)) == []
|
|
# A clear one-off returns the link's ack.
|
|
assert sch.run_oneoff(lambda: link.clear_dtcs()) is True
|
|
|
|
# Normal polling still happens through tick().
|
|
for _ in range(20):
|
|
sch.tick()
|
|
clk.advance(0.05)
|
|
assert store.latest("ICP") is not None, "ICP should still be polled"
|
|
assert store.latest("FICM_M") == 48.0
|
|
print(" one-off returns value + tick() polling intact: OK")
|
|
|
|
|
|
def test_oneoff_exception_propagates():
|
|
clk, reg, store, link, sch = _setup([("ICP", 5)])
|
|
|
|
def boom():
|
|
raise ValueError("link blew up")
|
|
|
|
try:
|
|
sch.run_oneoff(boom)
|
|
raise AssertionError("expected ValueError to propagate")
|
|
except ValueError as e:
|
|
assert "blew up" in str(e)
|
|
print(" one-off re-raises callable's exception: OK")
|
|
|
|
|
|
def test_oneoff_drained_at_top_of_tick():
|
|
"""Enqueue a job without inline-draining (simulate the running scheduler),
|
|
then prove a single tick() drains+runs it before doing its PID reads."""
|
|
clk, reg, store, link, sch = _setup([("ICP", 5)])
|
|
sch._running = True # pretend the thread is alive
|
|
box = {"ran": False, "ticks_at_run": None}
|
|
|
|
def job():
|
|
box["ran"] = True
|
|
# store is still empty -> the one-off ran before any PID push this tick
|
|
box["ticks_at_run"] = store.latest("ICP")
|
|
return "done"
|
|
|
|
import threading
|
|
# Submit via the public API on a helper thread (it blocks waiting for the
|
|
# job), so the main thread can drive tick() to drain it.
|
|
result = {}
|
|
|
|
def submit():
|
|
result["val"] = sch.run_oneoff(job, timeout=2.0)
|
|
|
|
t = threading.Thread(target=submit)
|
|
t.start()
|
|
# wait until the job is queued, then drain it with one tick()
|
|
for _ in range(200):
|
|
if not sch._oneoffs.empty():
|
|
break
|
|
time.sleep(0.001)
|
|
sch.tick() # drains the one-off, then polls ICP
|
|
t.join(timeout=2.0)
|
|
|
|
assert box["ran"] is True
|
|
assert result.get("val") == "done"
|
|
assert box["ticks_at_run"] is None, "one-off must run before this tick's reads"
|
|
assert store.latest("ICP") is not None, "ICP polled in the same tick"
|
|
print(" one-off drained at top of tick(), before PID reads: OK")
|
|
|
|
|
|
def test_oneoff_on_live_polling_thread():
|
|
"""Realistic path: scheduler running on its own thread (real clock) while a
|
|
one-off DTC read is submitted from the 'GUI' thread and returns its value."""
|
|
clk = time.time
|
|
reg = PidRegistry(load_default())
|
|
store = TimeSeriesStore()
|
|
link = MockLink(clock=clk)
|
|
sch = PollScheduler(link, reg, store, clock=clk)
|
|
sch.set_subscriptions([("ICP", 50), ("FICM_M", 20)])
|
|
sch.start()
|
|
try:
|
|
time.sleep(0.05) # let it poll a few frames
|
|
stored = sch.run_oneoff(lambda: link.read_dtcs("03", 0x43), timeout=3.0)
|
|
assert stored == ["P0148"], stored
|
|
assert sch.run_oneoff(lambda: link.clear_dtcs(), timeout=3.0) is True
|
|
time.sleep(0.05)
|
|
finally:
|
|
sch.stop()
|
|
assert store.latest("ICP") is not None, "polling ran alongside the one-off"
|
|
print(" one-off on live polling thread, polling continued: OK")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
for fn in [test_oneoff_returns_value_and_polling_continues,
|
|
test_oneoff_exception_propagates,
|
|
test_oneoff_drained_at_top_of_tick,
|
|
test_oneoff_on_live_polling_thread]:
|
|
fn()
|
|
print("\nALL diagnostics TESTS PASS")
|