initial
This commit is contained in:
291
command_ttl.conf
Normal file
291
command_ttl.conf
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
# Format: COMMAND_NAME,ttl_ms
|
||||||
|
PIDS_A,5000
|
||||||
|
STATUS,5000
|
||||||
|
FREEZE_DTC,5000
|
||||||
|
FUEL_STATUS,5000
|
||||||
|
ENGINE_LOAD,5000
|
||||||
|
COOLANT_TEMP,5000
|
||||||
|
SHORT_FUEL_TRIM_1,5000
|
||||||
|
LONG_FUEL_TRIM_1,5000
|
||||||
|
SHORT_FUEL_TRIM_2,5000
|
||||||
|
LONG_FUEL_TRIM_2,5000
|
||||||
|
FUEL_PRESSURE,5000
|
||||||
|
INTAKE_PRESSURE,5000
|
||||||
|
RPM,10
|
||||||
|
SPEED,10
|
||||||
|
TIMING_ADVANCE,5000
|
||||||
|
INTAKE_TEMP,5000
|
||||||
|
MAF,5000
|
||||||
|
THROTTLE_POS,5000
|
||||||
|
AIR_STATUS,5000
|
||||||
|
O2_SENSORS,5000
|
||||||
|
O2_B1S1,5000
|
||||||
|
O2_B1S2,5000
|
||||||
|
O2_B1S3,5000
|
||||||
|
O2_B1S4,5000
|
||||||
|
O2_B2S1,5000
|
||||||
|
O2_B2S2,5000
|
||||||
|
O2_B2S3,5000
|
||||||
|
O2_B2S4,5000
|
||||||
|
OBD_COMPLIANCE,5000
|
||||||
|
O2_SENSORS_ALT,5000
|
||||||
|
AUX_INPUT_STATUS,5000
|
||||||
|
RUN_TIME,5000
|
||||||
|
PIDS_B,5000
|
||||||
|
DISTANCE_W_MIL,5000
|
||||||
|
FUEL_RAIL_PRESSURE_VAC,5000
|
||||||
|
FUEL_RAIL_PRESSURE_DIRECT,5000
|
||||||
|
O2_S1_WR_VOLTAGE,5000
|
||||||
|
O2_S2_WR_VOLTAGE,5000
|
||||||
|
O2_S3_WR_VOLTAGE,5000
|
||||||
|
O2_S4_WR_VOLTAGE,5000
|
||||||
|
O2_S5_WR_VOLTAGE,5000
|
||||||
|
O2_S6_WR_VOLTAGE,5000
|
||||||
|
O2_S7_WR_VOLTAGE,5000
|
||||||
|
O2_S8_WR_VOLTAGE,5000
|
||||||
|
COMMANDED_EGR,5000
|
||||||
|
EGR_ERROR,5000
|
||||||
|
EVAPORATIVE_PURGE,5000
|
||||||
|
FUEL_LEVEL,5000
|
||||||
|
WARMUPS_SINCE_DTC_CLEAR,5000
|
||||||
|
DISTANCE_SINCE_DTC_CLEAR,5000
|
||||||
|
EVAP_VAPOR_PRESSURE,5000
|
||||||
|
BAROMETRIC_PRESSURE,5000
|
||||||
|
O2_S1_WR_CURRENT,5000
|
||||||
|
O2_S2_WR_CURRENT,5000
|
||||||
|
O2_S3_WR_CURRENT,5000
|
||||||
|
O2_S4_WR_CURRENT,5000
|
||||||
|
O2_S5_WR_CURRENT,5000
|
||||||
|
O2_S6_WR_CURRENT,5000
|
||||||
|
O2_S7_WR_CURRENT,5000
|
||||||
|
O2_S8_WR_CURRENT,5000
|
||||||
|
CATALYST_TEMP_B1S1,5000
|
||||||
|
CATALYST_TEMP_B2S1,5000
|
||||||
|
CATALYST_TEMP_B1S2,5000
|
||||||
|
CATALYST_TEMP_B2S2,5000
|
||||||
|
PIDS_C,5000
|
||||||
|
STATUS_DRIVE_CYCLE,5000
|
||||||
|
CONTROL_MODULE_VOLTAGE,5000
|
||||||
|
ABSOLUTE_LOAD,5000
|
||||||
|
COMMANDED_EQUIV_RATIO,5000
|
||||||
|
RELATIVE_THROTTLE_POS,5000
|
||||||
|
AMBIANT_AIR_TEMP,5000
|
||||||
|
THROTTLE_POS_B,5000
|
||||||
|
THROTTLE_POS_C,5000
|
||||||
|
ACCELERATOR_POS_D,5000
|
||||||
|
ACCELERATOR_POS_E,5000
|
||||||
|
ACCELERATOR_POS_F,5000
|
||||||
|
THROTTLE_ACTUATOR,5000
|
||||||
|
RUN_TIME_MIL,5000
|
||||||
|
TIME_SINCE_DTC_CLEARED,5000
|
||||||
|
MAX_VALUES,5000
|
||||||
|
MAX_MAF,5000
|
||||||
|
FUEL_TYPE,5000
|
||||||
|
ETHANOL_PERCENT,5000
|
||||||
|
EVAP_VAPOR_PRESSURE_ABS,5000
|
||||||
|
EVAP_VAPOR_PRESSURE_ALT,5000
|
||||||
|
SHORT_O2_TRIM_B1,5000
|
||||||
|
LONG_O2_TRIM_B1,5000
|
||||||
|
SHORT_O2_TRIM_B2,5000
|
||||||
|
LONG_O2_TRIM_B2,5000
|
||||||
|
FUEL_RAIL_PRESSURE_ABS,5000
|
||||||
|
RELATIVE_ACCEL_POS,5000
|
||||||
|
HYBRID_BATTERY_REMAINING,5000
|
||||||
|
OIL_TEMP,5000
|
||||||
|
FUEL_INJECT_TIMING,5000
|
||||||
|
FUEL_RATE,5000
|
||||||
|
EMISSION_REQ,5000
|
||||||
|
DTC_PIDS_A,5000
|
||||||
|
DTC_STATUS,5000
|
||||||
|
DTC_FREEZE_DTC,5000
|
||||||
|
DTC_FUEL_STATUS,5000
|
||||||
|
DTC_ENGINE_LOAD,5000
|
||||||
|
DTC_COOLANT_TEMP,5000
|
||||||
|
DTC_SHORT_FUEL_TRIM_1,5000
|
||||||
|
DTC_LONG_FUEL_TRIM_1,5000
|
||||||
|
DTC_SHORT_FUEL_TRIM_2,5000
|
||||||
|
DTC_LONG_FUEL_TRIM_2,5000
|
||||||
|
DTC_FUEL_PRESSURE,5000
|
||||||
|
DTC_INTAKE_PRESSURE,5000
|
||||||
|
DTC_RPM,5000
|
||||||
|
DTC_SPEED,5000
|
||||||
|
DTC_TIMING_ADVANCE,5000
|
||||||
|
DTC_INTAKE_TEMP,5000
|
||||||
|
DTC_MAF,5000
|
||||||
|
DTC_THROTTLE_POS,5000
|
||||||
|
DTC_AIR_STATUS,5000
|
||||||
|
DTC_O2_SENSORS,5000
|
||||||
|
DTC_O2_B1S1,5000
|
||||||
|
DTC_O2_B1S2,5000
|
||||||
|
DTC_O2_B1S3,5000
|
||||||
|
DTC_O2_B1S4,5000
|
||||||
|
DTC_O2_B2S1,5000
|
||||||
|
DTC_O2_B2S2,5000
|
||||||
|
DTC_O2_B2S3,5000
|
||||||
|
DTC_O2_B2S4,5000
|
||||||
|
DTC_OBD_COMPLIANCE,5000
|
||||||
|
DTC_O2_SENSORS_ALT,5000
|
||||||
|
DTC_AUX_INPUT_STATUS,5000
|
||||||
|
DTC_RUN_TIME,5000
|
||||||
|
DTC_PIDS_B,5000
|
||||||
|
DTC_DISTANCE_W_MIL,5000
|
||||||
|
DTC_FUEL_RAIL_PRESSURE_VAC,5000
|
||||||
|
DTC_FUEL_RAIL_PRESSURE_DIRECT,5000
|
||||||
|
DTC_O2_S1_WR_VOLTAGE,5000
|
||||||
|
DTC_O2_S2_WR_VOLTAGE,5000
|
||||||
|
DTC_O2_S3_WR_VOLTAGE,5000
|
||||||
|
DTC_O2_S4_WR_VOLTAGE,5000
|
||||||
|
DTC_O2_S5_WR_VOLTAGE,5000
|
||||||
|
DTC_O2_S6_WR_VOLTAGE,5000
|
||||||
|
DTC_O2_S7_WR_VOLTAGE,5000
|
||||||
|
DTC_O2_S8_WR_VOLTAGE,5000
|
||||||
|
DTC_COMMANDED_EGR,5000
|
||||||
|
DTC_EGR_ERROR,5000
|
||||||
|
DTC_EVAPORATIVE_PURGE,5000
|
||||||
|
DTC_FUEL_LEVEL,5000
|
||||||
|
DTC_WARMUPS_SINCE_DTC_CLEAR,5000
|
||||||
|
DTC_DISTANCE_SINCE_DTC_CLEAR,5000
|
||||||
|
DTC_EVAP_VAPOR_PRESSURE,5000
|
||||||
|
DTC_BAROMETRIC_PRESSURE,5000
|
||||||
|
DTC_O2_S1_WR_CURRENT,5000
|
||||||
|
DTC_O2_S2_WR_CURRENT,5000
|
||||||
|
DTC_O2_S3_WR_CURRENT,5000
|
||||||
|
DTC_O2_S4_WR_CURRENT,5000
|
||||||
|
DTC_O2_S5_WR_CURRENT,5000
|
||||||
|
DTC_O2_S6_WR_CURRENT,5000
|
||||||
|
DTC_O2_S7_WR_CURRENT,5000
|
||||||
|
DTC_O2_S8_WR_CURRENT,5000
|
||||||
|
DTC_CATALYST_TEMP_B1S1,5000
|
||||||
|
DTC_CATALYST_TEMP_B2S1,5000
|
||||||
|
DTC_CATALYST_TEMP_B1S2,5000
|
||||||
|
DTC_CATALYST_TEMP_B2S2,5000
|
||||||
|
DTC_PIDS_C,5000
|
||||||
|
DTC_STATUS_DRIVE_CYCLE,5000
|
||||||
|
DTC_CONTROL_MODULE_VOLTAGE,5000
|
||||||
|
DTC_ABSOLUTE_LOAD,5000
|
||||||
|
DTC_COMMANDED_EQUIV_RATIO,5000
|
||||||
|
DTC_RELATIVE_THROTTLE_POS,5000
|
||||||
|
DTC_AMBIANT_AIR_TEMP,5000
|
||||||
|
DTC_THROTTLE_POS_B,5000
|
||||||
|
DTC_THROTTLE_POS_C,5000
|
||||||
|
DTC_ACCELERATOR_POS_D,5000
|
||||||
|
DTC_ACCELERATOR_POS_E,5000
|
||||||
|
DTC_ACCELERATOR_POS_F,5000
|
||||||
|
DTC_THROTTLE_ACTUATOR,5000
|
||||||
|
DTC_RUN_TIME_MIL,5000
|
||||||
|
DTC_TIME_SINCE_DTC_CLEARED,5000
|
||||||
|
DTC_MAX_VALUES,5000
|
||||||
|
DTC_MAX_MAF,5000
|
||||||
|
DTC_FUEL_TYPE,5000
|
||||||
|
DTC_ETHANOL_PERCENT,5000
|
||||||
|
DTC_EVAP_VAPOR_PRESSURE_ABS,5000
|
||||||
|
DTC_EVAP_VAPOR_PRESSURE_ALT,5000
|
||||||
|
DTC_SHORT_O2_TRIM_B1,5000
|
||||||
|
DTC_LONG_O2_TRIM_B1,5000
|
||||||
|
DTC_SHORT_O2_TRIM_B2,5000
|
||||||
|
DTC_LONG_O2_TRIM_B2,5000
|
||||||
|
DTC_FUEL_RAIL_PRESSURE_ABS,5000
|
||||||
|
DTC_RELATIVE_ACCEL_POS,5000
|
||||||
|
DTC_HYBRID_BATTERY_REMAINING,5000
|
||||||
|
DTC_OIL_TEMP,5000
|
||||||
|
DTC_FUEL_INJECT_TIMING,5000
|
||||||
|
DTC_FUEL_RATE,5000
|
||||||
|
DTC_EMISSION_REQ,5000
|
||||||
|
GET_DTC,5000
|
||||||
|
CLEAR_DTC,5000
|
||||||
|
MIDS_A,5000
|
||||||
|
MONITOR_O2_B1S1,5000
|
||||||
|
MONITOR_O2_B1S2,5000
|
||||||
|
MONITOR_O2_B1S3,5000
|
||||||
|
MONITOR_O2_B1S4,5000
|
||||||
|
MONITOR_O2_B2S1,5000
|
||||||
|
MONITOR_O2_B2S2,5000
|
||||||
|
MONITOR_O2_B2S3,5000
|
||||||
|
MONITOR_O2_B2S4,5000
|
||||||
|
MONITOR_O2_B3S1,5000
|
||||||
|
MONITOR_O2_B3S2,5000
|
||||||
|
MONITOR_O2_B3S3,5000
|
||||||
|
MONITOR_O2_B3S4,5000
|
||||||
|
MONITOR_O2_B4S1,5000
|
||||||
|
MONITOR_O2_B4S2,5000
|
||||||
|
MONITOR_O2_B4S3,5000
|
||||||
|
MONITOR_O2_B4S4,5000
|
||||||
|
MIDS_B,5000
|
||||||
|
MONITOR_CATALYST_B1,5000
|
||||||
|
MONITOR_CATALYST_B2,5000
|
||||||
|
MONITOR_CATALYST_B3,5000
|
||||||
|
MONITOR_CATALYST_B4,5000
|
||||||
|
MONITOR_EGR_B1,5000
|
||||||
|
MONITOR_EGR_B2,5000
|
||||||
|
MONITOR_EGR_B3,5000
|
||||||
|
MONITOR_EGR_B4,5000
|
||||||
|
MONITOR_VVT_B1,5000
|
||||||
|
MONITOR_VVT_B2,5000
|
||||||
|
MONITOR_VVT_B3,5000
|
||||||
|
MONITOR_VVT_B4,5000
|
||||||
|
MONITOR_EVAP_150,5000
|
||||||
|
MONITOR_EVAP_090,5000
|
||||||
|
MONITOR_EVAP_040,5000
|
||||||
|
MONITOR_EVAP_020,5000
|
||||||
|
MONITOR_PURGE_FLOW,5000
|
||||||
|
MIDS_C,5000
|
||||||
|
MONITOR_O2_HEATER_B1S1,5000
|
||||||
|
MONITOR_O2_HEATER_B1S2,5000
|
||||||
|
MONITOR_O2_HEATER_B1S3,5000
|
||||||
|
MONITOR_O2_HEATER_B1S4,5000
|
||||||
|
MONITOR_O2_HEATER_B2S1,5000
|
||||||
|
MONITOR_O2_HEATER_B2S2,5000
|
||||||
|
MONITOR_O2_HEATER_B2S3,5000
|
||||||
|
MONITOR_O2_HEATER_B2S4,5000
|
||||||
|
MONITOR_O2_HEATER_B3S1,5000
|
||||||
|
MONITOR_O2_HEATER_B3S2,5000
|
||||||
|
MONITOR_O2_HEATER_B3S3,5000
|
||||||
|
MONITOR_O2_HEATER_B3S4,5000
|
||||||
|
MONITOR_O2_HEATER_B4S1,5000
|
||||||
|
MONITOR_O2_HEATER_B4S2,5000
|
||||||
|
MONITOR_O2_HEATER_B4S3,5000
|
||||||
|
MONITOR_O2_HEATER_B4S4,5000
|
||||||
|
MIDS_D,5000
|
||||||
|
MONITOR_HEATED_CATALYST_B1,5000
|
||||||
|
MONITOR_HEATED_CATALYST_B2,5000
|
||||||
|
MONITOR_HEATED_CATALYST_B3,5000
|
||||||
|
MONITOR_HEATED_CATALYST_B4,5000
|
||||||
|
MONITOR_SECONDARY_AIR_1,5000
|
||||||
|
MONITOR_SECONDARY_AIR_2,5000
|
||||||
|
MONITOR_SECONDARY_AIR_3,5000
|
||||||
|
MONITOR_SECONDARY_AIR_4,5000
|
||||||
|
MIDS_E,5000
|
||||||
|
MONITOR_FUEL_SYSTEM_B1,5000
|
||||||
|
MONITOR_FUEL_SYSTEM_B2,5000
|
||||||
|
MONITOR_FUEL_SYSTEM_B3,5000
|
||||||
|
MONITOR_FUEL_SYSTEM_B4,5000
|
||||||
|
MONITOR_BOOST_PRESSURE_B1,5000
|
||||||
|
MONITOR_BOOST_PRESSURE_B2,5000
|
||||||
|
MONITOR_NOX_ABSORBER_B1,5000
|
||||||
|
MONITOR_NOX_ABSORBER_B2,5000
|
||||||
|
MONITOR_NOX_CATALYST_B1,5000
|
||||||
|
MONITOR_NOX_CATALYST_B2,5000
|
||||||
|
MIDS_F,5000
|
||||||
|
MONITOR_MISFIRE_GENERAL,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_1,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_2,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_3,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_4,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_5,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_6,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_7,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_8,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_9,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_10,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_11,5000
|
||||||
|
MONITOR_MISFIRE_CYLINDER_12,5000
|
||||||
|
MONITOR_PM_FILTER_B1,5000
|
||||||
|
MONITOR_PM_FILTER_B2,5000
|
||||||
|
GET_CURRENT_DTC,5000
|
||||||
|
PIDS_9A,5000
|
||||||
|
VIN_MESSAGE_COUNT,5000
|
||||||
|
VIN,5000
|
||||||
|
CALIBRATION_ID_MESSAGE_COUNT,5000
|
||||||
|
CALIBRATION_ID,5000
|
||||||
|
CVN_MESSAGE_COUNT,5000
|
||||||
|
CVN,5000
|
||||||
43
models.py
Normal file
43
models.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Report(BaseModel):
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
|
speed_mph: float = 0.0
|
||||||
|
rpm: float = 0.0
|
||||||
|
fuel_level_pct: float = 0.0
|
||||||
|
oil_temp_f: float = 0.0
|
||||||
|
coolant_temp_f: float = 0.0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_numeric(value: Any) -> float:
|
||||||
|
if value is None:
|
||||||
|
return 0.0
|
||||||
|
if hasattr(value, "magnitude"):
|
||||||
|
value = value.magnitude
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
def set_speed(self, value: Any) -> None:
|
||||||
|
self.speed_mph = self._coerce_numeric(value)
|
||||||
|
|
||||||
|
def set_rpm(self, value: Any) -> None:
|
||||||
|
self.rpm = self._coerce_numeric(value)
|
||||||
|
|
||||||
|
def set_fuel(self, value: Any) -> None:
|
||||||
|
self.fuel_level_pct = self._coerce_numeric(value)
|
||||||
|
|
||||||
|
def set_oil_temp(self, value: Any) -> None:
|
||||||
|
self.oil_temp_f = self._coerce_numeric(value)
|
||||||
|
|
||||||
|
def set_coolant_temp(self, value: Any) -> None:
|
||||||
|
self.coolant_temp_f = self._coerce_numeric(value)
|
||||||
|
|
||||||
|
|
||||||
|
class Scan(BaseModel):
|
||||||
|
cmd: Any = None
|
||||||
|
interval: float = 10.0
|
||||||
|
callback: Callable[[float], None] | None = None
|
||||||
|
transform: Callable[[Any], float] | None = None
|
||||||
605
obd2_interface.py
Normal file
605
obd2_interface.py
Normal file
@ -0,0 +1,605 @@
|
|||||||
|
import asyncio as aio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from numbers import Number
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import obd
|
||||||
|
|
||||||
|
from models import Report, Scan
|
||||||
|
|
||||||
|
|
||||||
|
def _quantity_to_float(value: Any, unit: str | None = None) -> float:
|
||||||
|
if value is None:
|
||||||
|
raise ValueError("Cannot normalize an empty OBD value")
|
||||||
|
if unit and hasattr(value, "to"):
|
||||||
|
value = value.to(unit)
|
||||||
|
if hasattr(value, "magnitude"):
|
||||||
|
value = value.magnitude
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def format_obd_value(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "--"
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if hasattr(value, "magnitude"):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, Number):
|
||||||
|
numeric = float(value)
|
||||||
|
if numeric.is_integer():
|
||||||
|
return str(int(numeric))
|
||||||
|
return f"{numeric:.1f}"
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return ", ".join(str(item) for item in value) or "--"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CacheEntry:
|
||||||
|
value: Any
|
||||||
|
expires_at: float
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatedOBDConnection:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._start = time.monotonic()
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return not self._closed
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
def query(self, cmd: Any) -> Any:
|
||||||
|
value = self._value_for_command(cmd)
|
||||||
|
return type(
|
||||||
|
"SimulatedResponse",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"value": value,
|
||||||
|
"is_null": staticmethod(lambda: False),
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
|
||||||
|
def _value_for_command(self, cmd: Any) -> float:
|
||||||
|
elapsed = time.monotonic() - self._start
|
||||||
|
name = getattr(cmd, "name", str(cmd))
|
||||||
|
mode = getattr(cmd, "mode", None)
|
||||||
|
|
||||||
|
if name == "SPEED":
|
||||||
|
return 45.0 + 20.0 * math.sin(elapsed / 2.5)
|
||||||
|
if name == "RPM":
|
||||||
|
return 1800.0 + 900.0 * (1.0 + math.sin(elapsed * 1.7))
|
||||||
|
if name == "FUEL_LEVEL":
|
||||||
|
return max(0.0, 85.0 - elapsed * 0.03)
|
||||||
|
if name == "OIL_TEMP":
|
||||||
|
return 185.0 + 12.0 * math.sin(elapsed / 8.0)
|
||||||
|
if name == "COOLANT_TEMP":
|
||||||
|
return 195.0 + 8.0 * math.sin(elapsed / 10.0)
|
||||||
|
if name == "VIN":
|
||||||
|
return "1HGBH41JXMN109186"
|
||||||
|
if "CALIBRATION_ID" in name:
|
||||||
|
return "CALID-TEST-001"
|
||||||
|
if name == "CVN":
|
||||||
|
return "A1B2C3D4"
|
||||||
|
if "MESSAGE_COUNT" in name:
|
||||||
|
return 1
|
||||||
|
if name in {"GET_DTC", "GET_CURRENT_DTC"}:
|
||||||
|
return "P0301, P0420"
|
||||||
|
if name == "CLEAR_DTC":
|
||||||
|
return "READY"
|
||||||
|
if name.startswith(("PIDS_", "DTC_PIDS_", "MIDS_")):
|
||||||
|
return "FFFF"
|
||||||
|
if mode == 6:
|
||||||
|
base = 40.0 + (sum(ord(char) for char in name) % 30)
|
||||||
|
return base + 10.0 * math.sin(elapsed / 3.0)
|
||||||
|
if mode in {1, 2, 9}:
|
||||||
|
base = 10.0 + (sum(ord(char) for char in name) % 80)
|
||||||
|
return base + 5.0 * math.sin(elapsed / 4.0)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class OBD2Interface:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
logger: logging.Logger,
|
||||||
|
connection: Any | None = None,
|
||||||
|
query_rate_limit: float = 10.0,
|
||||||
|
cache_ttl: float = 0.25,
|
||||||
|
ttl_config_path: str | None = None,
|
||||||
|
ttl_config_scan_interval: float = 2.0,
|
||||||
|
):
|
||||||
|
self.logger = logger
|
||||||
|
self.connection = connection
|
||||||
|
self._owns_connection = connection is None
|
||||||
|
self.query_rate_limit = query_rate_limit
|
||||||
|
self.cache_ttl = cache_ttl
|
||||||
|
self.ttl_config_path = Path(ttl_config_path) if ttl_config_path else None
|
||||||
|
self.ttl_config_scan_interval = ttl_config_scan_interval
|
||||||
|
self.sensor_connected = self._is_connected() if connection is not None else False
|
||||||
|
self._stop_event = aio.Event()
|
||||||
|
self._tasks: list[aio.Task[Any]] = []
|
||||||
|
self._rate_limit_lock = aio.Lock()
|
||||||
|
self._cache_lock = aio.Lock()
|
||||||
|
self._next_query_time = 0.0
|
||||||
|
self._query_cache: dict[str, CacheEntry] = {}
|
||||||
|
self._command_ttls: dict[str, float] = {}
|
||||||
|
self._ttl_config_mtime: float | None = None
|
||||||
|
self._query_queue: aio.Queue[tuple[Any, str, aio.Future[Any | None]]] = aio.Queue()
|
||||||
|
self._pending_queries: dict[str, aio.Future[Any | None]] = {}
|
||||||
|
self._pending_lock = aio.Lock()
|
||||||
|
self._query_worker_task: aio.Task[None] | None = None
|
||||||
|
self._ttl_config_task: aio.Task[None] | None = None
|
||||||
|
|
||||||
|
def _is_connected(self) -> bool:
|
||||||
|
if self.connection is None:
|
||||||
|
return False
|
||||||
|
is_connected = getattr(self.connection, "is_connected", None)
|
||||||
|
if callable(is_connected):
|
||||||
|
return bool(is_connected())
|
||||||
|
return self.connection is not None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
if self.sensor_connected and self.connection is not None:
|
||||||
|
self._ensure_query_worker()
|
||||||
|
self._ensure_ttl_config_task()
|
||||||
|
return
|
||||||
|
if not self._owns_connection and self.connection is not None:
|
||||||
|
self.sensor_connected = self._is_connected()
|
||||||
|
if not self.sensor_connected:
|
||||||
|
raise ConnectionError("OBD adapter is not connected")
|
||||||
|
self._ensure_query_worker()
|
||||||
|
self._ensure_ttl_config_task()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.logger.info("Attempting to connect to OBD adapter")
|
||||||
|
self.connection = await aio.to_thread(obd.OBD)
|
||||||
|
self.sensor_connected = self._is_connected()
|
||||||
|
if not self.sensor_connected:
|
||||||
|
raise ConnectionError("OBD adapter not found")
|
||||||
|
self._ensure_query_worker()
|
||||||
|
self._ensure_ttl_config_task()
|
||||||
|
self.logger.info("Connected to OBD adapter")
|
||||||
|
except Exception as exc:
|
||||||
|
self.sensor_connected = False
|
||||||
|
self.connection = None
|
||||||
|
raise ConnectionError(f"Unable to connect to OBD adapter: {exc}") from exc
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
self.sensor_connected = False
|
||||||
|
close = getattr(self.connection, "close", None)
|
||||||
|
self.connection = None
|
||||||
|
async with self._cache_lock:
|
||||||
|
self._query_cache.clear()
|
||||||
|
await self._fail_pending_queries(ConnectionError("OBD adapter is not connected"))
|
||||||
|
if callable(close):
|
||||||
|
await aio.to_thread(close)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scans(self) -> list[Scan]:
|
||||||
|
return [
|
||||||
|
Scan(
|
||||||
|
cmd=obd.commands.SPEED,
|
||||||
|
interval=0.25,
|
||||||
|
callback=lambda value: None,
|
||||||
|
transform=lambda value: _quantity_to_float(value, "mph"),
|
||||||
|
),
|
||||||
|
Scan(
|
||||||
|
cmd=obd.commands.FUEL_LEVEL,
|
||||||
|
interval=10.0,
|
||||||
|
callback=lambda value: None,
|
||||||
|
transform=lambda value: _quantity_to_float(value),
|
||||||
|
),
|
||||||
|
Scan(
|
||||||
|
cmd=obd.commands.OIL_TEMP,
|
||||||
|
interval=5.0,
|
||||||
|
callback=lambda value: None,
|
||||||
|
transform=lambda value: _quantity_to_float(value, "degF"),
|
||||||
|
),
|
||||||
|
Scan(
|
||||||
|
cmd=obd.commands.RPM,
|
||||||
|
interval=0.25,
|
||||||
|
callback=lambda value: None,
|
||||||
|
transform=lambda value: _quantity_to_float(value),
|
||||||
|
),
|
||||||
|
Scan(
|
||||||
|
cmd=obd.commands.COOLANT_TEMP,
|
||||||
|
interval=5.0,
|
||||||
|
callback=lambda value: None,
|
||||||
|
transform=lambda value: _quantity_to_float(value, "degF"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telemetry_commands(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"speed": obd.commands.SPEED,
|
||||||
|
"rpm": obd.commands.RPM,
|
||||||
|
"fuel": obd.commands.FUEL_LEVEL,
|
||||||
|
"oil-temp": obd.commands.OIL_TEMP,
|
||||||
|
"coolant-temp": obd.commands.COOLANT_TEMP,
|
||||||
|
}
|
||||||
|
|
||||||
|
def normalize_metric_value(self, metric_id: str, value: Any) -> float:
|
||||||
|
transforms = {
|
||||||
|
"speed": lambda raw: _quantity_to_float(raw, "mph"),
|
||||||
|
"rpm": lambda raw: _quantity_to_float(raw),
|
||||||
|
"fuel": lambda raw: _quantity_to_float(raw),
|
||||||
|
"oil-temp": lambda raw: _quantity_to_float(raw, "degF"),
|
||||||
|
"coolant-temp": lambda raw: _quantity_to_float(raw, "degF"),
|
||||||
|
}
|
||||||
|
return transforms[metric_id](value)
|
||||||
|
|
||||||
|
async def query_command(self, cmd: Any) -> Any | None:
|
||||||
|
if not self.sensor_connected or self.connection is None:
|
||||||
|
raise ConnectionError("OBD adapter is not connected")
|
||||||
|
cache_key = self._cache_key(cmd)
|
||||||
|
cached_value = await self._get_cached_value(cache_key)
|
||||||
|
if cached_value is not None:
|
||||||
|
return cached_value
|
||||||
|
self._ensure_query_worker()
|
||||||
|
return await self._enqueue_query(cmd, cache_key)
|
||||||
|
|
||||||
|
def _cache_key(self, cmd: Any) -> str:
|
||||||
|
return getattr(cmd, "name", str(cmd))
|
||||||
|
|
||||||
|
async def _get_cached_value(self, cache_key: str) -> Any | None:
|
||||||
|
ttl = self._ttl_for_command(cache_key)
|
||||||
|
if ttl <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = aio.get_running_loop().time()
|
||||||
|
async with self._cache_lock:
|
||||||
|
entry = self._query_cache.get(cache_key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if entry.expires_at <= now:
|
||||||
|
self._query_cache.pop(cache_key, None)
|
||||||
|
return None
|
||||||
|
return entry.value
|
||||||
|
|
||||||
|
async def _set_cached_value(self, cache_key: str, value: Any) -> None:
|
||||||
|
ttl = self._ttl_for_command(cache_key)
|
||||||
|
if ttl <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
expires_at = aio.get_running_loop().time() + ttl
|
||||||
|
async with self._cache_lock:
|
||||||
|
self._query_cache[cache_key] = CacheEntry(value=value, expires_at=expires_at)
|
||||||
|
|
||||||
|
def _ttl_for_command(self, cache_key: str) -> float:
|
||||||
|
return self._command_ttls.get(cache_key, self.cache_ttl)
|
||||||
|
|
||||||
|
def get_command_ttl_ms(self, command_name: str) -> int:
|
||||||
|
return int(round(self._ttl_for_command(command_name) * 1000.0))
|
||||||
|
|
||||||
|
def _ensure_ttl_config_task(self) -> None:
|
||||||
|
if self.ttl_config_path is None:
|
||||||
|
return
|
||||||
|
if self._ttl_config_task is None or self._ttl_config_task.done():
|
||||||
|
self._ttl_config_task = aio.create_task(self._watch_ttl_config())
|
||||||
|
|
||||||
|
async def _watch_ttl_config(self) -> None:
|
||||||
|
await self.reload_ttl_config(force=True)
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
await aio.sleep(self.ttl_config_scan_interval)
|
||||||
|
await self.reload_ttl_config()
|
||||||
|
|
||||||
|
async def reload_ttl_config(self, force: bool = False) -> None:
|
||||||
|
if self.ttl_config_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
stat_result = await aio.to_thread(self.ttl_config_path.stat)
|
||||||
|
except FileNotFoundError:
|
||||||
|
if self._command_ttls:
|
||||||
|
self.logger.warning("TTL config file not found: %s", self.ttl_config_path)
|
||||||
|
self._command_ttls = {}
|
||||||
|
self._ttl_config_mtime = None
|
||||||
|
return
|
||||||
|
|
||||||
|
mtime = stat_result.st_mtime
|
||||||
|
if not force and self._ttl_config_mtime == mtime:
|
||||||
|
return
|
||||||
|
|
||||||
|
contents = await aio.to_thread(self.ttl_config_path.read_text, "utf-8")
|
||||||
|
overrides = self._parse_ttl_config(contents)
|
||||||
|
self._command_ttls = overrides
|
||||||
|
self._ttl_config_mtime = mtime
|
||||||
|
self.logger.info("Loaded %d TTL overrides from %s", len(overrides), self.ttl_config_path.name)
|
||||||
|
|
||||||
|
def _parse_ttl_config(self, contents: str) -> dict[str, float]:
|
||||||
|
overrides: dict[str, float] = {}
|
||||||
|
for line_number, raw_line in enumerate(contents.splitlines(), start=1):
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
command_name, ttl_ms = [part.strip() for part in line.split(",", 1)]
|
||||||
|
except ValueError:
|
||||||
|
self.logger.warning("Invalid TTL config line %d: %s", line_number, raw_line)
|
||||||
|
continue
|
||||||
|
if not command_name:
|
||||||
|
self.logger.warning("Missing command name on TTL config line %d", line_number)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
overrides[command_name] = max(0.0, float(ttl_ms) / 1000.0)
|
||||||
|
except ValueError:
|
||||||
|
self.logger.warning("Invalid TTL value on line %d: %s", line_number, ttl_ms)
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
async def update_ttl_override(self, command_name: str, ttl_ms: int) -> None:
|
||||||
|
if self.ttl_config_path is None:
|
||||||
|
raise ValueError("TTL config path is not configured")
|
||||||
|
|
||||||
|
ttl_ms = max(0, int(ttl_ms))
|
||||||
|
lines: list[str] = []
|
||||||
|
found = False
|
||||||
|
|
||||||
|
if self.ttl_config_path.exists():
|
||||||
|
contents = await aio.to_thread(self.ttl_config_path.read_text, "utf-8")
|
||||||
|
for raw_line in contents.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
lines.append(raw_line)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
existing_command, _existing_ttl = [part.strip() for part in raw_line.split(",", 1)]
|
||||||
|
except ValueError:
|
||||||
|
lines.append(raw_line)
|
||||||
|
continue
|
||||||
|
if existing_command == command_name:
|
||||||
|
lines.append(f"{command_name},{ttl_ms}")
|
||||||
|
found = True
|
||||||
|
else:
|
||||||
|
lines.append(raw_line)
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
if lines and lines[-1].strip():
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"{command_name},{ttl_ms}")
|
||||||
|
|
||||||
|
text = "\n".join(lines).rstrip() + "\n"
|
||||||
|
await aio.to_thread(self.ttl_config_path.write_text, text, "utf-8")
|
||||||
|
await self.reload_ttl_config(force=True)
|
||||||
|
|
||||||
|
def _ensure_query_worker(self) -> None:
|
||||||
|
if self._query_worker_task is None or self._query_worker_task.done():
|
||||||
|
self._query_worker_task = aio.create_task(self._query_worker())
|
||||||
|
|
||||||
|
async def _enqueue_query(self, cmd: Any, cache_key: str) -> Any | None:
|
||||||
|
async with self._pending_lock:
|
||||||
|
existing = self._pending_queries.get(cache_key)
|
||||||
|
if existing is not None:
|
||||||
|
return await existing
|
||||||
|
|
||||||
|
future: aio.Future[Any | None] = aio.get_running_loop().create_future()
|
||||||
|
self._pending_queries[cache_key] = future
|
||||||
|
await self._query_queue.put((cmd, cache_key, future))
|
||||||
|
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def _query_worker(self) -> None:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
cmd: Any | None = None
|
||||||
|
cache_key = ""
|
||||||
|
future: aio.Future[Any | None] | None = None
|
||||||
|
try:
|
||||||
|
cmd, cache_key, future = await self._query_queue.get()
|
||||||
|
if future.cancelled():
|
||||||
|
continue
|
||||||
|
result = await self._execute_query(cmd, cache_key)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(result)
|
||||||
|
except aio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
if future is not None and not future.done():
|
||||||
|
future.set_exception(exc)
|
||||||
|
finally:
|
||||||
|
if future is not None:
|
||||||
|
async with self._pending_lock:
|
||||||
|
current = self._pending_queries.get(cache_key)
|
||||||
|
if current is future:
|
||||||
|
self._pending_queries.pop(cache_key, None)
|
||||||
|
if cmd is not None:
|
||||||
|
self._query_queue.task_done()
|
||||||
|
|
||||||
|
async def _execute_query(self, cmd: Any, cache_key: str) -> Any | None:
|
||||||
|
await self._acquire_query_slot()
|
||||||
|
connection = self.connection
|
||||||
|
if connection is None or not self.sensor_connected:
|
||||||
|
raise ConnectionError("OBD adapter is not connected")
|
||||||
|
try:
|
||||||
|
response = await aio.to_thread(connection.query, cmd)
|
||||||
|
except Exception as exc:
|
||||||
|
await self.disconnect()
|
||||||
|
raise ConnectionError(f"OBD query failed for {cmd}: {exc}") from exc
|
||||||
|
if response is None or getattr(response, "is_null", lambda: False)():
|
||||||
|
self.logger.debug("No response for %s", cmd)
|
||||||
|
return None
|
||||||
|
value = getattr(response, "value", None)
|
||||||
|
if value is None:
|
||||||
|
self.logger.debug("Empty response for %s", cmd)
|
||||||
|
return None
|
||||||
|
await self._set_cached_value(cache_key, value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def _fail_pending_queries(self, exc: Exception) -> None:
|
||||||
|
async with self._pending_lock:
|
||||||
|
pending = list(self._pending_queries.values())
|
||||||
|
self._pending_queries.clear()
|
||||||
|
|
||||||
|
for future in pending:
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(exc)
|
||||||
|
|
||||||
|
while not self._query_queue.empty():
|
||||||
|
try:
|
||||||
|
_cmd, _cache_key, future = self._query_queue.get_nowait()
|
||||||
|
except aio.QueueEmpty:
|
||||||
|
break
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(exc)
|
||||||
|
self._query_queue.task_done()
|
||||||
|
|
||||||
|
async def _acquire_query_slot(self) -> None:
|
||||||
|
if self.query_rate_limit <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
interval = 1.0 / self.query_rate_limit
|
||||||
|
loop = aio.get_running_loop()
|
||||||
|
|
||||||
|
async with self._rate_limit_lock:
|
||||||
|
now = loop.time()
|
||||||
|
wait_time = max(0.0, self._next_query_time - now)
|
||||||
|
scheduled_time = max(now, self._next_query_time)
|
||||||
|
self._next_query_time = scheduled_time + interval
|
||||||
|
|
||||||
|
if wait_time > 0:
|
||||||
|
await aio.sleep(wait_time)
|
||||||
|
|
||||||
|
async def query_value(self, scan: Scan) -> float | None:
|
||||||
|
value = await self.query_command(scan.cmd)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if scan.transform is None:
|
||||||
|
return _quantity_to_float(value)
|
||||||
|
return scan.transform(value)
|
||||||
|
|
||||||
|
async def query_display_value(self, cmd: Any) -> str:
|
||||||
|
value = await self.query_command(cmd)
|
||||||
|
if value is None:
|
||||||
|
return "--"
|
||||||
|
return format_obd_value(value)
|
||||||
|
|
||||||
|
async def sensor_data_loop(self, scan: Scan) -> None:
|
||||||
|
if scan.callback is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"Starting sensor acquisition for %s at %.2fs interval",
|
||||||
|
scan.cmd,
|
||||||
|
scan.interval,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while self.sensor_connected and not self._stop_event.is_set():
|
||||||
|
value = await self.query_value(scan)
|
||||||
|
if value is not None:
|
||||||
|
scan.callback(value)
|
||||||
|
await aio.sleep(scan.interval)
|
||||||
|
except ConnectionError as exc:
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
self.logger.info("Sensor loop stopped for %s during shutdown", scan.cmd)
|
||||||
|
return
|
||||||
|
self.logger.error("Sensor loop lost connection for %s: %s", scan.cmd, exc)
|
||||||
|
self.sensor_connected = False
|
||||||
|
self._stop_event.set()
|
||||||
|
raise
|
||||||
|
except aio.CancelledError:
|
||||||
|
self.logger.info("Sensor task for %s cancelled", scan.cmd)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
self.logger.exception("Sensor loop failed for %s", scan.cmd)
|
||||||
|
self.sensor_connected = False
|
||||||
|
self._stop_event.set()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
self.logger.info("Sensor loop terminated for %s", scan.cmd)
|
||||||
|
|
||||||
|
async def print_report(self, report: Report, interval: float = 1.0) -> None:
|
||||||
|
try:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
self.logger.info(report.model_dump())
|
||||||
|
await aio.sleep(interval)
|
||||||
|
except aio.CancelledError:
|
||||||
|
self.logger.info("Report logger cancelled")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def start(self, report: Report) -> None:
|
||||||
|
if not self.sensor_connected:
|
||||||
|
self.logger.warning("No OBD adapter connected; telemetry polling not started")
|
||||||
|
return
|
||||||
|
|
||||||
|
scans = self.scans
|
||||||
|
scans[0].callback = report.set_speed
|
||||||
|
scans[1].callback = report.set_fuel
|
||||||
|
scans[2].callback = report.set_oil_temp
|
||||||
|
scans[3].callback = report.set_rpm
|
||||||
|
scans[4].callback = report.set_coolant_temp
|
||||||
|
|
||||||
|
self._tasks = [aio.create_task(self.print_report(report))]
|
||||||
|
self._tasks.extend(aio.create_task(self.sensor_data_loop(scan)) for scan in scans)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await aio.gather(*self._tasks)
|
||||||
|
finally:
|
||||||
|
await self.stop()
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
|
||||||
|
current = aio.current_task()
|
||||||
|
for task in self._tasks:
|
||||||
|
if task is not current and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
for task in self._tasks:
|
||||||
|
if task is current:
|
||||||
|
continue
|
||||||
|
with contextlib.suppress(aio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
self._tasks.clear()
|
||||||
|
|
||||||
|
if self._query_worker_task is not None:
|
||||||
|
self._query_worker_task.cancel()
|
||||||
|
with contextlib.suppress(aio.CancelledError):
|
||||||
|
await self._query_worker_task
|
||||||
|
self._query_worker_task = None
|
||||||
|
|
||||||
|
if self._ttl_config_task is not None:
|
||||||
|
self._ttl_config_task.cancel()
|
||||||
|
with contextlib.suppress(aio.CancelledError):
|
||||||
|
await self._ttl_config_task
|
||||||
|
self._ttl_config_task = None
|
||||||
|
|
||||||
|
await self.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
class SimulatedOBD2Interface(OBD2Interface):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
logger: logging.Logger,
|
||||||
|
query_rate_limit: float = 10.0,
|
||||||
|
ttl_config_path: str | None = None,
|
||||||
|
ttl_config_scan_interval: float = 2.0,
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
logger,
|
||||||
|
connection=SimulatedOBDConnection(),
|
||||||
|
query_rate_limit=query_rate_limit,
|
||||||
|
ttl_config_path=ttl_config_path,
|
||||||
|
ttl_config_scan_interval=ttl_config_scan_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s.%(msecs)03d - [%(levelname)s] - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
report = Report()
|
||||||
|
aio.run(OBD2Interface(logging.getLogger()).start(report))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logging.info("Program interrupted by user (Ctrl+C).")
|
||||||
390
obd2_tui.py
Normal file
390
obd2_tui.py
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import obd
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Container
|
||||||
|
from textual.widgets import DataTable, Digits, Footer, Header, Input, Label, RichLog, Static
|
||||||
|
|
||||||
|
from models import Report
|
||||||
|
from obd2_interface import OBD2Interface, SimulatedOBD2Interface, format_obd_value
|
||||||
|
|
||||||
|
|
||||||
|
METRICS = [
|
||||||
|
("speed", "Speed", "mph"),
|
||||||
|
("rpm", "RPM", ""),
|
||||||
|
("fuel", "Fuel", "%"),
|
||||||
|
("coolant-temp", "Coolant Temp", "F"),
|
||||||
|
("oil-temp", "Oil Temp", "F"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MODE_LABELS = {
|
||||||
|
1: "Mode 01 Current Data",
|
||||||
|
2: "Mode 02 Freeze Frame",
|
||||||
|
3: "Mode 03 Stored DTCs",
|
||||||
|
4: "Mode 04 Clear DTCs",
|
||||||
|
6: "Mode 06 Onboard Monitoring",
|
||||||
|
7: "Mode 07 Pending DTCs",
|
||||||
|
9: "Mode 09 Vehicle Info",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_metric_value(report: Report, metric_id: str) -> str:
|
||||||
|
values = {
|
||||||
|
"speed": f"{report.speed_mph:05.1f}",
|
||||||
|
"rpm": f"{report.rpm:05.0f}",
|
||||||
|
"fuel": f"{report.fuel_level_pct:05.1f}",
|
||||||
|
"oil-temp": f"{report.oil_temp_f:05.1f}",
|
||||||
|
"coolant-temp": f"{report.coolant_temp_f:05.1f}",
|
||||||
|
}
|
||||||
|
return values[metric_id]
|
||||||
|
|
||||||
|
|
||||||
|
class RichLogHandler(logging.Handler):
|
||||||
|
def __init__(self, app: "OBD2App") -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
message = self.format(record)
|
||||||
|
try:
|
||||||
|
self.app.call_from_thread(self.app.write_log, message)
|
||||||
|
except RuntimeError:
|
||||||
|
self.app.write_log(message)
|
||||||
|
|
||||||
|
|
||||||
|
class OBD2App(App[None]):
|
||||||
|
CSS_PATH = "ui.css"
|
||||||
|
POLL_CONCURRENCY = 12
|
||||||
|
POLL_INTERVAL = 0.01
|
||||||
|
RECONNECT_DELAY = 5.0
|
||||||
|
BINDINGS = [
|
||||||
|
("q", "quit", "Quit"),
|
||||||
|
("b", "toggle_border", "Toggle border"),
|
||||||
|
("1", "select_mode(1)", "Mode 1"),
|
||||||
|
("2", "select_mode(2)", "Mode 2"),
|
||||||
|
("3", "select_mode(3)", "Mode 3"),
|
||||||
|
("4", "select_mode(4)", "Mode 4"),
|
||||||
|
("6", "select_mode(6)", "Mode 6"),
|
||||||
|
("7", "select_mode(7)", "Mode 7"),
|
||||||
|
("9", "select_mode(9)", "Mode 9"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
report: Report,
|
||||||
|
interface_factory: type[OBD2Interface] = OBD2Interface,
|
||||||
|
query_rate_limit: float = 10.0,
|
||||||
|
ttl_config_path: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__()
|
||||||
|
self.report = report
|
||||||
|
self.interface_factory = interface_factory
|
||||||
|
self.query_rate_limit = query_rate_limit
|
||||||
|
self.ttl_config_path = ttl_config_path
|
||||||
|
self.logger = logging.getLogger("obd2")
|
||||||
|
self.interface: OBD2Interface | None = None
|
||||||
|
self.poll_task: asyncio.Task[None] | None = None
|
||||||
|
self.double_border = False
|
||||||
|
self.selected_mode = 1
|
||||||
|
self.last_command_values: dict[str, str] = {}
|
||||||
|
self.command_value_cache: dict[str, str] = {}
|
||||||
|
self.log_handler: RichLogHandler | None = None
|
||||||
|
self.ttl_edit_command: str | None = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Container(
|
||||||
|
Container(*[self.compose_metric(metric_id, label, unit) for metric_id, label, unit in METRICS], id="dashboard"),
|
||||||
|
Container(
|
||||||
|
DataTable(id="commands-table"),
|
||||||
|
Container(
|
||||||
|
Label("Selected TTL (ms)", id="ttl-editor-label"),
|
||||||
|
Input(placeholder="TTL in ms", id="ttl-editor"),
|
||||||
|
id="ttl-editor-pane",
|
||||||
|
),
|
||||||
|
id="table-pane",
|
||||||
|
),
|
||||||
|
id="main-pane",
|
||||||
|
)
|
||||||
|
yield Static("", id="mode-banner")
|
||||||
|
yield RichLog(id="log-panel", auto_scroll=True, wrap=True, highlight=True, markup=False)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def compose_metric(self, metric_id: str, label: str, unit: str) -> Container:
|
||||||
|
unit_text = unit or "value"
|
||||||
|
return Container(
|
||||||
|
Label(label, classes="metric-label"),
|
||||||
|
Digits(format_metric_value(self.report, metric_id), id=f"{metric_id}-digits", classes="metric-digits"),
|
||||||
|
Label(unit_text, classes="metric-unit"),
|
||||||
|
classes="metric-card",
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.configure_logging()
|
||||||
|
self.interface = self.interface_factory(
|
||||||
|
self.logger,
|
||||||
|
query_rate_limit=self.query_rate_limit,
|
||||||
|
ttl_config_path=self.ttl_config_path,
|
||||||
|
)
|
||||||
|
self.configure_commands_table()
|
||||||
|
self.load_mode_table(self.selected_mode)
|
||||||
|
self.poll_task = asyncio.create_task(self.poll_commands())
|
||||||
|
self.logger.info("Mounted OBD2 dashboard")
|
||||||
|
|
||||||
|
async def on_unmount(self) -> None:
|
||||||
|
if self.interface is not None:
|
||||||
|
await self.interface.stop()
|
||||||
|
if self.poll_task is not None:
|
||||||
|
self.poll_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self.poll_task
|
||||||
|
self.teardown_logging()
|
||||||
|
|
||||||
|
def action_toggle_border(self) -> None:
|
||||||
|
self.double_border = not self.double_border
|
||||||
|
border = ("double", "yellow") if self.double_border else ("solid", "white")
|
||||||
|
for card in self.query(".metric-card"):
|
||||||
|
card.styles.border = border
|
||||||
|
|
||||||
|
def action_select_mode(self, mode: int) -> None:
|
||||||
|
if mode not in MODE_LABELS:
|
||||||
|
return
|
||||||
|
self.selected_mode = mode
|
||||||
|
self.load_mode_table(mode)
|
||||||
|
self.logger.info("Selected %s", MODE_LABELS[mode])
|
||||||
|
|
||||||
|
def configure_logging(self) -> None:
|
||||||
|
self.log_handler = RichLogHandler(self)
|
||||||
|
self.log_handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
"%(asctime)s.%(msecs)03d [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
root_logger.setLevel(logging.INFO)
|
||||||
|
root_logger.addHandler(self.log_handler)
|
||||||
|
self.logger.propagate = True
|
||||||
|
|
||||||
|
def teardown_logging(self) -> None:
|
||||||
|
if self.log_handler is None:
|
||||||
|
return
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
root_logger.removeHandler(self.log_handler)
|
||||||
|
self.log_handler = None
|
||||||
|
|
||||||
|
def write_log(self, message: str) -> None:
|
||||||
|
self.query_one("#log-panel", RichLog).write(message)
|
||||||
|
|
||||||
|
def configure_commands_table(self) -> None:
|
||||||
|
table = self.query_one("#commands-table", DataTable)
|
||||||
|
table.cursor_type = "row"
|
||||||
|
table.zebra_stripes = True
|
||||||
|
table.add_column("Code", key="code", width=10)
|
||||||
|
table.add_column("Name", key="name", width=28)
|
||||||
|
table.add_column("Description", key="desc", width=44)
|
||||||
|
table.add_column("TTL (ms)", key="ttl", width=12)
|
||||||
|
table.add_column("Value", key="value", width=24)
|
||||||
|
|
||||||
|
def load_mode_table(self, mode: int) -> None:
|
||||||
|
table = self.query_one("#commands-table", DataTable)
|
||||||
|
table.clear(columns=False)
|
||||||
|
self.last_command_values = {}
|
||||||
|
commands = self.get_mode_commands(mode)
|
||||||
|
for command in commands:
|
||||||
|
pid = getattr(command, "pid", None)
|
||||||
|
code = f"{mode:02X}.{pid:02X}" if pid is not None else f"{mode:02X}"
|
||||||
|
cached_value = self.command_value_cache.get(command.name, "--")
|
||||||
|
ttl_value = self.get_command_ttl_display(command.name)
|
||||||
|
table.add_row(code, command.name, getattr(command, "desc", ""), ttl_value, cached_value, key=command.name)
|
||||||
|
self.last_command_values[command.name] = cached_value
|
||||||
|
banner = self.query_one("#mode-banner", Static)
|
||||||
|
banner.update(f"{MODE_LABELS[mode]} Press 1,2,3,4,6,7,9 to switch modes")
|
||||||
|
if commands:
|
||||||
|
self.set_ttl_editor(commands[0].name)
|
||||||
|
|
||||||
|
def get_mode_commands(self, mode: int) -> list[object]:
|
||||||
|
return [command for command in obd.commands[mode] if command is not None]
|
||||||
|
|
||||||
|
def get_command_ttl_display(self, command_name: str) -> str:
|
||||||
|
if self.interface is None:
|
||||||
|
return "--"
|
||||||
|
return str(self.interface.get_command_ttl_ms(command_name))
|
||||||
|
|
||||||
|
def set_ttl_editor(self, command_name: str) -> None:
|
||||||
|
self.ttl_edit_command = command_name
|
||||||
|
self.query_one("#ttl-editor-label", Label).update(f"Selected TTL (ms) for {command_name}")
|
||||||
|
self.query_one("#ttl-editor", Input).value = self.get_command_ttl_display(command_name)
|
||||||
|
|
||||||
|
def update_metric_from_command(self, metric_id: str, raw_value: object) -> None:
|
||||||
|
if self.interface is None:
|
||||||
|
return
|
||||||
|
numeric_value = self.interface.normalize_metric_value(metric_id, raw_value)
|
||||||
|
setters = {
|
||||||
|
"speed": self.report.set_speed,
|
||||||
|
"rpm": self.report.set_rpm,
|
||||||
|
"fuel": self.report.set_fuel,
|
||||||
|
"oil-temp": self.report.set_oil_temp,
|
||||||
|
"coolant-temp": self.report.set_coolant_temp,
|
||||||
|
}
|
||||||
|
setters[metric_id](numeric_value)
|
||||||
|
self.query_one(f"#{metric_id}-digits", Digits).update(format_metric_value(self.report, metric_id))
|
||||||
|
|
||||||
|
def build_query_plan(self, mode: int) -> list[tuple[str | None, Any]]:
|
||||||
|
if self.interface is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
query_plan: list[tuple[str | None, Any]] = []
|
||||||
|
seen_names: set[str] = set()
|
||||||
|
|
||||||
|
for metric_id, command in self.interface.telemetry_commands.items():
|
||||||
|
command_name = getattr(command, "name", str(command))
|
||||||
|
query_plan.append((metric_id, command))
|
||||||
|
seen_names.add(command_name)
|
||||||
|
|
||||||
|
visible_command_names = self.get_visible_command_names()
|
||||||
|
for command in self.get_mode_commands(mode):
|
||||||
|
command_name = getattr(command, "name", str(command))
|
||||||
|
if command_name not in visible_command_names:
|
||||||
|
continue
|
||||||
|
if command_name in seen_names:
|
||||||
|
continue
|
||||||
|
query_plan.append((None, command))
|
||||||
|
seen_names.add(command_name)
|
||||||
|
|
||||||
|
return query_plan
|
||||||
|
|
||||||
|
def get_visible_command_names(self) -> set[str]:
|
||||||
|
table = self.query_one("#commands-table", DataTable)
|
||||||
|
if table.row_count == 0:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
first_visible_row = max(0, int(table.scroll_y))
|
||||||
|
visible_height = max(1, table.scrollable_content_region.height)
|
||||||
|
last_visible_row = min(table.row_count, first_visible_row + visible_height)
|
||||||
|
|
||||||
|
visible_names: set[str] = set()
|
||||||
|
for row_index in range(first_visible_row, last_visible_row):
|
||||||
|
row = table.get_row_at(row_index)
|
||||||
|
if len(row) < 2:
|
||||||
|
continue
|
||||||
|
visible_names.add(str(row[1]))
|
||||||
|
return visible_names
|
||||||
|
|
||||||
|
async def query_commands_concurrently(self, query_plan: list[tuple[str | None, Any]]) -> list[tuple[str | None, Any, Any | None]]:
|
||||||
|
if self.interface is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
semaphore = asyncio.Semaphore(self.POLL_CONCURRENCY)
|
||||||
|
|
||||||
|
async def query_one(metric_id: str | None, command: Any) -> tuple[str | None, Any, Any | None]:
|
||||||
|
async with semaphore:
|
||||||
|
raw_value = await self.interface.query_command(command)
|
||||||
|
return (metric_id, command, raw_value)
|
||||||
|
|
||||||
|
tasks = [query_one(metric_id, command) for metric_id, command in query_plan]
|
||||||
|
return await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
def apply_query_results(self, results: list[tuple[str | None, Any, Any | None]]) -> None:
|
||||||
|
table = self.query_one("#commands-table", DataTable)
|
||||||
|
|
||||||
|
for metric_id, command, raw_value in results:
|
||||||
|
command_name = getattr(command, "name", str(command))
|
||||||
|
value = "--" if raw_value is None else format_obd_value(raw_value)
|
||||||
|
|
||||||
|
if metric_id is not None and raw_value is not None:
|
||||||
|
self.update_metric_from_command(metric_id, raw_value)
|
||||||
|
|
||||||
|
if self.last_command_values.get(command_name) == value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
table.update_cell(command_name, "value", value)
|
||||||
|
self.last_command_values[command_name] = value
|
||||||
|
self.command_value_cache[command_name] = value
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||||
|
self.set_ttl_editor(str(event.row_key))
|
||||||
|
|
||||||
|
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
if event.input.id != "ttl-editor" or self.interface is None or self.ttl_edit_command is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_value = event.value.strip()
|
||||||
|
try:
|
||||||
|
ttl_ms = int(raw_value)
|
||||||
|
except ValueError:
|
||||||
|
self.logger.error("Invalid TTL value: %s", raw_value)
|
||||||
|
self.set_ttl_editor(self.ttl_edit_command)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.interface.update_ttl_override(self.ttl_edit_command, ttl_ms)
|
||||||
|
self.query_one("#commands-table", DataTable).update_cell(self.ttl_edit_command, "ttl", str(ttl_ms))
|
||||||
|
self.logger.info("Updated TTL for %s to %d ms", self.ttl_edit_command, ttl_ms)
|
||||||
|
self.set_ttl_editor(self.ttl_edit_command)
|
||||||
|
|
||||||
|
async def poll_commands(self) -> None:
|
||||||
|
while True:
|
||||||
|
if self.interface is None:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.interface.connect()
|
||||||
|
mode = self.selected_mode
|
||||||
|
query_plan = self.build_query_plan(mode)
|
||||||
|
results = await self.query_commands_concurrently(query_plan)
|
||||||
|
|
||||||
|
if mode == self.selected_mode:
|
||||||
|
self.apply_query_results(results)
|
||||||
|
|
||||||
|
await asyncio.sleep(self.POLL_INTERVAL)
|
||||||
|
except ConnectionError as exc:
|
||||||
|
self.logger.error("%s", exc)
|
||||||
|
await asyncio.sleep(self.RECONNECT_DELAY)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="OBD2 telemetry dashboard")
|
||||||
|
parser.add_argument(
|
||||||
|
"--simulated",
|
||||||
|
action="store_true",
|
||||||
|
help="Run the dashboard with continuously generated telemetry data",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--qps",
|
||||||
|
type=float,
|
||||||
|
default=10.0,
|
||||||
|
help="Maximum OBD queries per second across the app",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ttl-config",
|
||||||
|
default="command_ttl.conf",
|
||||||
|
help="Path to COMMAND,ttl_ms cache TTL overrides file",
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s.%(msecs)03d - [%(levelname)s] - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
interface_factory = SimulatedOBD2Interface if args.simulated else OBD2Interface
|
||||||
|
app = OBD2App(
|
||||||
|
Report(),
|
||||||
|
interface_factory=interface_factory,
|
||||||
|
query_rate_limit=args.qps,
|
||||||
|
ttl_config_path=args.ttl_config,
|
||||||
|
)
|
||||||
|
app.run()
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
python-can[serial]==4.5.0
|
||||||
|
obd==0.7.3
|
||||||
|
pydantic==2.12.5
|
||||||
|
textual==6.8.0
|
||||||
|
textual-dev==1.8.0
|
||||||
80
ui.css
Normal file
80
ui.css
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
Screen {
|
||||||
|
layout: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dashboard {
|
||||||
|
layout: grid;
|
||||||
|
grid-size: 3 2;
|
||||||
|
grid-gutter: 1 2;
|
||||||
|
padding: 1 0;
|
||||||
|
height: auto;
|
||||||
|
width: 72;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-pane {
|
||||||
|
layout: horizontal;
|
||||||
|
height: 1fr;
|
||||||
|
margin: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#table-pane {
|
||||||
|
layout: vertical;
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mode-banner {
|
||||||
|
padding: 0 2;
|
||||||
|
color: $text;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
border: solid white;
|
||||||
|
padding: 1;
|
||||||
|
height: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
text-style: bold;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-digits {
|
||||||
|
color: ansi_bright_green;
|
||||||
|
text-style: bold;
|
||||||
|
margin: 1 0;
|
||||||
|
height: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-unit {
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
#commands-table {
|
||||||
|
height: 1fr;
|
||||||
|
width: 1fr;
|
||||||
|
border: heavy $primary;
|
||||||
|
margin: 1 0 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ttl-editor-pane {
|
||||||
|
layout: horizontal;
|
||||||
|
height: 3;
|
||||||
|
margin: 1 0 0 2;
|
||||||
|
align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ttl-editor-label {
|
||||||
|
width: 28;
|
||||||
|
content-align: left middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ttl-editor {
|
||||||
|
width: 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-panel {
|
||||||
|
height: 12;
|
||||||
|
border: heavy $accent;
|
||||||
|
margin: 0 2 1 2;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user