From cdbe8c89b1563cb347fb17ded63ff64bdeb2ff82 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 9 Aug 2026 17:47:52 -0400 Subject: [PATCH] Harden OBD connection and telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PLAN.md | 42 ++-- app.py | 2 +- modules/app_events.py | 3 +- modules/app_obd.py | 535 +++++++++++++++++++++++++++++++----------- 4 files changed, 420 insertions(+), 162 deletions(-) diff --git a/PLAN.md b/PLAN.md index 2a484ed..8c19e8e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -676,23 +676,10 @@ returns HTTP 200 with no "relay" text over the real network) and the remote serv CORS-hook removal is baked into the image at build time - confirmed `/health` still returns `{"status":"ok"}` and the response no longer carries any `Access-Control-*` header). -**Handoff note for next session (2026-08-09, end of session)**: Phase 12 is fully done/deployed/ -verified as described above - nothing left to do there. Work had just started on **Phase 17 (OBD -hardening)** next - picked because it's pure Python with zero risk to the Pi's live filesystem, -in contrast to Phase 13's overlay-FS work which is riskier and deserves its own careful pass. -**No code was written for Phase 17 yet** - only read `modules/app_events.py` (confirms -`app_obd.obd_connection()` is only called from the Socket.IO `connect` handler, exactly as -previously noted) and `modules/app_gps.py` (to use its `_poll_loop()`/`start_gps()` background- -thread pattern as the template for OBD's new reconnect loop - lock+while-True+`time.sleep`, -daemon thread, idempotent `start_*()` guard against double-starting). A `pip install obd` on the -dev machine to inspect `python-obd`'s actual API (for DTC codes, ELM327 version/protocol -attributes, exception types) was attempted but interrupted by a tool permission prompt before -producing any output - **this still needs to be done** (or just read the `python-obd` source -directly, e.g. via `pip download`/GitHub) before writing the DTC/diagnostics/protocol-override -parts of Phase 17, to get exact attribute/method names right (e.g. `connection.protocol_name()`, -`obd.commands.GET_DTC`, whatever `python-obd`'s actual exception class is called) rather than -guessing. Phases 13 and 14 (UI foundation) are also fully ready to start (no blocking deps) if -picking a different phase first makes more sense than continuing Phase 17. +**Update 2026-08-09**: Phase 17 (OBD hardening) was completed next; see its section below. +Phase 14 is the recommended next software phase because Phases 15/16 and the later list-based +features depend on its shared UI foundation. Phase 13 remains independent but requires a careful +hardware/filesystem deployment pass rather than ordinary application deployment. ### Phase 13: Power-loss resilience & watchdog — 🚧 PLANNED **New phase, not code-dependent on anything else — OS/hardware-level, can be done anytime, @@ -772,7 +759,7 @@ Same file-browser treatment for videos already imported to the Pi's own disk Settings page - e.g. "delete local copies older than N days once uploaded", off by default) so the Pi's limited storage doesn't need to be babysat manually as the dashcam backlog grows. -### Phase 17: OBD real-hardware readiness (code hardening) — 🚧 PLANNED +### Phase 17: OBD real-hardware readiness (code hardening) — ✅ DONE **Independent of everything else in this plan — only touches `app_obd.py`/`app_events.py`, no UI dependency for the backend part, so this can be done anytime/in parallel with the phases above.** Nothing has been installed or tested in the actual 2013 Yaris yet - `modules/app_obd.py` has @@ -814,6 +801,25 @@ all in `modules/app_obd.py` / `modules/app_events.py`: confirmed once tested against the real car (falls under "code hardening only" for now - add the query with graceful "unsupported" handling rather than assuming it will work). +**Implemented 2026-08-09**: +- Added an idempotent, browser-independent reconnect daemon started from `app.py`; it retries + every 10 seconds and restarts any dead polling workers after reconnect. +- Serialized all python-obd queries so speed, RPM, and the expanded sensor worker cannot issue + overlapping serial exchanges. Query exceptions and silent connection-state changes now close + and discard the stale handle, emit disconnected state, and hand recovery back to the reconnect + loop instead of permanently killing a worker. +- Added `OBD_PROTOCOL` support and distinct logs for no serial adapter, failed ELM327 handshake, + and ELM327 connected but no ECU response. Successful connections log port, ELM327 firmware, + negotiated protocol ID, and protocol name once. +- Added backend polling/emits for coolant temperature, fuel level, throttle position, MIL/DTC + status, stored DTCs, and SAE PID `0xA6` odometer. Odometer uses a custom python-obd command with + a null/unsupported result left as `None`; support is not assumed for the 2013 Yaris. +- Verified against the Pi's installed python-obd 0.7.3 API, plus focused mocked checks covering + successful connection, forced protocol, ECU-off status, silent disconnect cleanup, query + exceptions, and odometer decoding. The Pi currently has `/dev/ttyUSB0` present but is not + connected to the car, so actual PID availability and negotiated Toyota protocol still require + the user-planned first in-car test. + ### Phase 18: Trip history & replay — 🚧 PLANNED **New phase.** Right now the `tracking` table (Phase 7) is just a raw stream of GPS/speed points with no concept of a discrete "trip" - there's no way to look back and see "trip #47, left home diff --git a/app.py b/app.py index a04bad6..3bd7356 100644 --- a/app.py +++ b/app.py @@ -8,7 +8,7 @@ app = Flask(__name__) app_events.socketio.init_app(app) account = app_account.Account() -obd = app_obd.OBD() +obd = app_obd.start_obd() car = app_car.Car() gps = app_gps.start_gps() app_sql.start_logging() diff --git a/modules/app_events.py b/modules/app_events.py index 175df7b..2d832bf 100644 --- a/modules/app_events.py +++ b/modules/app_events.py @@ -10,7 +10,7 @@ thread_uptime = None @socketio.on('connect') def connect_event(): print('Client connected') - print('Getting OBD status...') + print('Getting current OBD status...') app_obd.obd_connection() uptime() @@ -42,4 +42,3 @@ def loop_uptime(): uptime_lock.release() - diff --git a/modules/app_obd.py b/modules/app_obd.py index 8815001..f9f7d19 100644 --- a/modules/app_obd.py +++ b/modules/app_obd.py @@ -1,141 +1,394 @@ -from modules import app_gps, app_sql, app_events -import glob, obd, os, time, threading -from datetime import datetime -from threading import Event, Thread - -status = None -speed = 0 -rpm = None - -class OBD: - def __init__(self): - self.status = status - self.rpm = rpm - self.speed = speed - -obd_status = ["Disconnected", "Connected", "Connection...", "wtf"] -obd_speed_lock = threading.Lock() -obd_rpm_lock = threading.Lock() -thread_speed = None -thread_rpm = None -stop_speed = Event() -stop_rpm = Event() - -connection = None - - -def _find_obd_port(): - """Locate the USB-serial OBD-II adapter. - - Honors an OBD_PORT env var override; otherwise picks the first - /dev/ttyUSB* device found (the PL2303 USB adapter shows up here). - Returns None if nothing is found, letting python-obd fall back to its - own auto-scan (which also covers /dev/rfcomm* for the old Bluetooth setup). - """ - env_port = os.environ.get("OBD_PORT") - if env_port: - return env_port - ports = sorted(glob.glob("/dev/ttyUSB*")) - return ports[0] if ports else None - - -def connect_obd(): - """(Re)connect to the OBD-II adapter. Safe to call repeatedly.""" - global connection - port = _find_obd_port() - try: - print(obd_status[2]) - connection = obd.OBD(portstr=port) if port else obd.OBD() - except Exception as e: - print("An error occurred:", str(e)) - connection = None - return connection - - -connect_obd() - - -def get_speedtime(): - global speed - time_now = str(datetime.now().strftime("%H:%M:%S")) - return [time_now, speed] - - -def obd_connection(): - global thread_speed, thread_rpm - - if connection is None or not connection.is_connected(): - connect_obd() - - if connection is not None and connection.is_connected(): - print(obd_status[1]) - app_events.socketio.emit('obd_status', obd_status[1]) - print("OBD connection established.") - - if thread_speed is None or not thread_speed.is_alive(): - stop_speed.clear() - thread_speed = Thread(target=obd_speed) - thread_speed.daemon = True - thread_speed.start() - print(f'thread_speed alive? {thread_speed.is_alive()}') - - if thread_rpm is None or not thread_rpm.is_alive(): - stop_rpm.clear() - thread_rpm = Thread(target=obd_rpm) - thread_rpm.daemon = True - thread_rpm.start() - print(f'thread_rpm alive? {thread_rpm.is_alive()}') - else: - print("OBD connection failed.") - print(obd_status[0]) - - app_events.socketio.emit('obd_status', obd_status[0]) - app_events.socketio.emit('obd_speed', "-") - app_events.socketio.emit('obd_error', "-") - app_events.socketio.emit('obd_rpm', "-") - -def obd_speed(): - global speed - with obd_speed_lock: - cmd = obd.commands.SPEED # select an OBD command (sensor) - while not stop_speed.is_set(): - if connection is None or not connection.is_connected(): - print("OBD disconnected, stopping speed polling.") - break - - response = connection.query(cmd) # send the command and parse the response - if response.is_null(): - print("No data received. #" + str(get_speedtime())) - print(app_gps.get_gps()) - else: - speed = round(response.value.to("kph").magnitude, 1) - print("Speed:", speed, "kph") - - app_events.socketio.emit('obd_speed', get_speedtime()) - time.sleep(2) - -def obd_rpm(): - global rpm - with obd_rpm_lock: - cmd = obd.commands.RPM # select an OBD command (sensor) - while not stop_rpm.is_set(): - if connection is None or not connection.is_connected(): - print("OBD disconnected, stopping rpm polling.") - break - - response = connection.query(cmd) # send the command and parse the response - if response.is_null(): - print("No RPM data received. #" + str(get_speedtime())) - app_events.socketio.emit('obd_rpm', "-") - else: - rpm = round(response.value.magnitude, 0) - print("RPM:", rpm) - app_events.socketio.emit('obd_rpm', rpm) - - time.sleep(1) - -def kill_obd(obd_type): - if obd_type == "speed": - stop_speed.set() - elif obd_type == "rpm": - stop_rpm.set() +from datetime import datetime +from threading import Event, Lock, Thread +import glob +import os +import time + +import obd + +from modules import app_events, app_gps + + +RECONNECT_INTERVAL = 10 +SENSOR_POLL_INTERVAL = 5 + +status = None +speed = 0 +rpm = None +coolant_temp = None +fuel_level = None +throttle_position = None +mil_on = None +dtc_codes = [] +odometer = None + + +class OBD: + def __init__(self): + self.status = status + self.rpm = rpm + self.speed = speed + + +obd_status = ["Disconnected", "Connected", "Connecting"] +obd_speed_lock = Lock() +obd_rpm_lock = Lock() +obd_sensor_lock = Lock() +_connection_lock = Lock() +_connect_lock = Lock() +_query_lock = Lock() +thread_speed = None +thread_rpm = None +thread_sensors = None +_reconnect_thread = None +stop_speed = Event() +stop_rpm = Event() +stop_sensors = Event() + +connection = None + + +def _decode_odometer(messages): + data = messages[0].data[2:] + if len(data) != 4: + raise ValueError(f"expected 4 odometer bytes, received {len(data)}") + return int.from_bytes(bytes(data), byteorder="big") / 10 + + +ODOMETER_COMMAND = obd.OBDCommand( + "ODOMETER", + "Vehicle odometer", + b"01A6", + 6, + _decode_odometer, +) + + +def _find_obd_port(): + """Locate the USB-serial OBD-II adapter.""" + env_port = os.environ.get("OBD_PORT") + if env_port: + return env_port + ports = sorted(glob.glob("/dev/ttyUSB*")) + return ports[0] if ports else None + + +def _connection_status(candidate): + try: + return candidate.status() + except Exception: + return obd.OBDStatus.NOT_CONNECTED + + +def _is_car_connected(candidate=None): + if candidate is None: + with _connection_lock: + candidate = connection + return ( + candidate is not None + and _connection_status(candidate) == obd.OBDStatus.CAR_CONNECTED + ) + + +def _emit_disconnected(): + app_events.socketio.emit("obd_status", obd_status[0]) + app_events.socketio.emit("obd_speed", "-") + app_events.socketio.emit("obd_error", "-") + app_events.socketio.emit("obd_rpm", "-") + + +def _close_connection(candidate): + try: + candidate.close() + except Exception as error: + print("Failed to close OBD connection:", str(error)) + + +def _mark_connection_lost(candidate, operation, error): + global connection + with _connection_lock: + if connection is not candidate: + return + connection = None + print(f"OBD {operation} failed; reconnecting: {error}") + _close_connection(candidate) + _emit_disconnected() + + +def _query(command, *, force=False): + with _connection_lock: + candidate = connection + if candidate is None: + return None + if not _is_car_connected(candidate): + _mark_connection_lost( + candidate, + "connection check", + f"status changed to {_connection_status(candidate)}", + ) + return None + + try: + with _query_lock: + return candidate.query(command, force=force) + except Exception as error: + _mark_connection_lost(candidate, command.name, error) + return None + + +def _log_connection_diagnostics(candidate): + firmware = "unknown" + try: + with _query_lock: + response = candidate.query(obd.commands.ELM_VERSION) + if not response.is_null(): + firmware = str(response.value) + except Exception as error: + print("Unable to read ELM327 firmware version:", str(error)) + + print( + "OBD connected:" + f" port={candidate.port_name()}," + f" ELM327={firmware}," + f" protocol={candidate.protocol_id()} ({candidate.protocol_name()})" + ) + + +def connect_obd(): + """Connect to the adapter and ECU, with actionable failure diagnostics.""" + global connection + with _connect_lock: + if _is_car_connected(): + return connection + + with _connection_lock: + stale_connection = connection + connection = None + if stale_connection is not None: + _close_connection(stale_connection) + + port = _find_obd_port() + detected_ports = [port] if port else obd.scan_serial() + if not detected_ports: + print( + "No OBD-II adapter detected. Check USB power/cabling and the " + "OBD_PORT setting." + ) + with _connection_lock: + connection = None + _emit_disconnected() + return None + + protocol = os.environ.get("OBD_PROTOCOL") or None + selected_port = port or detected_ports[0] + print( + f"{obd_status[2]} to {selected_port}" + + (f" using protocol {protocol}" if protocol else " using auto protocol") + + "..." + ) + + try: + candidate = obd.OBD(portstr=selected_port, protocol=protocol) + except Exception as error: + print( + f"OBD adapter detected at {selected_port}, but the ELM327 " + f"handshake failed: {error}" + ) + with _connection_lock: + connection = None + _emit_disconnected() + return None + + candidate_status = _connection_status(candidate) + if candidate_status == obd.OBDStatus.NOT_CONNECTED: + print( + f"OBD adapter detected at {selected_port}, but the ELM327 " + "handshake failed." + ) + _close_connection(candidate) + candidate = None + elif candidate_status != obd.OBDStatus.CAR_CONNECTED: + print( + f"ELM327 handshake succeeded at {selected_port}, but the car ECU " + f"did not respond (status: {candidate_status}). Check ignition." + ) + _close_connection(candidate) + candidate = None + + with _connection_lock: + connection = candidate + + if candidate is None: + _emit_disconnected() + return None + + _log_connection_diagnostics(candidate) + app_events.socketio.emit("obd_status", obd_status[1]) + _start_polling_threads() + return candidate + + +def get_speedtime(): + time_now = str(datetime.now().strftime("%H:%M:%S")) + return [time_now, speed] + + +def obd_connection(): + """Emit current state for a newly connected browser and ensure OBD is running.""" + start_obd() + if _is_car_connected(): + app_events.socketio.emit("obd_status", obd_status[1]) + app_events.socketio.emit("obd_speed", get_speedtime()) + app_events.socketio.emit("obd_rpm", rpm if rpm is not None else "-") + else: + _emit_disconnected() + + +def obd_speed(): + global speed + with obd_speed_lock: + while not stop_speed.is_set() and _is_car_connected(): + response = _query(obd.commands.SPEED) + if response is None: + break + if response.is_null(): + print("No speed data received. #" + str(get_speedtime())) + print(app_gps.get_gps()) + else: + speed = round(response.value.to("kph").magnitude, 1) + print("Speed:", speed, "kph") + + app_events.socketio.emit("obd_speed", get_speedtime()) + stop_speed.wait(2) + + +def obd_rpm(): + global rpm + with obd_rpm_lock: + while not stop_rpm.is_set() and _is_car_connected(): + response = _query(obd.commands.RPM) + if response is None: + break + if response.is_null(): + print("No RPM data received. #" + str(get_speedtime())) + app_events.socketio.emit("obd_rpm", "-") + else: + rpm = round(response.value.magnitude) + print("RPM:", rpm) + app_events.socketio.emit("obd_rpm", rpm) + + stop_rpm.wait(1) + + +def _quantity_value(command, unit=None): + response = _query(command) + if response is None or response.is_null(): + return None + value = response.value.to(unit) if unit else response.value + return round(value.magnitude, 1) + + +def _poll_diagnostics(): + global coolant_temp, fuel_level, throttle_position + global mil_on, dtc_codes, odometer + + coolant_temp = _quantity_value(obd.commands.COOLANT_TEMP, "degC") + fuel_level = _quantity_value(obd.commands.FUEL_LEVEL, "percent") + throttle_position = _quantity_value(obd.commands.THROTTLE_POS, "percent") + + status_response = _query(obd.commands.STATUS) + if status_response is not None and not status_response.is_null(): + mil_on = bool(status_response.value.MIL) + app_events.socketio.emit( + "obd_mil", + {"on": mil_on, "dtc_count": status_response.value.DTC_count}, + ) + + dtc_response = _query(obd.commands.GET_DTC) + if dtc_response is not None and not dtc_response.is_null(): + dtc_codes = [ + {"code": code, "description": description} + for code, description in dtc_response.value + ] + app_events.socketio.emit("obd_dtc", dtc_codes) + + odometer_response = _query(ODOMETER_COMMAND, force=True) + if odometer_response is not None and not odometer_response.is_null(): + odometer = round(odometer_response.value, 1) + + app_events.socketio.emit("obd_coolant_temp", coolant_temp) + app_events.socketio.emit("obd_fuel_level", fuel_level) + app_events.socketio.emit("obd_throttle", throttle_position) + app_events.socketio.emit("obd_odometer", odometer) + + +def obd_sensors(): + with obd_sensor_lock: + while not stop_sensors.is_set() and _is_car_connected(): + _poll_diagnostics() + stop_sensors.wait(SENSOR_POLL_INTERVAL) + + +def _start_polling_threads(): + global thread_speed, thread_rpm, thread_sensors + + thread_specs = ( + ("speed", obd_speed, stop_speed), + ("rpm", obd_rpm, stop_rpm), + ("sensors", obd_sensors, stop_sensors), + ) + threads = { + "speed": thread_speed, + "rpm": thread_rpm, + "sensors": thread_sensors, + } + + for name, target, stop_event in thread_specs: + thread = threads[name] + if thread is None or not thread.is_alive(): + stop_event.clear() + thread = Thread(target=target, name=f"obd-{name}", daemon=True) + thread.start() + if name == "speed": + thread_speed = thread + elif name == "rpm": + thread_rpm = thread + else: + thread_sensors = thread + print(f"OBD {name} thread started, alive: {thread.is_alive()}") + + +def _reconnect_loop(): + while True: + if _is_car_connected(): + _start_polling_threads() + else: + connect_obd() + time.sleep(RECONNECT_INTERVAL) + + +def start_obd(): + """Start the browser-independent OBD reconnect loop once.""" + global _reconnect_thread + if _reconnect_thread is None or not _reconnect_thread.is_alive(): + _reconnect_thread = Thread( + target=_reconnect_loop, + name="obd-reconnect", + daemon=True, + ) + _reconnect_thread.start() + print(f"OBD reconnect thread started, alive: {_reconnect_thread.is_alive()}") + return OBD() + + +def kill_obd(obd_type): + if obd_type == "speed": + stop_speed.set() + elif obd_type == "rpm": + stop_rpm.set() + elif obd_type == "sensors": + stop_sensors.set() + elif obd_type == "all": + stop_speed.set() + stop_rpm.set() + stop_sensors.set()