Files
obdash/gui/controller.py
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

220 lines
7.8 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
from obdcore.trip import TripComputer, PerformanceMeter
# 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
self.trip = TripComputer()
self.perf = PerformanceMeter()
self.speed_key = None # PID key for standard speed (mode 01 0D)
self.maf_key = None # PID key for standard MAF (mode 01 10)
def _find_std_keys(self):
"""Locate the speed/MAF PIDs (mode 01, pid 0D/10) by any key name."""
self.speed_key = self.maf_key = None
for p in self.reg.all():
if p.mode == "01" and p.pid.upper() == "0D":
self.speed_key = p.key
elif p.mode == "01" and p.pid.upper() == "10":
self.maf_key = p.key
def _on_poll_error(self, exc):
"""Called on the poll thread if it dies (transport failure). Flag the
connection dead so the GUI stops showing frozen 'Connected' data."""
self.poll_error = exc
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, conn=None):
"""conn: optional {'kind': 'serial'|'wifi'|'ble', ...}. Falls back to
serial(port, baud) for backward compatibility."""
if mock:
self.link = MockLink(clock=time.time)
else:
from obdcore.link import ElmLink # imported lazily (needs pyserial)
c = conn or {"kind": "serial", "port": port, "baud": baud}
kind = c.get("kind", "serial")
if kind == "wifi":
self.link = ElmLink.tcp(c["host"], c.get("port", 35000))
elif kind == "ble":
self.link = ElmLink.ble(c["address"])
else:
self.link = ElmLink.serial(c.get("port", port), c.get("baud", baud))
try: # don't leak the transport if handshake fails
if not mock:
self.link.init()
ok = self.link.connect()
try:
self.link.fast_timing(True)
except Exception:
pass
except Exception:
try:
self.link.close()
except Exception:
pass
self.link = None
raise
self.poll_error = None
self.sched = PollScheduler(self.link, self.reg, self.store, clock=time.time,
on_error=self._on_poll_error)
self.t0 = time.time()
self.connected = True
self.trip.reset()
self.perf = PerformanceMeter()
# keep speed + MAF polled in the background so trip/performance always run
self._find_std_keys()
if self.speed_key:
self.sched.subscribe(self.speed_key, 2)
if self.maf_key:
self.sched.subscribe(self.maf_key, 2)
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):
rec = self.store.recorder
if rec:
self.store.recorder = None # unhook first so the poll thread stops writing
rec.close()
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()))
# -- standard OBD services (via the one-off path) --
def read_vehicle_info(self):
return self._oneoff(lambda: self.link.read_vehicle_info())
def read_readiness(self):
return self._oneoff(lambda: self.link.read_readiness())
def read_freeze_frame(self):
return self._oneoff(lambda: self.link.read_freeze_frame())
# -- bi-directional / service actions --
def actions(self):
return self.profile.actions or []
def run_action(self, action):
from obdcore.actions import run_action
def go():
# actions/routines can take longer than the fast polling window and
# may reply 0x78 (pending) — restore slow ELM timing for the run
try:
self.link.fast_timing(False)
except Exception:
pass
try:
return run_action(action, self.link)
finally:
try:
self.link.fast_timing(True)
except Exception:
pass
return self._oneoff(go, timeout=25.0)
# -- trip / performance (fed from the live store each GUI tick) --
def update_trip(self):
spd = self.store.latest(self.speed_key) if self.speed_key else None
maf = self.store.latest(self.maf_key) if self.maf_key else None
now = time.time()
self.trip.update(now, spd, maf)
self.perf.update(now, spd)
return spd, maf
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