Files
obdash/obdcore/store.py
T
justin 39fcf3fb55 Fix #10 + #11: transport hardening + controller resource leaks
#10 transport (obdcore/transport.py):
- TcpTransport.read raises IOError on a real socket error or peer-close instead
  of swallowing it as a timeout, so a dead WiFi link surfaces (via the #8 poll
  handler) as 'connection lost' rather than a frozen dashboard.
- TcpTransport.reset_input_buffer drains at most 64 chunks — never spins forever.
- BleTransport closes the client + stops the event-loop thread on connect
  timeout (no leak), caps the notification buffer at 64 KiB, and close() is
  robust when only partially initialised.

#11 controller (gui/controller.py, obdcore/store.py):
- connect() closes the transport and nulls the link if init()/connect() raises,
  so a failed/retried connect doesn't orphan sockets/threads.
- stop_record() unhooks store.recorder BEFORE closing it, and CsvRecorder now
  has a 'closed' guard so a poll-thread write racing close() is a no-op instead
  of an I/O-on-closed-file crash.

Closes #10
Closes #11

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
2026-07-01 19:39:47 -04:00

171 lines
5.1 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()
self._closed = False
def write(self, key, t, v):
with self._lock:
if self._closed: # a poll-thread write racing close() is a no-op
return
self._f.write(f"{t:.3f},{key},{'' if v is None else v}\n")
def close(self):
with self._lock:
if self._closed:
return
self._closed = True
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