add code
This commit is contained in:
127
obd2_client/README.md
Normal file
127
obd2_client/README.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# OBD2 Client для Skoda Kodiaq 2021 на RPi5
|
||||||
|
|
||||||
|
Python приложение для чтения OBD2 данных через WaveShare 2-CH CAN HAT.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Конфигурация RPi5
|
||||||
|
|
||||||
|
### /boot/config.txt
|
||||||
|
```
|
||||||
|
dtparam=spi=on
|
||||||
|
dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=23
|
||||||
|
```
|
||||||
|
|
||||||
|
### Инициализация CAN интерфейса
|
||||||
|
```bash
|
||||||
|
sudo ip link set can0 up type can bitrate 500000
|
||||||
|
sudo ifconfig can0 txqueuelen 65536
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Запуск с реальным авто
|
||||||
|
```bash
|
||||||
|
python -m src.main --interface can0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Только сканирование PID
|
||||||
|
```bash
|
||||||
|
python -m src.main --interface can0 --scan-only
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тестирование с виртуальным CAN
|
||||||
|
```bash
|
||||||
|
# Создание виртуального интерфейса
|
||||||
|
sudo modprobe vcan
|
||||||
|
sudo ip link add dev vcan0 type vcan
|
||||||
|
sudo ip link set up vcan0
|
||||||
|
|
||||||
|
# Запуск клиента
|
||||||
|
python -m src.main --interface vcan0 --virtual
|
||||||
|
```
|
||||||
|
|
||||||
|
### Параметры командной строки
|
||||||
|
|
||||||
|
| Параметр | Описание |
|
||||||
|
|----------|----------|
|
||||||
|
| `-i, --interface` | CAN интерфейс (can0, vcan0) |
|
||||||
|
| `-c, --config` | Путь к config.json |
|
||||||
|
| `-v, --virtual` | Использовать виртуальный CAN |
|
||||||
|
| `--scan-only` | Только сканировать PID |
|
||||||
|
| `--debug` | Включить отладочный вывод |
|
||||||
|
|
||||||
|
## Поддерживаемые PID
|
||||||
|
|
||||||
|
| PID | Параметр | Единицы |
|
||||||
|
|-----|----------|---------|
|
||||||
|
| 0x04 | Engine Load | % |
|
||||||
|
| 0x05 | Coolant Temp | °C |
|
||||||
|
| 0x0C | Engine RPM | RPM |
|
||||||
|
| 0x0D | Vehicle Speed | km/h |
|
||||||
|
| 0x0F | Intake Air Temp | °C |
|
||||||
|
| 0x10 | MAF Rate | g/s |
|
||||||
|
| 0x11 | Throttle Position | % |
|
||||||
|
| 0x2F | Fuel Level | % |
|
||||||
|
| 0x5C | Oil Temperature | °C |
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Файл `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"can": {
|
||||||
|
"interface": "can0",
|
||||||
|
"bitrate": 500000,
|
||||||
|
"virtual": false
|
||||||
|
},
|
||||||
|
"obd2": {
|
||||||
|
"request_id": "0x7DF",
|
||||||
|
"response_id": "0x7E8",
|
||||||
|
"timeout": 0.1
|
||||||
|
},
|
||||||
|
"polling": {
|
||||||
|
"interval_fast": 0.1,
|
||||||
|
"interval_slow": 1.0,
|
||||||
|
"fast_pids": ["0x0C", "0x0D", "0x11"],
|
||||||
|
"slow_pids": ["0x05", "0x2F", "0x5C"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
- `OBD2_CONFIG_PATH` - путь к config.json
|
||||||
|
- `OBD2_CAN_INTERFACE` - CAN интерфейс
|
||||||
|
- `OBD2_CAN_BITRATE` - битрейт
|
||||||
|
- `OBD2_CAN_VIRTUAL` - использовать виртуальный CAN (true/false)
|
||||||
|
- `OBD2_TIMEOUT` - таймаут ответа
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
obd2_client/
|
||||||
|
├── src/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py # Точка входа, CLI
|
||||||
|
│ ├── config.py # Конфигурация
|
||||||
|
│ ├── logger.py # Логирование
|
||||||
|
│ ├── can/
|
||||||
|
│ │ ├── frame.py # CAN фрейм dataclass
|
||||||
|
│ │ └── interface.py # Абстракция CAN шины
|
||||||
|
│ ├── obd2/
|
||||||
|
│ │ ├── pids.py # Определения PID
|
||||||
|
│ │ ├── protocol.py # OBD2 запросы/ответы
|
||||||
|
│ │ └── scanner.py # Автодетект PID
|
||||||
|
│ └── vehicle/
|
||||||
|
│ ├── state.py # Состояние авто
|
||||||
|
│ └── poller.py # Циклический опрос
|
||||||
|
├── config.json
|
||||||
|
├── requirements.txt
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
1
obd2_client/requirements.txt
Normal file
1
obd2_client/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
python-can>=4.0.0
|
||||||
3
obd2_client/src/__init__.py
Normal file
3
obd2_client/src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""OBD2 Client for Skoda Kodiaq 2021 on RPi5 with WaveShare 2-CH CAN HAT."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
6
obd2_client/src/can/__init__.py
Normal file
6
obd2_client/src/can/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""CAN bus abstraction layer."""
|
||||||
|
|
||||||
|
from .frame import CANFrame
|
||||||
|
from .interface import CANInterface
|
||||||
|
|
||||||
|
__all__ = ["CANFrame", "CANInterface"]
|
||||||
77
obd2_client/src/can/frame.py
Normal file
77
obd2_client/src/can/frame.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""CAN frame data structures."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CANFrame:
|
||||||
|
"""Represents a CAN bus frame.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
arbitration_id: CAN message identifier (11-bit or 29-bit)
|
||||||
|
data: Payload data (up to 8 bytes for standard CAN)
|
||||||
|
timestamp: Time when frame was received/created
|
||||||
|
is_extended_id: True if using 29-bit extended identifier
|
||||||
|
is_remote_frame: True if this is a remote transmission request
|
||||||
|
is_error_frame: True if this is an error frame
|
||||||
|
"""
|
||||||
|
|
||||||
|
arbitration_id: int
|
||||||
|
data: bytes = field(default_factory=bytes)
|
||||||
|
timestamp: float = field(default_factory=time.time)
|
||||||
|
is_extended_id: bool = False
|
||||||
|
is_remote_frame: bool = False
|
||||||
|
is_error_frame: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Validate and normalize data."""
|
||||||
|
if isinstance(self.data, (list, tuple)):
|
||||||
|
self.data = bytes(self.data)
|
||||||
|
if len(self.data) > 8:
|
||||||
|
raise ValueError("CAN frame data cannot exceed 8 bytes")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dlc(self) -> int:
|
||||||
|
"""Data length code (number of data bytes)."""
|
||||||
|
return len(self.data)
|
||||||
|
|
||||||
|
def pad_to_8_bytes(self) -> "CANFrame":
|
||||||
|
"""Return a new frame with data padded to 8 bytes with zeros."""
|
||||||
|
padded_data = self.data + bytes(8 - len(self.data))
|
||||||
|
return CANFrame(
|
||||||
|
arbitration_id=self.arbitration_id,
|
||||||
|
data=padded_data,
|
||||||
|
timestamp=self.timestamp,
|
||||||
|
is_extended_id=self.is_extended_id,
|
||||||
|
is_remote_frame=self.is_remote_frame,
|
||||||
|
is_error_frame=self.is_error_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_hex_string(self) -> str:
|
||||||
|
"""Return hex representation of frame data."""
|
||||||
|
return " ".join(f"{b:02X}" for b in self.data)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Human-readable string representation."""
|
||||||
|
id_str = f"0x{self.arbitration_id:03X}"
|
||||||
|
if self.is_extended_id:
|
||||||
|
id_str = f"0x{self.arbitration_id:08X}"
|
||||||
|
data_str = self.to_hex_string()
|
||||||
|
return f"CAN[{id_str}] [{self.dlc}] {data_str}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_obd2_request(cls, mode: int, pid: int, request_id: int = 0x7DF) -> "CANFrame":
|
||||||
|
"""Create a standard OBD2 request frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: OBD2 mode (e.g., 0x01 for current data)
|
||||||
|
pid: Parameter ID to request
|
||||||
|
request_id: CAN arbitration ID for requests (default: 0x7DF broadcast)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CANFrame configured as OBD2 request
|
||||||
|
"""
|
||||||
|
data = bytes([0x02, mode, pid, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||||
|
return cls(arbitration_id=request_id, data=data)
|
||||||
197
obd2_client/src/can/interface.py
Normal file
197
obd2_client/src/can/interface.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""CAN bus interface abstraction."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Optional, List
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
try:
|
||||||
|
import can
|
||||||
|
except ImportError:
|
||||||
|
can = None
|
||||||
|
|
||||||
|
from .frame import CANFrame
|
||||||
|
from ..logger import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
class CANInterface:
|
||||||
|
"""CAN bus interface using python-can library.
|
||||||
|
|
||||||
|
Supports both physical CAN interfaces (can0) and virtual interfaces (vcan0).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
interface: str = "can0",
|
||||||
|
bitrate: int = 500000,
|
||||||
|
virtual: bool = False,
|
||||||
|
):
|
||||||
|
"""Initialize CAN interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interface: CAN interface name (e.g., 'can0', 'vcan0')
|
||||||
|
bitrate: CAN bus bitrate (default: 500000 for OBD2)
|
||||||
|
virtual: If True, use virtual CAN (no bitrate needed)
|
||||||
|
"""
|
||||||
|
self.interface = interface
|
||||||
|
self.bitrate = bitrate
|
||||||
|
self.virtual = virtual
|
||||||
|
self._bus: Optional["can.Bus"] = None
|
||||||
|
self._logger = get_logger("obd2_client.can")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""Check if interface is connected."""
|
||||||
|
return self._bus is not None
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
"""Connect to CAN bus.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If python-can is not installed
|
||||||
|
can.CanError: If connection fails
|
||||||
|
"""
|
||||||
|
if can is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"python-can is not installed. Run: pip install python-can"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._bus is not None:
|
||||||
|
self._logger.warning("Already connected to CAN bus")
|
||||||
|
return
|
||||||
|
|
||||||
|
bus_type = "virtual" if self.virtual else "socketcan"
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
f"Connecting to {self.interface} (type={bus_type}, bitrate={self.bitrate})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.virtual:
|
||||||
|
self._bus = can.Bus(channel=self.interface, interface="virtual")
|
||||||
|
else:
|
||||||
|
self._bus = can.Bus(
|
||||||
|
channel=self.interface,
|
||||||
|
interface="socketcan",
|
||||||
|
bitrate=self.bitrate,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._logger.info(f"Connected to {self.interface}")
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""Disconnect from CAN bus."""
|
||||||
|
if self._bus is not None:
|
||||||
|
self._logger.info(f"Disconnecting from {self.interface}")
|
||||||
|
self._bus.shutdown()
|
||||||
|
self._bus = None
|
||||||
|
|
||||||
|
def send(self, frame: CANFrame) -> bool:
|
||||||
|
"""Send a CAN frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: CANFrame to send
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if sent successfully
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If not connected
|
||||||
|
"""
|
||||||
|
if self._bus is None:
|
||||||
|
raise RuntimeError("Not connected to CAN bus")
|
||||||
|
|
||||||
|
msg = can.Message(
|
||||||
|
arbitration_id=frame.arbitration_id,
|
||||||
|
data=frame.data,
|
||||||
|
is_extended_id=frame.is_extended_id,
|
||||||
|
is_remote_frame=frame.is_remote_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._bus.send(msg)
|
||||||
|
self._logger.debug(f"TX: {frame}")
|
||||||
|
return True
|
||||||
|
except can.CanError as e:
|
||||||
|
self._logger.error(f"Failed to send frame: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def receive(self, timeout: float = 0.1) -> Optional[CANFrame]:
|
||||||
|
"""Receive a CAN frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait for a frame (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CANFrame if received, None on timeout
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If not connected
|
||||||
|
"""
|
||||||
|
if self._bus is None:
|
||||||
|
raise RuntimeError("Not connected to CAN bus")
|
||||||
|
|
||||||
|
msg = self._bus.recv(timeout=timeout)
|
||||||
|
|
||||||
|
if msg is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frame = CANFrame(
|
||||||
|
arbitration_id=msg.arbitration_id,
|
||||||
|
data=bytes(msg.data),
|
||||||
|
timestamp=msg.timestamp if msg.timestamp else time.time(),
|
||||||
|
is_extended_id=msg.is_extended_id,
|
||||||
|
is_remote_frame=msg.is_remote_frame,
|
||||||
|
is_error_frame=msg.is_error_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._logger.debug(f"RX: {frame}")
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def receive_filtered(
|
||||||
|
self,
|
||||||
|
arbitration_ids: List[int],
|
||||||
|
timeout: float = 0.1,
|
||||||
|
) -> Optional[CANFrame]:
|
||||||
|
"""Receive a CAN frame matching specific arbitration IDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
arbitration_ids: List of acceptable arbitration IDs
|
||||||
|
timeout: Maximum time to wait (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CANFrame if received and matching, None on timeout
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while (time.time() - start_time) < timeout:
|
||||||
|
remaining = timeout - (time.time() - start_time)
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
frame = self.receive(timeout=min(remaining, 0.01))
|
||||||
|
if frame and frame.arbitration_id in arbitration_ids:
|
||||||
|
return frame
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session(self):
|
||||||
|
"""Context manager for CAN bus session.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
with can_interface.session():
|
||||||
|
can_interface.send(frame)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.connect()
|
||||||
|
yield self
|
||||||
|
finally:
|
||||||
|
self.disconnect()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
"""Enter context manager."""
|
||||||
|
self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Exit context manager."""
|
||||||
|
self.disconnect()
|
||||||
|
return False
|
||||||
127
obd2_client/src/config.py
Normal file
127
obd2_client/src/config.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Configuration management for OBD2 client."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CANConfig:
|
||||||
|
"""CAN bus configuration."""
|
||||||
|
|
||||||
|
interface: str = "can0"
|
||||||
|
bitrate: int = 500000
|
||||||
|
virtual: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OBD2Config:
|
||||||
|
"""OBD2 protocol configuration."""
|
||||||
|
|
||||||
|
request_id: int = 0x7DF
|
||||||
|
response_id: int = 0x7E8
|
||||||
|
timeout: float = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PollingConfig:
|
||||||
|
"""Polling configuration."""
|
||||||
|
|
||||||
|
interval_fast: float = 0.1
|
||||||
|
interval_slow: float = 1.0
|
||||||
|
fast_pids: List[int] = field(default_factory=lambda: [0x0C, 0x0D, 0x11])
|
||||||
|
slow_pids: List[int] = field(default_factory=lambda: [0x05, 0x2F, 0x5C])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""Main configuration container."""
|
||||||
|
|
||||||
|
can: CANConfig = field(default_factory=CANConfig)
|
||||||
|
obd2: OBD2Config = field(default_factory=OBD2Config)
|
||||||
|
polling: PollingConfig = field(default_factory=PollingConfig)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, config_path: Optional[str] = None) -> "Config":
|
||||||
|
"""Load configuration from JSON file and environment variables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to config.json file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Config instance
|
||||||
|
"""
|
||||||
|
config = cls()
|
||||||
|
|
||||||
|
if config_path is None:
|
||||||
|
config_path = os.environ.get(
|
||||||
|
"OBD2_CONFIG_PATH",
|
||||||
|
str(Path(__file__).parent.parent / "config.json"),
|
||||||
|
)
|
||||||
|
|
||||||
|
config_file = Path(config_path)
|
||||||
|
if config_file.exists():
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
config._load_from_dict(data)
|
||||||
|
|
||||||
|
config._load_from_env()
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def _load_from_dict(self, data: dict) -> None:
|
||||||
|
"""Load configuration from dictionary."""
|
||||||
|
if "can" in data:
|
||||||
|
can_data = data["can"]
|
||||||
|
self.can.interface = can_data.get("interface", self.can.interface)
|
||||||
|
self.can.bitrate = can_data.get("bitrate", self.can.bitrate)
|
||||||
|
self.can.virtual = can_data.get("virtual", self.can.virtual)
|
||||||
|
|
||||||
|
if "obd2" in data:
|
||||||
|
obd2_data = data["obd2"]
|
||||||
|
if "request_id" in obd2_data:
|
||||||
|
self.obd2.request_id = self._parse_hex(obd2_data["request_id"])
|
||||||
|
if "response_id" in obd2_data:
|
||||||
|
self.obd2.response_id = self._parse_hex(obd2_data["response_id"])
|
||||||
|
self.obd2.timeout = obd2_data.get("timeout", self.obd2.timeout)
|
||||||
|
|
||||||
|
if "polling" in data:
|
||||||
|
poll_data = data["polling"]
|
||||||
|
self.polling.interval_fast = poll_data.get(
|
||||||
|
"interval_fast", self.polling.interval_fast
|
||||||
|
)
|
||||||
|
self.polling.interval_slow = poll_data.get(
|
||||||
|
"interval_slow", self.polling.interval_slow
|
||||||
|
)
|
||||||
|
if "fast_pids" in poll_data:
|
||||||
|
self.polling.fast_pids = [
|
||||||
|
self._parse_hex(p) for p in poll_data["fast_pids"]
|
||||||
|
]
|
||||||
|
if "slow_pids" in poll_data:
|
||||||
|
self.polling.slow_pids = [
|
||||||
|
self._parse_hex(p) for p in poll_data["slow_pids"]
|
||||||
|
]
|
||||||
|
|
||||||
|
def _load_from_env(self) -> None:
|
||||||
|
"""Load configuration from environment variables."""
|
||||||
|
if "OBD2_CAN_INTERFACE" in os.environ:
|
||||||
|
self.can.interface = os.environ["OBD2_CAN_INTERFACE"]
|
||||||
|
if "OBD2_CAN_BITRATE" in os.environ:
|
||||||
|
self.can.bitrate = int(os.environ["OBD2_CAN_BITRATE"])
|
||||||
|
if "OBD2_CAN_VIRTUAL" in os.environ:
|
||||||
|
self.can.virtual = os.environ["OBD2_CAN_VIRTUAL"].lower() == "true"
|
||||||
|
if "OBD2_TIMEOUT" in os.environ:
|
||||||
|
self.obd2.timeout = float(os.environ["OBD2_TIMEOUT"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_hex(value) -> int:
|
||||||
|
"""Parse hex string or int to int."""
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value.startswith("0x") or value.startswith("0X"):
|
||||||
|
return int(value, 16)
|
||||||
|
return int(value)
|
||||||
|
return value
|
||||||
57
obd2_client/src/logger.py
Normal file
57
obd2_client/src/logger.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Logging configuration for OBD2 client."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logger(
|
||||||
|
name: str = "obd2_client",
|
||||||
|
level: int = logging.INFO,
|
||||||
|
log_file: Optional[str] = None,
|
||||||
|
) -> logging.Logger:
|
||||||
|
"""Configure and return a logger instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name
|
||||||
|
level: Logging level (default: INFO)
|
||||||
|
log_file: Optional file path for logging
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured logger instance
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
logger.setLevel(level)
|
||||||
|
|
||||||
|
if logger.handlers:
|
||||||
|
return logger
|
||||||
|
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setLevel(level)
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
if log_file:
|
||||||
|
file_handler = logging.FileHandler(log_file)
|
||||||
|
file_handler.setLevel(level)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str = "obd2_client") -> logging.Logger:
|
||||||
|
"""Get an existing logger or create a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Logger instance
|
||||||
|
"""
|
||||||
|
return logging.getLogger(name)
|
||||||
250
obd2_client/src/main.py
Normal file
250
obd2_client/src/main.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OBD2 Client - Main entry point."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .logger import setup_logger, get_logger
|
||||||
|
from .can.interface import CANInterface
|
||||||
|
from .obd2.protocol import OBD2Protocol
|
||||||
|
from .obd2.scanner import OBD2Scanner
|
||||||
|
from .vehicle.state import VehicleState
|
||||||
|
from .vehicle.poller import VehiclePoller
|
||||||
|
|
||||||
|
|
||||||
|
class OBD2Client:
|
||||||
|
"""Main OBD2 client application."""
|
||||||
|
|
||||||
|
def __init__(self, config: Config):
|
||||||
|
self.config = config
|
||||||
|
self._logger = get_logger("obd2_client")
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
self.can_interface = CANInterface(
|
||||||
|
interface=config.can.interface,
|
||||||
|
bitrate=config.can.bitrate,
|
||||||
|
virtual=config.can.virtual,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.protocol = OBD2Protocol(
|
||||||
|
can_interface=self.can_interface,
|
||||||
|
request_id=config.obd2.request_id,
|
||||||
|
response_id=config.obd2.response_id,
|
||||||
|
timeout=config.obd2.timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
cache_file = str(Path(__file__).parent.parent / "pid_cache.json")
|
||||||
|
self.scanner = OBD2Scanner(self.protocol, cache_file=cache_file)
|
||||||
|
|
||||||
|
self.state = VehicleState()
|
||||||
|
|
||||||
|
self.poller = VehiclePoller(
|
||||||
|
protocol=self.protocol,
|
||||||
|
state=self.state,
|
||||||
|
fast_interval=config.polling.interval_fast,
|
||||||
|
slow_interval=config.polling.interval_slow,
|
||||||
|
fast_pids=config.polling.fast_pids,
|
||||||
|
slow_pids=config.polling.slow_pids,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, scan_only: bool = False, monitor: bool = True) -> None:
|
||||||
|
"""Run the OBD2 client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scan_only: If True, only scan for PIDs and exit
|
||||||
|
monitor: If True, continuously monitor and display values
|
||||||
|
"""
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._logger.info(f"Connecting to {self.config.can.interface}...")
|
||||||
|
self.can_interface.connect()
|
||||||
|
|
||||||
|
self._logger.info("Scanning for supported PIDs...")
|
||||||
|
supported = self.scanner.scan()
|
||||||
|
|
||||||
|
if not supported:
|
||||||
|
self._logger.error("No PIDs supported or no response from ECU")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.scanner.print_supported_pids()
|
||||||
|
|
||||||
|
if scan_only:
|
||||||
|
return
|
||||||
|
|
||||||
|
readable_pids = self.scanner.get_readable_pids()
|
||||||
|
if not readable_pids:
|
||||||
|
self._logger.warning("No readable PIDs found")
|
||||||
|
return
|
||||||
|
|
||||||
|
fast_pids = [p for p in self.config.polling.fast_pids if p in readable_pids]
|
||||||
|
slow_pids = [p for p in self.config.polling.slow_pids if p in readable_pids]
|
||||||
|
|
||||||
|
if not fast_pids and not slow_pids:
|
||||||
|
fast_pids = readable_pids[:3]
|
||||||
|
slow_pids = readable_pids[3:]
|
||||||
|
|
||||||
|
self.poller.set_pids(fast_pids, slow_pids)
|
||||||
|
|
||||||
|
self._logger.info("Starting poller...")
|
||||||
|
self.poller.start()
|
||||||
|
|
||||||
|
if monitor:
|
||||||
|
self._monitor_loop()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._shutdown()
|
||||||
|
|
||||||
|
def _monitor_loop(self) -> None:
|
||||||
|
"""Display live values in console."""
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(" OBD2 Live Monitor (Ctrl+C to stop)")
|
||||||
|
print("=" * 50 + "\n")
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
self._clear_screen()
|
||||||
|
self._print_header()
|
||||||
|
self._print_values()
|
||||||
|
self._print_stats()
|
||||||
|
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
def _clear_screen(self) -> None:
|
||||||
|
"""Clear console screen."""
|
||||||
|
print("\033[H\033[J", end="")
|
||||||
|
|
||||||
|
def _print_header(self) -> None:
|
||||||
|
"""Print header."""
|
||||||
|
print("=" * 50)
|
||||||
|
print(" OBD2 Live Monitor - Skoda Kodiaq 2021")
|
||||||
|
print("=" * 50)
|
||||||
|
print()
|
||||||
|
|
||||||
|
def _print_values(self) -> None:
|
||||||
|
"""Print current values."""
|
||||||
|
values = self.state.get_all()
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
print(" Waiting for data...")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(" Current Values:")
|
||||||
|
print(" " + "-" * 40)
|
||||||
|
|
||||||
|
rpm = self.state.rpm
|
||||||
|
speed = self.state.speed
|
||||||
|
coolant = self.state.coolant_temp
|
||||||
|
throttle = self.state.throttle
|
||||||
|
fuel = self.state.fuel_level
|
||||||
|
oil = self.state.oil_temp
|
||||||
|
|
||||||
|
if rpm is not None:
|
||||||
|
print(f" Engine RPM: {rpm:>8.0f} RPM")
|
||||||
|
if speed is not None:
|
||||||
|
print(f" Vehicle Speed: {speed:>8.0f} km/h")
|
||||||
|
if throttle is not None:
|
||||||
|
print(f" Throttle: {throttle:>8.1f} %")
|
||||||
|
if coolant is not None:
|
||||||
|
print(f" Coolant Temp: {coolant:>8.0f} °C")
|
||||||
|
if oil is not None:
|
||||||
|
print(f" Oil Temp: {oil:>8.0f} °C")
|
||||||
|
if fuel is not None:
|
||||||
|
print(f" Fuel Level: {fuel:>8.1f} %")
|
||||||
|
|
||||||
|
print(" " + "-" * 40)
|
||||||
|
print()
|
||||||
|
|
||||||
|
def _print_stats(self) -> None:
|
||||||
|
"""Print polling statistics."""
|
||||||
|
stats = self.poller.get_stats()
|
||||||
|
total = stats["queries"]
|
||||||
|
success = stats["successes"]
|
||||||
|
rate = (success / total * 100) if total > 0 else 0
|
||||||
|
|
||||||
|
print(f" Stats: {total} queries, {success} success ({rate:.1f}%)")
|
||||||
|
print()
|
||||||
|
print(" Press Ctrl+C to stop")
|
||||||
|
|
||||||
|
def _signal_handler(self, signum, frame) -> None:
|
||||||
|
"""Handle shutdown signals."""
|
||||||
|
self._logger.info("Shutdown signal received")
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def _shutdown(self) -> None:
|
||||||
|
"""Clean shutdown."""
|
||||||
|
self._logger.info("Shutting down...")
|
||||||
|
|
||||||
|
if self.poller.is_running:
|
||||||
|
self.poller.stop()
|
||||||
|
|
||||||
|
if self.can_interface.is_connected:
|
||||||
|
self.can_interface.disconnect()
|
||||||
|
|
||||||
|
self._logger.info("Shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="OBD2 Client for Skoda Kodiaq 2021",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-i", "--interface",
|
||||||
|
default=None,
|
||||||
|
help="CAN interface (e.g., can0, vcan0)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-c", "--config",
|
||||||
|
default=None,
|
||||||
|
help="Path to config.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--virtual",
|
||||||
|
action="store_true",
|
||||||
|
help="Use virtual CAN interface",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--scan-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Only scan for supported PIDs and exit",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable debug logging",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
import logging
|
||||||
|
log_level = logging.DEBUG if args.debug else logging.INFO
|
||||||
|
setup_logger(level=log_level)
|
||||||
|
|
||||||
|
config = Config.load(args.config)
|
||||||
|
|
||||||
|
if args.interface:
|
||||||
|
config.can.interface = args.interface
|
||||||
|
if args.virtual:
|
||||||
|
config.can.virtual = True
|
||||||
|
|
||||||
|
client = OBD2Client(config)
|
||||||
|
client.run(scan_only=args.scan_only)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
7
obd2_client/src/obd2/__init__.py
Normal file
7
obd2_client/src/obd2/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""OBD2 protocol implementation."""
|
||||||
|
|
||||||
|
from .pids import PID, PIDRegistry
|
||||||
|
from .protocol import OBD2Protocol
|
||||||
|
from .scanner import OBD2Scanner
|
||||||
|
|
||||||
|
__all__ = ["PID", "PIDRegistry", "OBD2Protocol", "OBD2Scanner"]
|
||||||
259
obd2_client/src/obd2/pids.py
Normal file
259
obd2_client/src/obd2/pids.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""OBD2 PID definitions and decoders."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Dict, List, Optional, Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PID:
|
||||||
|
"""OBD2 Parameter ID definition.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
code: PID code (e.g., 0x0C for RPM)
|
||||||
|
name: Human-readable name
|
||||||
|
description: Detailed description
|
||||||
|
unit: Unit of measurement
|
||||||
|
min_value: Minimum possible value
|
||||||
|
max_value: Maximum possible value
|
||||||
|
decoder: Function to decode raw bytes to value
|
||||||
|
num_bytes: Number of data bytes in response
|
||||||
|
"""
|
||||||
|
|
||||||
|
code: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
unit: str
|
||||||
|
min_value: float
|
||||||
|
max_value: float
|
||||||
|
decoder: Callable[[bytes], float]
|
||||||
|
num_bytes: int = 1
|
||||||
|
|
||||||
|
def decode(self, data: bytes) -> float:
|
||||||
|
"""Decode raw bytes to value using the decoder function."""
|
||||||
|
return self.decoder(data)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"PID[0x{self.code:02X}] {self.name} ({self.unit})"
|
||||||
|
|
||||||
|
|
||||||
|
class PIDRegistry:
|
||||||
|
"""Registry of supported OBD2 PIDs."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._pids: Dict[int, PID] = {}
|
||||||
|
self._register_standard_pids()
|
||||||
|
|
||||||
|
def _register_standard_pids(self) -> None:
|
||||||
|
"""Register standard Mode 01 PIDs."""
|
||||||
|
|
||||||
|
# PID 0x00 - Supported PIDs [01-20]
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x00,
|
||||||
|
name="PIDS_A",
|
||||||
|
description="Supported PIDs [01-20]",
|
||||||
|
unit="bitmask",
|
||||||
|
min_value=0,
|
||||||
|
max_value=0xFFFFFFFF,
|
||||||
|
decoder=lambda d: int.from_bytes(d[:4], "big"),
|
||||||
|
num_bytes=4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x04 - Engine Load
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x04,
|
||||||
|
name="ENGINE_LOAD",
|
||||||
|
description="Calculated engine load",
|
||||||
|
unit="%",
|
||||||
|
min_value=0,
|
||||||
|
max_value=100,
|
||||||
|
decoder=lambda d: d[0] * 100 / 255,
|
||||||
|
num_bytes=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x05 - Coolant Temperature
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x05,
|
||||||
|
name="COOLANT_TEMP",
|
||||||
|
description="Engine coolant temperature",
|
||||||
|
unit="°C",
|
||||||
|
min_value=-40,
|
||||||
|
max_value=215,
|
||||||
|
decoder=lambda d: d[0] - 40,
|
||||||
|
num_bytes=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x0C - Engine RPM
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x0C,
|
||||||
|
name="ENGINE_RPM",
|
||||||
|
description="Engine RPM",
|
||||||
|
unit="RPM",
|
||||||
|
min_value=0,
|
||||||
|
max_value=16383.75,
|
||||||
|
decoder=lambda d: (d[0] * 256 + d[1]) / 4,
|
||||||
|
num_bytes=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x0D - Vehicle Speed
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x0D,
|
||||||
|
name="VEHICLE_SPEED",
|
||||||
|
description="Vehicle speed",
|
||||||
|
unit="km/h",
|
||||||
|
min_value=0,
|
||||||
|
max_value=255,
|
||||||
|
decoder=lambda d: d[0],
|
||||||
|
num_bytes=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x0F - Intake Air Temperature
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x0F,
|
||||||
|
name="INTAKE_TEMP",
|
||||||
|
description="Intake air temperature",
|
||||||
|
unit="°C",
|
||||||
|
min_value=-40,
|
||||||
|
max_value=215,
|
||||||
|
decoder=lambda d: d[0] - 40,
|
||||||
|
num_bytes=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x10 - MAF Air Flow Rate
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x10,
|
||||||
|
name="MAF_RATE",
|
||||||
|
description="Mass air flow rate",
|
||||||
|
unit="g/s",
|
||||||
|
min_value=0,
|
||||||
|
max_value=655.35,
|
||||||
|
decoder=lambda d: (d[0] * 256 + d[1]) / 100,
|
||||||
|
num_bytes=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x11 - Throttle Position
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x11,
|
||||||
|
name="THROTTLE_POS",
|
||||||
|
description="Throttle position",
|
||||||
|
unit="%",
|
||||||
|
min_value=0,
|
||||||
|
max_value=100,
|
||||||
|
decoder=lambda d: d[0] * 100 / 255,
|
||||||
|
num_bytes=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x20 - Supported PIDs [21-40]
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x20,
|
||||||
|
name="PIDS_B",
|
||||||
|
description="Supported PIDs [21-40]",
|
||||||
|
unit="bitmask",
|
||||||
|
min_value=0,
|
||||||
|
max_value=0xFFFFFFFF,
|
||||||
|
decoder=lambda d: int.from_bytes(d[:4], "big"),
|
||||||
|
num_bytes=4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x2F - Fuel Tank Level
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x2F,
|
||||||
|
name="FUEL_LEVEL",
|
||||||
|
description="Fuel tank level input",
|
||||||
|
unit="%",
|
||||||
|
min_value=0,
|
||||||
|
max_value=100,
|
||||||
|
decoder=lambda d: d[0] * 100 / 255,
|
||||||
|
num_bytes=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x40 - Supported PIDs [41-60]
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x40,
|
||||||
|
name="PIDS_C",
|
||||||
|
description="Supported PIDs [41-60]",
|
||||||
|
unit="bitmask",
|
||||||
|
min_value=0,
|
||||||
|
max_value=0xFFFFFFFF,
|
||||||
|
decoder=lambda d: int.from_bytes(d[:4], "big"),
|
||||||
|
num_bytes=4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x5C - Oil Temperature
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x5C,
|
||||||
|
name="OIL_TEMP",
|
||||||
|
description="Engine oil temperature",
|
||||||
|
unit="°C",
|
||||||
|
min_value=-40,
|
||||||
|
max_value=210,
|
||||||
|
decoder=lambda d: d[0] - 40,
|
||||||
|
num_bytes=1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# PID 0x60 - Supported PIDs [61-80]
|
||||||
|
self.register(
|
||||||
|
PID(
|
||||||
|
code=0x60,
|
||||||
|
name="PIDS_D",
|
||||||
|
description="Supported PIDs [61-80]",
|
||||||
|
unit="bitmask",
|
||||||
|
min_value=0,
|
||||||
|
max_value=0xFFFFFFFF,
|
||||||
|
decoder=lambda d: int.from_bytes(d[:4], "big"),
|
||||||
|
num_bytes=4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def register(self, pid: PID) -> None:
|
||||||
|
"""Register a PID in the registry."""
|
||||||
|
self._pids[pid.code] = pid
|
||||||
|
|
||||||
|
def get(self, code: int) -> Optional[PID]:
|
||||||
|
"""Get a PID by its code."""
|
||||||
|
return self._pids.get(code)
|
||||||
|
|
||||||
|
def get_all(self) -> List[PID]:
|
||||||
|
"""Get all registered PIDs."""
|
||||||
|
return list(self._pids.values())
|
||||||
|
|
||||||
|
def get_data_pids(self) -> List[PID]:
|
||||||
|
"""Get all data PIDs (excluding supported PID queries)."""
|
||||||
|
return [
|
||||||
|
pid
|
||||||
|
for pid in self._pids.values()
|
||||||
|
if pid.code not in (0x00, 0x20, 0x40, 0x60, 0x80, 0xA0, 0xC0)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __contains__(self, code: int) -> bool:
|
||||||
|
return code in self._pids
|
||||||
|
|
||||||
|
def __getitem__(self, code: int) -> PID:
|
||||||
|
return self._pids[code]
|
||||||
|
|
||||||
|
|
||||||
|
# Global registry instance
|
||||||
|
PIDS = PIDRegistry()
|
||||||
174
obd2_client/src/obd2/protocol.py
Normal file
174
obd2_client/src/obd2/protocol.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""OBD2 protocol implementation."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from ..can.frame import CANFrame
|
||||||
|
from ..can.interface import CANInterface
|
||||||
|
from ..logger import get_logger
|
||||||
|
from .pids import PID, PIDRegistry, PIDS
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OBD2Response:
|
||||||
|
"""OBD2 response data.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
pid: PID that was queried
|
||||||
|
raw_data: Raw response data bytes
|
||||||
|
value: Decoded value
|
||||||
|
unit: Unit of measurement
|
||||||
|
"""
|
||||||
|
|
||||||
|
pid: PID
|
||||||
|
raw_data: bytes
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.pid.name}: {self.value:.2f} {self.unit}"
|
||||||
|
|
||||||
|
|
||||||
|
class OBD2Protocol:
|
||||||
|
"""OBD2 protocol handler for Mode 01 (current data).
|
||||||
|
|
||||||
|
Handles request/response encoding and decoding for OBD2 PIDs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODE_CURRENT_DATA = 0x01
|
||||||
|
MODE_RESPONSE_OFFSET = 0x40
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
can_interface: CANInterface,
|
||||||
|
request_id: int = 0x7DF,
|
||||||
|
response_id: int = 0x7E8,
|
||||||
|
timeout: float = 0.1,
|
||||||
|
pid_registry: Optional[PIDRegistry] = None,
|
||||||
|
):
|
||||||
|
"""Initialize OBD2 protocol handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
can_interface: CAN interface to use
|
||||||
|
request_id: CAN ID for OBD2 requests (default: 0x7DF broadcast)
|
||||||
|
response_id: CAN ID for OBD2 responses (default: 0x7E8 ECU)
|
||||||
|
timeout: Response timeout in seconds
|
||||||
|
pid_registry: PID registry (default: global PIDS)
|
||||||
|
"""
|
||||||
|
self.can = can_interface
|
||||||
|
self.request_id = request_id
|
||||||
|
self.response_id = response_id
|
||||||
|
self.timeout = timeout
|
||||||
|
self.pids = pid_registry or PIDS
|
||||||
|
self._logger = get_logger("obd2_client.protocol")
|
||||||
|
|
||||||
|
def build_request(self, mode: int, pid: int) -> CANFrame:
|
||||||
|
"""Build an OBD2 request frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: OBD2 mode (e.g., 0x01)
|
||||||
|
pid: Parameter ID to request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CANFrame ready to send
|
||||||
|
"""
|
||||||
|
data = bytes([0x02, mode, pid, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||||
|
return CANFrame(arbitration_id=self.request_id, data=data)
|
||||||
|
|
||||||
|
def parse_response(self, frame: CANFrame, expected_pid: int) -> Optional[bytes]:
|
||||||
|
"""Parse OBD2 response frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: Received CAN frame
|
||||||
|
expected_pid: PID we're expecting a response for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Data bytes if valid response, None otherwise
|
||||||
|
"""
|
||||||
|
if len(frame.data) < 3:
|
||||||
|
return None
|
||||||
|
|
||||||
|
length = frame.data[0]
|
||||||
|
mode = frame.data[1]
|
||||||
|
pid = frame.data[2]
|
||||||
|
|
||||||
|
expected_mode = self.MODE_CURRENT_DATA + self.MODE_RESPONSE_OFFSET
|
||||||
|
|
||||||
|
if mode != expected_mode:
|
||||||
|
self._logger.debug(f"Unexpected mode: 0x{mode:02X}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if pid != expected_pid:
|
||||||
|
self._logger.debug(f"Unexpected PID: 0x{pid:02X}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
data_length = length - 2
|
||||||
|
if data_length <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return frame.data[3 : 3 + data_length]
|
||||||
|
|
||||||
|
def query_pid(self, pid_code: int) -> Optional[OBD2Response]:
|
||||||
|
"""Query a single OBD2 PID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid_code: PID code to query
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
OBD2Response if successful, None otherwise
|
||||||
|
"""
|
||||||
|
pid = self.pids.get(pid_code)
|
||||||
|
if pid is None:
|
||||||
|
self._logger.warning(f"Unknown PID: 0x{pid_code:02X}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
request = self.build_request(self.MODE_CURRENT_DATA, pid_code)
|
||||||
|
|
||||||
|
if not self.can.send(request):
|
||||||
|
self._logger.error(f"Failed to send request for PID 0x{pid_code:02X}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response_ids = [self.response_id + i for i in range(8)]
|
||||||
|
frame = self.can.receive_filtered(response_ids, timeout=self.timeout)
|
||||||
|
|
||||||
|
if frame is None:
|
||||||
|
self._logger.debug(f"No response for PID 0x{pid_code:02X}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_data = self.parse_response(frame, pid_code)
|
||||||
|
if raw_data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = pid.decode(raw_data)
|
||||||
|
return OBD2Response(
|
||||||
|
pid=pid,
|
||||||
|
raw_data=raw_data,
|
||||||
|
value=value,
|
||||||
|
unit=pid.unit,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Failed to decode PID 0x{pid_code:02X}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def query_supported_pids(self, base_pid: int = 0x00) -> list[int]:
|
||||||
|
"""Query supported PIDs starting from a base PID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_pid: Base PID (0x00, 0x20, 0x40, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of supported PID codes
|
||||||
|
"""
|
||||||
|
response = self.query_pid(base_pid)
|
||||||
|
if response is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
bitmask = int(response.value)
|
||||||
|
supported = []
|
||||||
|
|
||||||
|
for i in range(32):
|
||||||
|
if bitmask & (1 << (31 - i)):
|
||||||
|
supported.append(base_pid + i + 1)
|
||||||
|
|
||||||
|
return supported
|
||||||
169
obd2_client/src/obd2/scanner.py
Normal file
169
obd2_client/src/obd2/scanner.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""OBD2 PID scanner for auto-detection."""
|
||||||
|
|
||||||
|
from typing import List, Set, Optional
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ..can.interface import CANInterface
|
||||||
|
from ..logger import get_logger
|
||||||
|
from .protocol import OBD2Protocol
|
||||||
|
from .pids import PIDS
|
||||||
|
|
||||||
|
|
||||||
|
class OBD2Scanner:
|
||||||
|
"""Scanner for detecting supported OBD2 PIDs.
|
||||||
|
|
||||||
|
Queries the vehicle ECU to determine which PIDs are supported,
|
||||||
|
then caches the results for future use.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SUPPORTED_PID_QUERIES = [0x00, 0x20, 0x40, 0x60, 0x80, 0xA0, 0xC0]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
protocol: OBD2Protocol,
|
||||||
|
cache_file: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""Initialize scanner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
protocol: OBD2Protocol instance
|
||||||
|
cache_file: Optional path to cache file for storing results
|
||||||
|
"""
|
||||||
|
self.protocol = protocol
|
||||||
|
self.cache_file = cache_file
|
||||||
|
self._supported_pids: Set[int] = set()
|
||||||
|
self._scanned = False
|
||||||
|
self._logger = get_logger("obd2_client.scanner")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supported_pids(self) -> List[int]:
|
||||||
|
"""Get list of supported PIDs (sorted)."""
|
||||||
|
return sorted(self._supported_pids)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_scanned(self) -> bool:
|
||||||
|
"""Check if scan has been performed."""
|
||||||
|
return self._scanned
|
||||||
|
|
||||||
|
def scan(self, use_cache: bool = True) -> List[int]:
|
||||||
|
"""Scan for supported PIDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
use_cache: If True, try to load from cache first
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of supported PID codes
|
||||||
|
"""
|
||||||
|
if use_cache and self._load_cache():
|
||||||
|
self._logger.info(f"Loaded {len(self._supported_pids)} PIDs from cache")
|
||||||
|
return self.supported_pids
|
||||||
|
|
||||||
|
self._logger.info("Starting PID scan...")
|
||||||
|
self._supported_pids.clear()
|
||||||
|
|
||||||
|
for base_pid in self.SUPPORTED_PID_QUERIES:
|
||||||
|
self._logger.debug(f"Querying supported PIDs from 0x{base_pid:02X}")
|
||||||
|
|
||||||
|
supported = self.protocol.query_supported_pids(base_pid)
|
||||||
|
if not supported:
|
||||||
|
self._logger.debug(f"No response for base PID 0x{base_pid:02X}")
|
||||||
|
break
|
||||||
|
|
||||||
|
self._supported_pids.update(supported)
|
||||||
|
self._logger.debug(f"Found {len(supported)} supported PIDs")
|
||||||
|
|
||||||
|
next_base = base_pid + 0x20
|
||||||
|
if next_base not in supported:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._scanned = True
|
||||||
|
self._logger.info(f"Scan complete. Found {len(self._supported_pids)} supported PIDs")
|
||||||
|
|
||||||
|
if self.cache_file:
|
||||||
|
self._save_cache()
|
||||||
|
|
||||||
|
return self.supported_pids
|
||||||
|
|
||||||
|
def get_readable_pids(self) -> List[int]:
|
||||||
|
"""Get PIDs that can be read (have decoders in registry).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of PID codes that are both supported and have decoders
|
||||||
|
"""
|
||||||
|
readable = []
|
||||||
|
for pid_code in self._supported_pids:
|
||||||
|
pid = PIDS.get(pid_code)
|
||||||
|
if pid and pid_code not in self.SUPPORTED_PID_QUERIES:
|
||||||
|
readable.append(pid_code)
|
||||||
|
return sorted(readable)
|
||||||
|
|
||||||
|
def is_pid_supported(self, pid_code: int) -> bool:
|
||||||
|
"""Check if a specific PID is supported.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid_code: PID code to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if PID is supported
|
||||||
|
"""
|
||||||
|
return pid_code in self._supported_pids
|
||||||
|
|
||||||
|
def _load_cache(self) -> bool:
|
||||||
|
"""Load supported PIDs from cache file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cache was loaded successfully
|
||||||
|
"""
|
||||||
|
if not self.cache_file:
|
||||||
|
return False
|
||||||
|
|
||||||
|
cache_path = Path(self.cache_file)
|
||||||
|
if not cache_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(cache_path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self._supported_pids = set(data.get("supported_pids", []))
|
||||||
|
self._scanned = True
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.warning(f"Failed to load cache: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _save_cache(self) -> bool:
|
||||||
|
"""Save supported PIDs to cache file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cache was saved successfully
|
||||||
|
"""
|
||||||
|
if not self.cache_file:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
cache_path = Path(self.cache_file)
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with open(cache_path, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{"supported_pids": sorted(self._supported_pids)},
|
||||||
|
f,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
self._logger.info(f"Saved PID cache to {self.cache_file}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.warning(f"Failed to save cache: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def print_supported_pids(self) -> None:
|
||||||
|
"""Print supported PIDs in a readable format."""
|
||||||
|
print("\n=== Supported PIDs ===")
|
||||||
|
for pid_code in self.supported_pids:
|
||||||
|
pid = PIDS.get(pid_code)
|
||||||
|
if pid:
|
||||||
|
print(f" 0x{pid_code:02X} - {pid.name}: {pid.description}")
|
||||||
|
else:
|
||||||
|
print(f" 0x{pid_code:02X} - Unknown PID")
|
||||||
|
print(f"\nTotal: {len(self._supported_pids)} PIDs")
|
||||||
6
obd2_client/src/vehicle/__init__.py
Normal file
6
obd2_client/src/vehicle/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Vehicle state and polling."""
|
||||||
|
|
||||||
|
from .state import VehicleState
|
||||||
|
from .poller import VehiclePoller
|
||||||
|
|
||||||
|
__all__ = ["VehicleState", "VehiclePoller"]
|
||||||
193
obd2_client/src/vehicle/poller.py
Normal file
193
obd2_client/src/vehicle/poller.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""Vehicle PID polling service."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import List, Optional, Set
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from ..obd2.protocol import OBD2Protocol
|
||||||
|
from ..obd2.pids import PIDS
|
||||||
|
from ..logger import get_logger
|
||||||
|
from .state import VehicleState
|
||||||
|
|
||||||
|
|
||||||
|
class PollerState(Enum):
|
||||||
|
"""Poller state enum."""
|
||||||
|
|
||||||
|
STOPPED = "stopped"
|
||||||
|
RUNNING = "running"
|
||||||
|
PAUSED = "paused"
|
||||||
|
|
||||||
|
|
||||||
|
class VehiclePoller:
|
||||||
|
"""Background poller for vehicle PIDs.
|
||||||
|
|
||||||
|
Supports priority-based polling with fast and slow intervals:
|
||||||
|
- Fast PIDs (RPM, speed, throttle): polled frequently
|
||||||
|
- Slow PIDs (temperatures, fuel level): polled less frequently
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
protocol: OBD2Protocol,
|
||||||
|
state: VehicleState,
|
||||||
|
fast_interval: float = 0.1,
|
||||||
|
slow_interval: float = 1.0,
|
||||||
|
fast_pids: Optional[List[int]] = None,
|
||||||
|
slow_pids: Optional[List[int]] = None,
|
||||||
|
):
|
||||||
|
"""Initialize poller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
protocol: OBD2Protocol instance
|
||||||
|
state: VehicleState to update
|
||||||
|
fast_interval: Polling interval for fast PIDs (seconds)
|
||||||
|
slow_interval: Polling interval for slow PIDs (seconds)
|
||||||
|
fast_pids: List of PIDs to poll frequently
|
||||||
|
slow_pids: List of PIDs to poll less frequently
|
||||||
|
"""
|
||||||
|
self.protocol = protocol
|
||||||
|
self.state = state
|
||||||
|
self.fast_interval = fast_interval
|
||||||
|
self.slow_interval = slow_interval
|
||||||
|
|
||||||
|
self.fast_pids: Set[int] = set(fast_pids or [0x0C, 0x0D, 0x11])
|
||||||
|
self.slow_pids: Set[int] = set(slow_pids or [0x05, 0x2F, 0x5C])
|
||||||
|
|
||||||
|
self._state = PollerState.STOPPED
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._pause_event = threading.Event()
|
||||||
|
self._pause_event.set()
|
||||||
|
|
||||||
|
self._logger = get_logger("obd2_client.poller")
|
||||||
|
self._stats = {"queries": 0, "successes": 0, "failures": 0}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def poller_state(self) -> PollerState:
|
||||||
|
"""Get current poller state."""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
"""Check if poller is running."""
|
||||||
|
return self._state == PollerState.RUNNING
|
||||||
|
|
||||||
|
def set_pids(
|
||||||
|
self,
|
||||||
|
fast_pids: Optional[List[int]] = None,
|
||||||
|
slow_pids: Optional[List[int]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update PIDs to poll.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fast_pids: New list of fast PIDs (or None to keep current)
|
||||||
|
slow_pids: New list of slow PIDs (or None to keep current)
|
||||||
|
"""
|
||||||
|
if fast_pids is not None:
|
||||||
|
self.fast_pids = set(fast_pids)
|
||||||
|
if slow_pids is not None:
|
||||||
|
self.slow_pids = set(slow_pids)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the polling thread."""
|
||||||
|
if self._thread is not None and self._thread.is_alive():
|
||||||
|
self._logger.warning("Poller already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._pause_event.set()
|
||||||
|
self._state = PollerState.RUNNING
|
||||||
|
|
||||||
|
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
self._logger.info("Poller started")
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 2.0) -> None:
|
||||||
|
"""Stop the polling thread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait for thread to stop
|
||||||
|
"""
|
||||||
|
if self._thread is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._stop_event.set()
|
||||||
|
self._pause_event.set()
|
||||||
|
|
||||||
|
self._thread.join(timeout=timeout)
|
||||||
|
if self._thread.is_alive():
|
||||||
|
self._logger.warning("Poller thread did not stop cleanly")
|
||||||
|
|
||||||
|
self._thread = None
|
||||||
|
self._state = PollerState.STOPPED
|
||||||
|
self._logger.info("Poller stopped")
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
"""Pause polling."""
|
||||||
|
self._pause_event.clear()
|
||||||
|
self._state = PollerState.PAUSED
|
||||||
|
self._logger.info("Poller paused")
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
"""Resume polling after pause."""
|
||||||
|
self._pause_event.set()
|
||||||
|
self._state = PollerState.RUNNING
|
||||||
|
self._logger.info("Poller resumed")
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
"""Get polling statistics."""
|
||||||
|
return dict(self._stats)
|
||||||
|
|
||||||
|
def _poll_loop(self) -> None:
|
||||||
|
"""Main polling loop."""
|
||||||
|
last_slow_poll = 0.0
|
||||||
|
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
self._pause_event.wait()
|
||||||
|
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
for pid_code in self.fast_pids:
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
self._poll_pid(pid_code)
|
||||||
|
|
||||||
|
if (current_time - last_slow_poll) >= self.slow_interval:
|
||||||
|
for pid_code in self.slow_pids:
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
break
|
||||||
|
self._poll_pid(pid_code)
|
||||||
|
last_slow_poll = current_time
|
||||||
|
|
||||||
|
sleep_time = self.fast_interval - (time.time() - current_time)
|
||||||
|
if sleep_time > 0:
|
||||||
|
self._stop_event.wait(sleep_time)
|
||||||
|
|
||||||
|
def _poll_pid(self, pid_code: int) -> bool:
|
||||||
|
"""Poll a single PID and update state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid_code: PID to poll
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful
|
||||||
|
"""
|
||||||
|
self._stats["queries"] += 1
|
||||||
|
|
||||||
|
response = self.protocol.query_pid(pid_code)
|
||||||
|
if response is None:
|
||||||
|
self._stats["failures"] += 1
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._stats["successes"] += 1
|
||||||
|
self.state.update(
|
||||||
|
pid_code=pid_code,
|
||||||
|
name=response.pid.name,
|
||||||
|
value=response.value,
|
||||||
|
unit=response.unit,
|
||||||
|
)
|
||||||
|
return True
|
||||||
173
obd2_client/src/vehicle/state.py
Normal file
173
obd2_client/src/vehicle/state.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""Vehicle state management."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, Optional, Any
|
||||||
|
from datetime import datetime
|
||||||
|
import threading
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PIDValue:
|
||||||
|
"""Single PID value with metadata.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
pid_code: PID code
|
||||||
|
name: PID name
|
||||||
|
value: Current value
|
||||||
|
unit: Unit of measurement
|
||||||
|
timestamp: When value was last updated
|
||||||
|
"""
|
||||||
|
|
||||||
|
pid_code: int
|
||||||
|
name: str
|
||||||
|
value: float
|
||||||
|
unit: str
|
||||||
|
timestamp: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name}: {self.value:.1f} {self.unit}"
|
||||||
|
|
||||||
|
def age_seconds(self) -> float:
|
||||||
|
"""Get age of value in seconds."""
|
||||||
|
return (datetime.now() - self.timestamp).total_seconds()
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleState:
|
||||||
|
"""Thread-safe container for current vehicle state.
|
||||||
|
|
||||||
|
Stores latest values for all monitored PIDs with timestamps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._values: Dict[int, PIDValue] = {}
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._callbacks: list = []
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
pid_code: int,
|
||||||
|
name: str,
|
||||||
|
value: float,
|
||||||
|
unit: str,
|
||||||
|
) -> None:
|
||||||
|
"""Update a PID value.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid_code: PID code
|
||||||
|
name: PID name
|
||||||
|
value: New value
|
||||||
|
unit: Unit of measurement
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
old_value = self._values.get(pid_code)
|
||||||
|
new_value = PIDValue(
|
||||||
|
pid_code=pid_code,
|
||||||
|
name=name,
|
||||||
|
value=value,
|
||||||
|
unit=unit,
|
||||||
|
)
|
||||||
|
self._values[pid_code] = new_value
|
||||||
|
|
||||||
|
for callback in self._callbacks:
|
||||||
|
try:
|
||||||
|
callback(new_value, old_value)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self, pid_code: int) -> Optional[PIDValue]:
|
||||||
|
"""Get current value for a PID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pid_code: PID code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIDValue if available, None otherwise
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return self._values.get(pid_code)
|
||||||
|
|
||||||
|
def get_all(self) -> Dict[int, PIDValue]:
|
||||||
|
"""Get all current values.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of PID code to PIDValue
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
return dict(self._values)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear all stored values."""
|
||||||
|
with self._lock:
|
||||||
|
self._values.clear()
|
||||||
|
|
||||||
|
def add_callback(self, callback) -> None:
|
||||||
|
"""Add a callback for value updates.
|
||||||
|
|
||||||
|
Callback signature: callback(new_value: PIDValue, old_value: Optional[PIDValue])
|
||||||
|
"""
|
||||||
|
self._callbacks.append(callback)
|
||||||
|
|
||||||
|
def remove_callback(self, callback) -> None:
|
||||||
|
"""Remove a callback."""
|
||||||
|
if callback in self._callbacks:
|
||||||
|
self._callbacks.remove(callback)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rpm(self) -> Optional[float]:
|
||||||
|
"""Get current RPM value."""
|
||||||
|
val = self.get(0x0C)
|
||||||
|
return val.value if val else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def speed(self) -> Optional[float]:
|
||||||
|
"""Get current speed value (km/h)."""
|
||||||
|
val = self.get(0x0D)
|
||||||
|
return val.value if val else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def coolant_temp(self) -> Optional[float]:
|
||||||
|
"""Get current coolant temperature (°C)."""
|
||||||
|
val = self.get(0x05)
|
||||||
|
return val.value if val else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def throttle(self) -> Optional[float]:
|
||||||
|
"""Get current throttle position (%)."""
|
||||||
|
val = self.get(0x11)
|
||||||
|
return val.value if val else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fuel_level(self) -> Optional[float]:
|
||||||
|
"""Get current fuel level (%)."""
|
||||||
|
val = self.get(0x2F)
|
||||||
|
return val.value if val else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def oil_temp(self) -> Optional[float]:
|
||||||
|
"""Get current oil temperature (°C)."""
|
||||||
|
val = self.get(0x5C)
|
||||||
|
return val.value if val else None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
"""Convert state to dictionary."""
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
val.name: {
|
||||||
|
"value": val.value,
|
||||||
|
"unit": val.unit,
|
||||||
|
"timestamp": val.timestamp.isoformat(),
|
||||||
|
}
|
||||||
|
for val in self._values.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Format state as string."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._values:
|
||||||
|
return "No data"
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for pid_code in sorted(self._values.keys()):
|
||||||
|
val = self._values[pid_code]
|
||||||
|
lines.append(f" {val}")
|
||||||
|
return "\n".join(lines)
|
||||||
Reference in New Issue
Block a user