Add Tools/Diagnostics: thread-safe DTC read/clear + Diagnostics panel
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
This commit is contained in:
@@ -9,10 +9,23 @@ periodically revived so they don't starve the sample rate.
|
||||
Acquisition runs on a background thread; tick() is also public so tests can
|
||||
drive it deterministically with a fake clock (no threads, no sleeps).
|
||||
"""
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
class _OneOff:
|
||||
"""A single command to run once on the polling thread (DTC read/clear,
|
||||
probe, etc). The submitter blocks on `done` until the thread has run it."""
|
||||
__slots__ = ("fn", "done", "result", "error")
|
||||
|
||||
def __init__(self, fn):
|
||||
self.fn = fn
|
||||
self.done = threading.Event()
|
||||
self.result = None
|
||||
self.error = None
|
||||
|
||||
|
||||
class _Sub:
|
||||
__slots__ = ("key", "period", "next_due", "fails", "active")
|
||||
|
||||
@@ -38,6 +51,7 @@ class PollScheduler:
|
||||
self._thread = None
|
||||
self._running = False
|
||||
self._last_revive = 0.0
|
||||
self._oneoffs = queue.Queue()
|
||||
|
||||
# -- subscription management --
|
||||
def set_subscriptions(self, specs):
|
||||
@@ -78,8 +92,41 @@ class PollScheduler:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# -- one-off commands (thread-safe, serialized onto the polling thread) --
|
||||
def _drain_oneoffs(self):
|
||||
"""Run every queued one-off on the *calling* thread. Invoked at the top
|
||||
of tick() so one-offs interleave with polling on the same thread that
|
||||
owns the serial link -- never concurrently with a PID read."""
|
||||
while True:
|
||||
try:
|
||||
job = self._oneoffs.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
try:
|
||||
job.result = job.fn()
|
||||
except Exception as e: # hand the failure back
|
||||
job.error = e
|
||||
finally:
|
||||
job.done.set()
|
||||
|
||||
def run_oneoff(self, fn, timeout=8.0):
|
||||
"""Enqueue `fn` to run once on the polling thread and block for its
|
||||
result (or re-raise its exception). When the scheduler thread isn't
|
||||
running, the job is drained inline on the caller -- still serialized
|
||||
against tick(), and safe because nothing else is touching the link."""
|
||||
job = _OneOff(fn)
|
||||
self._oneoffs.put(job)
|
||||
if not self._running:
|
||||
self._drain_oneoffs()
|
||||
if not job.done.wait(timeout):
|
||||
raise TimeoutError("one-off command timed out")
|
||||
if job.error is not None:
|
||||
raise job.error
|
||||
return job.result
|
||||
|
||||
def tick(self, now=None):
|
||||
"""Read all due PIDs once. Returns number of PIDs read."""
|
||||
self._drain_oneoffs() # one-offs first, same thread
|
||||
now = self.clock() if now is None else now
|
||||
if now - self._last_revive >= self.revive_every:
|
||||
with self._lock:
|
||||
|
||||
Reference in New Issue
Block a user