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:
2026-06-30 14:53:57 -04:00
parent f3f0bf2a77
commit 0fea0908c8
4 changed files with 374 additions and 0 deletions
+33
View File
@@ -15,6 +15,14 @@ FAST = {"ICP", "FICM_M", "RPM"}
DEFAULT_HZ = 2
FAST_HZ = 5
# DTC services: result-bucket label, request mode string, response service byte
# (mode "03"->0x43 stored, "07"->0x47 pending, "0A"->0x4A permanent)
DTC_SERVICES = (
("stored", "03", 0x43),
("pending", "07", 0x47),
("permanent", "0A", 0x4A),
)
class Controller:
def __init__(self):
@@ -79,6 +87,31 @@ class Controller:
def now(self):
return (time.time() - self.t0) if self.t0 else 0.0
# -- diagnostics (DTCs) --------------------------------------------------
# All link access goes through the scheduler's one-off path when a
# scheduler exists, so a DTC read/clear never races the polling thread for
# the serial link. When disconnected (no scheduler), call the link direct.
def _oneoff(self, fn, timeout=8.0):
if self.sched is not None:
return self.sched.run_oneoff(fn, timeout=timeout)
if self.link is not None:
return fn()
raise RuntimeError("not connected")
def read_dtcs(self):
"""Read stored (03), pending (07) and permanent (0A) DTCs.
Returns {"stored": [...], "pending": [...], "permanent": [...]}."""
out = {}
for label, mode, svc in DTC_SERVICES:
out[label] = self._oneoff(
lambda m=mode, s=svc: self.link.read_dtcs(m, s)) or []
return out
def clear_dtcs(self):
"""Mode 04: clear stored+pending codes and freeze frame.
Returns True if the ECU acknowledged."""
return bool(self._oneoff(lambda: self.link.clear_dtcs()))
def stop(self):
if self.sched:
self.sched.stop()