f3f0bf2a77
Vehicle data is now DATA, not code. PIDs/scaling/DTCs/presets live in profiles/*.json; the app loads them at runtime, so it works across vehicles and others can contribute profiles (open source). Core: - obdcore/formula.py: safe AST evaluator for scaling formulas (A/B/... byte vars, Torque/FORScan convention). Only arithmetic/bitwise + min/max/abs/ round/int/float; names/attrs/arbitrary calls rejected at load -> a community profile CANNOT execute code. - obdcore/profile.py: load/save/list profiles; compiles each formula into a decode callable. registry.py now profile-backed (PidRegistry/DtcDatabase take a Profile); hardcoded Ford table removed. - store.py: clear()/snapshot()/export_csv() for capture management. Profiles: - profiles/ford-6.0-powerstroke.json (27 PIDs, verified formulas, DTCs) - profiles/generic-obd2.json (standard SAE Mode-01 base, any vehicle) - profiles/README.md (schema + formula language + contributing) GUI: - Menu bar: File (new/record/export/replay capture, quit), Profile (switch/ load/import/reload/edit-JSON/export, live profile list), View (Graph/Table views, gauges P2, toggle PID dock, normalize, light/dark theme), Help (about/confidence legend/profile info). - PID browser + presets rebuild on profile switch; added Table view; raw-JSON profile editor dialog (validates schema+formulas before saving). Tests: profiles load+compile, formula sandbox rejects hostile input, decoders still match real truck bytes, crank/derived/dead-PID/replay -- all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
165 lines
4.9 KiB
Python
165 lines
4.9 KiB
Python
"""Time-series store: per-channel ring buffers + min/max, with an optional
|
|
recorder for full-session capture/playback.
|
|
|
|
The acquisition thread pushes samples here; the GUI (or terminal) only reads.
|
|
This decoupling is what keeps the UI smooth while the ELM327 plods along at
|
|
~7-15 reads/sec. Nothing here touches serial or Qt -- pure data.
|
|
"""
|
|
import threading
|
|
from collections import deque
|
|
|
|
|
|
class Channel:
|
|
"""One PID's rolling history plus session min/max.
|
|
|
|
Lock-guarded so the acquisition thread can push while the GUI thread
|
|
reads series()/snapshots without a 'deque mutated during iteration' race.
|
|
"""
|
|
|
|
def __init__(self, key, maxlen=3600):
|
|
self.key = key
|
|
self.buf = deque(maxlen=maxlen) # (t, value); value may be None
|
|
self.lo = None
|
|
self.hi = None
|
|
self.last_t = None
|
|
self.last_v = None
|
|
self._lock = threading.Lock()
|
|
|
|
def push(self, t, v):
|
|
with self._lock:
|
|
self.buf.append((t, v))
|
|
self.last_t, self.last_v = t, v
|
|
if v is not None:
|
|
self.lo = v if self.lo is None else min(self.lo, v)
|
|
self.hi = v if self.hi is None else max(self.hi, v)
|
|
|
|
def reset_minmax(self):
|
|
with self._lock:
|
|
self.lo = self.hi = self.last_v
|
|
|
|
def series(self, since=None):
|
|
"""Return [(t, v), ...]; if since given, only samples with t >= since."""
|
|
with self._lock:
|
|
items = list(self.buf) # snapshot under lock
|
|
if since is None:
|
|
return items
|
|
return [(t, v) for (t, v) in items if t >= since]
|
|
|
|
|
|
class TimeSeriesStore:
|
|
"""Thread-safe collection of Channels keyed by PID key."""
|
|
|
|
def __init__(self, maxlen=3600):
|
|
self._ch = {}
|
|
self._maxlen = maxlen
|
|
self._lock = threading.Lock()
|
|
self.recorder = None # set to a recorder with .write(key, t, v)
|
|
|
|
def channel(self, key):
|
|
with self._lock:
|
|
c = self._ch.get(key)
|
|
if c is None:
|
|
c = Channel(key, self._maxlen)
|
|
self._ch[key] = c
|
|
return c
|
|
|
|
def push(self, key, t, v):
|
|
self.channel(key).push(t, v)
|
|
rec = self.recorder
|
|
if rec is not None:
|
|
rec.write(key, t, v)
|
|
|
|
def latest(self, key):
|
|
c = self._ch.get(key)
|
|
return None if c is None else c.last_v
|
|
|
|
def minmax(self, key):
|
|
c = self._ch.get(key)
|
|
return (None, None) if c is None else (c.lo, c.hi)
|
|
|
|
def reset_minmax(self, keys=None):
|
|
with self._lock:
|
|
for k, c in self._ch.items():
|
|
if keys is None or k in keys:
|
|
c.reset_minmax()
|
|
|
|
def keys(self):
|
|
with self._lock:
|
|
return list(self._ch.keys())
|
|
|
|
def clear(self):
|
|
"""Empty every channel's history + min/max (start a fresh capture)."""
|
|
with self._lock:
|
|
chans = list(self._ch.values())
|
|
for c in chans:
|
|
with c._lock:
|
|
c.buf.clear()
|
|
c.lo = c.hi = c.last_v = c.last_t = None
|
|
|
|
def snapshot(self):
|
|
"""Return {key: [(t, v), ...]} of all current channel history."""
|
|
with self._lock:
|
|
chans = dict(self._ch)
|
|
return {k: c.series() for k, c in chans.items()}
|
|
|
|
|
|
def export_csv(store, path):
|
|
"""Write a store's current buffers to a long-format CSV (t,key,value)."""
|
|
rows = []
|
|
for key, series in store.snapshot().items():
|
|
for t, v in series:
|
|
rows.append((t, key, v))
|
|
rows.sort(key=lambda r: r[0])
|
|
with open(path, "w") as f:
|
|
f.write("t,key,value\n")
|
|
for t, key, v in rows:
|
|
f.write(f"{t:.3f},{key},{'' if v is None else v}\n")
|
|
return path
|
|
|
|
|
|
class CsvRecorder:
|
|
"""Long-format session recorder: one row per sample (t,key,value).
|
|
|
|
Long format (vs wide) tolerates per-PID poll rates and PIDs appearing
|
|
mid-session. Replay re-pushes rows into a fresh store in t order.
|
|
"""
|
|
|
|
def __init__(self, path):
|
|
self._f = open(path, "w")
|
|
self._f.write("t,key,value\n")
|
|
self._lock = threading.Lock()
|
|
|
|
def write(self, key, t, v):
|
|
with self._lock:
|
|
self._f.write(f"{t:.3f},{key},{'' if v is None else v}\n")
|
|
|
|
def close(self):
|
|
with self._lock:
|
|
self._f.close()
|
|
|
|
|
|
def replay_csv(path, store):
|
|
"""Load a CsvRecorder file back into a store (for playback)."""
|
|
with open(path) as f:
|
|
next(f, None) # header
|
|
for line in f:
|
|
parts = line.rstrip("\n").split(",", 2)
|
|
if len(parts) != 3:
|
|
continue
|
|
t, key, v = parts
|
|
try:
|
|
t = float(t)
|
|
except ValueError:
|
|
continue
|
|
val = None if v == "" else float(v) if _is_num(v) else v
|
|
store.push(key, t, val)
|
|
return store
|
|
|
|
|
|
def _is_num(s):
|
|
try:
|
|
float(s)
|
|
return True
|
|
except ValueError:
|
|
return False
|