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
128 lines
4.2 KiB
Python
128 lines
4.2 KiB
Python
"""Controller -- owns the obdcore link/registry/store/scheduler for the GUI.
|
|
|
|
Keeps all acquisition concerns out of the widgets. The GUI subscribes/
|
|
unsubscribes PIDs (== what's polled == what's plotted) and reads the store on
|
|
a timer; the scheduler thread does the serial work.
|
|
"""
|
|
import time
|
|
|
|
from obdcore import (PidRegistry, DtcDatabase, TimeSeriesStore, PollScheduler,
|
|
CsvRecorder, load_default, load_profile)
|
|
from obdcore.mock import MockLink
|
|
|
|
# default poll rates (Hz) -- fast for the no-start metrics, slower for the rest
|
|
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):
|
|
self.profile = load_default()
|
|
self.reg = PidRegistry(self.profile)
|
|
self.dtcdb = DtcDatabase(self.profile)
|
|
self.store = TimeSeriesStore()
|
|
self.link = None
|
|
self.sched = None
|
|
self.t0 = None
|
|
self.connected = False
|
|
|
|
def load_profile(self, path):
|
|
"""Switch the active vehicle profile (only allowed while disconnected)."""
|
|
self.profile = load_profile(path)
|
|
self.reg = PidRegistry(self.profile)
|
|
self.dtcdb = DtcDatabase(self.profile)
|
|
|
|
def connect(self, port=None, baud=38400, mock=False):
|
|
if mock:
|
|
self.link = MockLink(clock=time.time)
|
|
else:
|
|
from obdcore.link import ElmLink # imported lazily (needs pyserial)
|
|
self.link = ElmLink(port, baud)
|
|
self.link.init()
|
|
ok = self.link.connect()
|
|
try:
|
|
self.link.fast_timing(True)
|
|
except Exception:
|
|
pass
|
|
self.sched = PollScheduler(self.link, self.reg, self.store, clock=time.time)
|
|
self.t0 = time.time()
|
|
self.connected = True
|
|
return ok
|
|
|
|
def hz_for(self, key):
|
|
return FAST_HZ if key in FAST else DEFAULT_HZ
|
|
|
|
def subscribe(self, key):
|
|
if self.sched:
|
|
self.sched.subscribe(key, self.hz_for(key))
|
|
|
|
def unsubscribe(self, key):
|
|
if self.sched:
|
|
self.sched.unsubscribe(key)
|
|
|
|
def subscribed(self):
|
|
return set(self.sched.subscriptions()) if self.sched else set()
|
|
|
|
def start(self):
|
|
if self.sched:
|
|
self.sched.start()
|
|
|
|
def record(self, path):
|
|
self.store.recorder = CsvRecorder(path)
|
|
|
|
def stop_record(self):
|
|
if self.store.recorder:
|
|
self.store.recorder.close()
|
|
self.store.recorder = None
|
|
|
|
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()
|
|
self.sched = None
|
|
self.stop_record()
|
|
if self.link:
|
|
try:
|
|
self.link.fast_timing(False)
|
|
except Exception:
|
|
pass
|
|
self.link.close()
|
|
self.link = None
|
|
self.connected = False
|