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()
+324 -71
View File
@@ -1,11 +1,27 @@
from modules import app_gps, app_sql, app_events
import glob, obd, os, time, threading
from datetime import datetime from datetime import datetime
from threading import Event, Thread 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 status = None
speed = 0 speed = 0
rpm = None rpm = None
coolant_temp = None
fuel_level = None
throttle_position = None
mil_on = None
dtc_codes = []
odometer = None
class OBD: class OBD:
def __init__(self): def __init__(self):
@@ -13,25 +29,43 @@ class OBD:
self.rpm = rpm self.rpm = rpm
self.speed = speed self.speed = speed
obd_status = ["Disconnected", "Connected", "Connection...", "wtf"]
obd_speed_lock = threading.Lock() obd_status = ["Disconnected", "Connected", "Connecting"]
obd_rpm_lock = threading.Lock() 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_speed = None
thread_rpm = None thread_rpm = None
thread_sensors = None
_reconnect_thread = None
stop_speed = Event() stop_speed = Event()
stop_rpm = Event() stop_rpm = Event()
stop_sensors = Event()
connection = None connection = None
def _find_obd_port(): def _decode_odometer(messages):
"""Locate the USB-serial OBD-II adapter. 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
Honors an OBD_PORT env var override; otherwise picks the first
/dev/ttyUSB* device found (the PL2303 USB adapter shows up here). ODOMETER_COMMAND = obd.OBDCommand(
Returns None if nothing is found, letting python-obd fall back to its "ODOMETER",
own auto-scan (which also covers /dev/rfcomm* for the old Bluetooth setup). "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") env_port = os.environ.get("OBD_PORT")
if env_port: if env_port:
return env_port return env_port
@@ -39,103 +73,322 @@ def _find_obd_port():
return ports[0] if ports else None return ports[0] if ports else None
def connect_obd(): def _connection_status(candidate):
"""(Re)connect to the OBD-II adapter. Safe to call repeatedly."""
global connection
port = _find_obd_port()
try: try:
print(obd_status[2]) return candidate.status()
connection = obd.OBD(portstr=port) if port else obd.OBD() except Exception:
except Exception as e: return obd.OBDStatus.NOT_CONNECTED
print("An error occurred:", str(e))
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 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 return connection
with _connection_lock:
stale_connection = connection
connection = None
if stale_connection is not None:
_close_connection(stale_connection)
connect_obd() 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(): def get_speedtime():
global speed
time_now = str(datetime.now().strftime("%H:%M:%S")) time_now = str(datetime.now().strftime("%H:%M:%S"))
return [time_now, speed] return [time_now, speed]
def obd_connection(): def obd_connection():
global thread_speed, thread_rpm """Emit current state for a newly connected browser and ensure OBD is running."""
start_obd()
if connection is None or not connection.is_connected(): if _is_car_connected():
connect_obd() app_events.socketio.emit("obd_status", obd_status[1])
app_events.socketio.emit("obd_speed", get_speedtime())
if connection is not None and connection.is_connected(): app_events.socketio.emit("obd_rpm", rpm if rpm is not None else "-")
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: else:
print("OBD connection failed.") _emit_disconnected()
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(): def obd_speed():
global speed global speed
with obd_speed_lock: with obd_speed_lock:
cmd = obd.commands.SPEED # select an OBD command (sensor) while not stop_speed.is_set() and _is_car_connected():
while not stop_speed.is_set(): response = _query(obd.commands.SPEED)
if connection is None or not connection.is_connected(): if response is None:
print("OBD disconnected, stopping speed polling.")
break break
response = connection.query(cmd) # send the command and parse the response
if response.is_null(): if response.is_null():
print("No data received. #" + str(get_speedtime())) print("No speed data received. #" + str(get_speedtime()))
print(app_gps.get_gps()) print(app_gps.get_gps())
else: else:
speed = round(response.value.to("kph").magnitude, 1) speed = round(response.value.to("kph").magnitude, 1)
print("Speed:", speed, "kph") print("Speed:", speed, "kph")
app_events.socketio.emit('obd_speed', get_speedtime()) app_events.socketio.emit("obd_speed", get_speedtime())
time.sleep(2) stop_speed.wait(2)
def obd_rpm(): def obd_rpm():
global rpm global rpm
with obd_rpm_lock: with obd_rpm_lock:
cmd = obd.commands.RPM # select an OBD command (sensor) while not stop_rpm.is_set() and _is_car_connected():
while not stop_rpm.is_set(): response = _query(obd.commands.RPM)
if connection is None or not connection.is_connected(): if response is None:
print("OBD disconnected, stopping rpm polling.")
break break
response = connection.query(cmd) # send the command and parse the response
if response.is_null(): if response.is_null():
print("No RPM data received. #" + str(get_speedtime())) print("No RPM data received. #" + str(get_speedtime()))
app_events.socketio.emit('obd_rpm', "-") app_events.socketio.emit("obd_rpm", "-")
else: else:
rpm = round(response.value.magnitude, 0) rpm = round(response.value.magnitude)
print("RPM:", rpm) print("RPM:", rpm)
app_events.socketio.emit('obd_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()
time.sleep(1)
def kill_obd(obd_type): def kill_obd(obd_type):
if obd_type == "speed": if obd_type == "speed":
stop_speed.set() stop_speed.set()
elif obd_type == "rpm": elif obd_type == "rpm":
stop_rpm.set() stop_rpm.set()
elif obd_type == "sensors":
stop_sensors.set()
elif obd_type == "all":
stop_speed.set()
stop_rpm.set()
stop_sensors.set()