This commit is contained in:
Jimmy Allen
2026-04-01 19:34:26 -04:00
commit a994900a6a
6 changed files with 1414 additions and 0 deletions

291
command_ttl.conf Normal file
View 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
View 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
View 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
View 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
View 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
View 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;
}