Harden OBD connection and telemetry
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
+394
-141
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user