Harden OBD connection and telemetry

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-09 17:47:52 -04:00
co-authored by Copilot
parent 33ea6958f4
commit cdbe8c89b1
4 changed files with 420 additions and 162 deletions
+24 -18
View File
@@ -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 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). `{"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/ **Update 2026-08-09**: Phase 17 (OBD hardening) was completed next; see its section below.
verified as described above - nothing left to do there. Work had just started on **Phase 17 (OBD Phase 14 is the recommended next software phase because Phases 15/16 and the later list-based
hardening)** next - picked because it's pure Python with zero risk to the Pi's live filesystem, features depend on its shared UI foundation. Phase 13 remains independent but requires a careful
in contrast to Phase 13's overlay-FS work which is riskier and deserves its own careful pass. hardware/filesystem deployment pass rather than ordinary application deployment.
**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.
### Phase 13: Power-loss resilience & watchdog — 🚧 PLANNED ### Phase 13: Power-loss resilience & watchdog — 🚧 PLANNED
**New phase, not code-dependent on anything else — OS/hardware-level, can be done anytime, **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) 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. 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 **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.** 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 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 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). 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 ### Phase 18: Trip history & replay — 🚧 PLANNED
**New phase.** Right now the `tracking` table (Phase 7) is just a raw stream of GPS/speed points **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 with no concept of a discrete "trip" - there's no way to look back and see "trip #47, left home
+1 -1
View File
@@ -8,7 +8,7 @@ app = Flask(__name__)
app_events.socketio.init_app(app) app_events.socketio.init_app(app)
account = app_account.Account() account = app_account.Account()
obd = app_obd.OBD() obd = app_obd.start_obd()
car = app_car.Car() car = app_car.Car()
gps = app_gps.start_gps() gps = app_gps.start_gps()
app_sql.start_logging() app_sql.start_logging()
+1 -2
View File
@@ -10,7 +10,7 @@ thread_uptime = None
@socketio.on('connect') @socketio.on('connect')
def connect_event(): def connect_event():
print('Client connected') print('Client connected')
print('Getting OBD status...') print('Getting current OBD status...')
app_obd.obd_connection() app_obd.obd_connection()
uptime() uptime()
@@ -42,4 +42,3 @@ def loop_uptime():
uptime_lock.release() uptime_lock.release()
+394 -141
View File
@@ -1,141 +1,394 @@
from modules import app_gps, app_sql, app_events from datetime import datetime
import glob, obd, os, time, threading from threading import Event, Lock, Thread
from datetime import datetime import glob
from threading import Event, Thread import os
import time
status = None
speed = 0 import obd
rpm = None
from modules import app_events, app_gps
class OBD:
def __init__(self):
self.status = status RECONNECT_INTERVAL = 10
self.rpm = rpm SENSOR_POLL_INTERVAL = 5
self.speed = speed
status = None
obd_status = ["Disconnected", "Connected", "Connection...", "wtf"] speed = 0
obd_speed_lock = threading.Lock() rpm = None
obd_rpm_lock = threading.Lock() coolant_temp = None
thread_speed = None fuel_level = None
thread_rpm = None throttle_position = None
stop_speed = Event() mil_on = None
stop_rpm = Event() dtc_codes = []
odometer = None
connection = None
class OBD:
def _find_obd_port(): def __init__(self):
"""Locate the USB-serial OBD-II adapter. self.status = status
self.rpm = rpm
Honors an OBD_PORT env var override; otherwise picks the first self.speed = speed
/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). obd_status = ["Disconnected", "Connected", "Connecting"]
""" obd_speed_lock = Lock()
env_port = os.environ.get("OBD_PORT") obd_rpm_lock = Lock()
if env_port: obd_sensor_lock = Lock()
return env_port _connection_lock = Lock()
ports = sorted(glob.glob("/dev/ttyUSB*")) _connect_lock = Lock()
return ports[0] if ports else None _query_lock = Lock()
thread_speed = None
thread_rpm = None
def connect_obd(): thread_sensors = None
"""(Re)connect to the OBD-II adapter. Safe to call repeatedly.""" _reconnect_thread = None
global connection stop_speed = Event()
port = _find_obd_port() stop_rpm = Event()
try: stop_sensors = Event()
print(obd_status[2])
connection = obd.OBD(portstr=port) if port else obd.OBD() connection = None
except Exception as e:
print("An error occurred:", str(e))
connection = None def _decode_odometer(messages):
return connection data = messages[0].data[2:]
if len(data) != 4:
raise ValueError(f"expected 4 odometer bytes, received {len(data)}")
connect_obd() return int.from_bytes(bytes(data), byteorder="big") / 10
def get_speedtime(): ODOMETER_COMMAND = obd.OBDCommand(
global speed "ODOMETER",
time_now = str(datetime.now().strftime("%H:%M:%S")) "Vehicle odometer",
return [time_now, speed] b"01A6",
6,
_decode_odometer,
def obd_connection(): )
global thread_speed, thread_rpm
if connection is None or not connection.is_connected(): def _find_obd_port():
connect_obd() """Locate the USB-serial OBD-II adapter."""
env_port = os.environ.get("OBD_PORT")
if connection is not None and connection.is_connected(): if env_port:
print(obd_status[1]) return env_port
app_events.socketio.emit('obd_status', obd_status[1]) ports = sorted(glob.glob("/dev/ttyUSB*"))
print("OBD connection established.") return ports[0] if ports else None
if thread_speed is None or not thread_speed.is_alive():
stop_speed.clear() def _connection_status(candidate):
thread_speed = Thread(target=obd_speed) try:
thread_speed.daemon = True return candidate.status()
thread_speed.start() except Exception:
print(f'thread_speed alive? {thread_speed.is_alive()}') return obd.OBDStatus.NOT_CONNECTED
if thread_rpm is None or not thread_rpm.is_alive():
stop_rpm.clear() def _is_car_connected(candidate=None):
thread_rpm = Thread(target=obd_rpm) if candidate is None:
thread_rpm.daemon = True with _connection_lock:
thread_rpm.start() candidate = connection
print(f'thread_rpm alive? {thread_rpm.is_alive()}') return (
else: candidate is not None
print("OBD connection failed.") and _connection_status(candidate) == obd.OBDStatus.CAR_CONNECTED
print(obd_status[0]) )
app_events.socketio.emit('obd_status', obd_status[0])
app_events.socketio.emit('obd_speed', "-") def _emit_disconnected():
app_events.socketio.emit('obd_error', "-") app_events.socketio.emit("obd_status", obd_status[0])
app_events.socketio.emit('obd_rpm', "-") app_events.socketio.emit("obd_speed", "-")
app_events.socketio.emit("obd_error", "-")
def obd_speed(): app_events.socketio.emit("obd_rpm", "-")
global speed
with obd_speed_lock:
cmd = obd.commands.SPEED # select an OBD command (sensor) def _close_connection(candidate):
while not stop_speed.is_set(): try:
if connection is None or not connection.is_connected(): candidate.close()
print("OBD disconnected, stopping speed polling.") except Exception as error:
break print("Failed to close OBD connection:", str(error))
response = connection.query(cmd) # send the command and parse the response
if response.is_null(): def _mark_connection_lost(candidate, operation, error):
print("No data received. #" + str(get_speedtime())) global connection
print(app_gps.get_gps()) with _connection_lock:
else: if connection is not candidate:
speed = round(response.value.to("kph").magnitude, 1) return
print("Speed:", speed, "kph") connection = None
print(f"OBD {operation} failed; reconnecting: {error}")
app_events.socketio.emit('obd_speed', get_speedtime()) _close_connection(candidate)
time.sleep(2) _emit_disconnected()
def obd_rpm():
global rpm def _query(command, *, force=False):
with obd_rpm_lock: with _connection_lock:
cmd = obd.commands.RPM # select an OBD command (sensor) candidate = connection
while not stop_rpm.is_set(): if candidate is None:
if connection is None or not connection.is_connected(): return None
print("OBD disconnected, stopping rpm polling.") if not _is_car_connected(candidate):
break _mark_connection_lost(
candidate,
response = connection.query(cmd) # send the command and parse the response "connection check",
if response.is_null(): f"status changed to {_connection_status(candidate)}",
print("No RPM data received. #" + str(get_speedtime())) )
app_events.socketio.emit('obd_rpm', "-") return None
else:
rpm = round(response.value.magnitude, 0) try:
print("RPM:", rpm) with _query_lock:
app_events.socketio.emit('obd_rpm', rpm) return candidate.query(command, force=force)
except Exception as error:
time.sleep(1) _mark_connection_lost(candidate, command.name, error)
return None
def kill_obd(obd_type):
if obd_type == "speed":
stop_speed.set() def _log_connection_diagnostics(candidate):
elif obd_type == "rpm": firmware = "unknown"
stop_rpm.set() 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()