Scaffold obdcore (headless acquisition core) + ARCHITECTURE.md
Foundation for the PySide6 + pyqtgraph Windows GUI, shared with the terminal
tool. Pure data/IO -- no Qt, no curses.
obdcore/
link.py ElmLink -- ELM327 serial (Mode-01/22, ATRV, DTC read/clear)
mock.py MockLink -- synthetic crank for tests + GUI dev (no truck)
registry.py PidRegistry (verified Ford 6.0 PIDs + confidence) + DtcDatabase
scheduler.py PollScheduler -- prioritized round-robin polling, dead-PID park,
derived channels; tick() is fake-clock test-drivable
store.py TimeSeriesStore (ring buffers + min/max) + CsvRecorder/replay
Design centers on the ELM327 bandwidth limit (~7-15 reads/sec): the active
view subscribes PIDs at chosen rates; acquisition runs off the UI thread;
the GUI only reads the store. FICM_M (09D0) promoted to verified after the
2026-06-30 on-truck crank read (48.0V, intermittent).
tests/test_obdcore.py: decoders vs real truck bytes, crank ramp + peak,
derived BOOST, dead-PID park/revive, record/replay roundtrip -- all pass.
ARCHITECTURE.md: layers, data model, GUI plan, 6.0 stock-PID limits
(no EGT/oil-PSI), feature backlog, P0-P5 roadmap.
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:
@@ -0,0 +1,137 @@
|
||||
"""PollScheduler -- the prioritized acquisition engine.
|
||||
|
||||
The ELM327 is a one-request-at-a-time straw (~7-15 reads/sec total). This
|
||||
scheduler holds a subscription set (PID key -> target Hz) and, each tick,
|
||||
reads the PIDs that are due, round-robin, pushing timestamped samples into the
|
||||
TimeSeriesStore. Dead PIDs (4 consecutive no-responses) are parked and
|
||||
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 threading
|
||||
import time
|
||||
|
||||
|
||||
class _Sub:
|
||||
__slots__ = ("key", "period", "next_due", "fails", "active")
|
||||
|
||||
def __init__(self, key, period):
|
||||
self.key = key
|
||||
self.period = period
|
||||
self.next_due = 0.0
|
||||
self.fails = 0
|
||||
self.active = True
|
||||
|
||||
|
||||
class PollScheduler:
|
||||
def __init__(self, link, registry, store, clock=time.time, dead_after=4,
|
||||
revive_every=5.0):
|
||||
self.link = link
|
||||
self.reg = registry
|
||||
self.store = store
|
||||
self.clock = clock
|
||||
self.dead_after = dead_after
|
||||
self.revive_every = revive_every
|
||||
self._subs = {}
|
||||
self._lock = threading.Lock()
|
||||
self._thread = None
|
||||
self._running = False
|
||||
self._last_revive = 0.0
|
||||
|
||||
# -- subscription management --
|
||||
def set_subscriptions(self, specs):
|
||||
"""specs: iterable of (key, hz). Replaces the whole set."""
|
||||
with self._lock:
|
||||
self._subs = {k: _Sub(k, (1.0 / hz) if hz > 0 else 0.5) for k, hz in specs}
|
||||
|
||||
def subscribe(self, key, hz):
|
||||
with self._lock:
|
||||
self._subs[key] = _Sub(key, (1.0 / hz) if hz > 0 else 0.5)
|
||||
|
||||
def unsubscribe(self, key):
|
||||
with self._lock:
|
||||
self._subs.pop(key, None)
|
||||
|
||||
def subscriptions(self):
|
||||
with self._lock:
|
||||
return list(self._subs.keys())
|
||||
|
||||
# -- the core read of a single PID --
|
||||
def _read(self, p, frame_vals):
|
||||
if p.mode == "atrv":
|
||||
return self.link.read_atrv()
|
||||
if p.mode == "derived":
|
||||
vals = [frame_vals.get(d, self.store.latest(d)) for d in p.deps]
|
||||
if any(v is None for v in vals):
|
||||
return None
|
||||
try:
|
||||
return p.decode(vals)
|
||||
except Exception:
|
||||
return None
|
||||
raw = (self.link.read_m01(p.pid, p.nbytes) if p.mode == "01"
|
||||
else self.link.read_m22(p.pid))
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return p.decode(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def tick(self, now=None):
|
||||
"""Read all due PIDs once. Returns number of PIDs read."""
|
||||
now = self.clock() if now is None else now
|
||||
if now - self._last_revive >= self.revive_every:
|
||||
with self._lock:
|
||||
for s in self._subs.values():
|
||||
if not s.active:
|
||||
s.active, s.fails = True, 0
|
||||
self._last_revive = now
|
||||
|
||||
with self._lock:
|
||||
due = [s for s in self._subs.values() if s.active and now >= s.next_due]
|
||||
due.sort(key=lambda s: s.next_due)
|
||||
|
||||
frame_vals = {}
|
||||
# non-derived first so derived channels can use this frame's values
|
||||
order = sorted(due, key=lambda s: 1 if _is_derived(self.reg, s.key) else 0)
|
||||
for s in order:
|
||||
p = self.reg.get(s.key)
|
||||
if p is None:
|
||||
continue
|
||||
v = self._read(p, frame_vals)
|
||||
frame_vals[s.key] = v
|
||||
self.store.push(s.key, self.clock(), v)
|
||||
s.next_due = now + s.period
|
||||
if v is None:
|
||||
s.fails += 1
|
||||
if s.fails >= self.dead_after:
|
||||
s.active = False
|
||||
else:
|
||||
s.fails = 0
|
||||
return len(order)
|
||||
|
||||
# -- background thread --
|
||||
def start(self):
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def _loop(self):
|
||||
while self._running:
|
||||
n = self.tick()
|
||||
if n == 0:
|
||||
time.sleep(0.005) # nothing due; yield
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
|
||||
|
||||
def _is_derived(reg, key):
|
||||
p = reg.get(key)
|
||||
return bool(p and p.mode == "derived")
|
||||
Reference in New Issue
Block a user