add src
This commit is contained in:
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
# OBD2 Emulator Dependencies
|
||||
|
||||
# CAN bus interface
|
||||
python-can>=4.3.0
|
||||
|
||||
# Configuration validation
|
||||
pydantic>=2.5.0
|
||||
|
||||
# Type hints (optional, for development)
|
||||
typing-extensions>=4.8.0
|
||||
32
scripts/monitor_can.sh
Normal file
32
scripts/monitor_can.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Мониторинг CAN трафика с фильтрацией OBD2
|
||||
|
||||
INTERFACE=${1:-vcan0}
|
||||
|
||||
echo "=== CAN Monitor (OBD2) ==="
|
||||
echo "Interface: $INTERFACE"
|
||||
echo "Filtering: 7DF (requests) and 7E8 (responses)"
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
# Проверка candump
|
||||
if ! command -v candump &> /dev/null; then
|
||||
echo "Error: candump not found. Install can-utils:"
|
||||
echo " sudo apt install can-utils"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Мониторинг с фильтром
|
||||
candump "$INTERFACE" | grep -E "(7DF|7E8)" | while read line; do
|
||||
timestamp=$(date +"%H:%M:%S.%3N")
|
||||
|
||||
# Парсинг и форматирование
|
||||
if echo "$line" | grep -q "7DF"; then
|
||||
# Запрос
|
||||
pid=$(echo "$line" | awk '{print $3}' | cut -c5-6)
|
||||
echo "[$timestamp] REQUEST -> PID: 0x$pid"
|
||||
elif echo "$line" | grep -q "7E8"; then
|
||||
# Ответ
|
||||
echo "[$timestamp] RESPONSE <- $line"
|
||||
fi
|
||||
done
|
||||
62
scripts/setup_can.sh
Normal file
62
scripts/setup_can.sh
Normal file
@@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# Скрипт настройки CAN интерфейсов на Raspberry Pi 5 с 2CH CAN HAT
|
||||
|
||||
set -e
|
||||
|
||||
BITRATE=${1:-500000}
|
||||
|
||||
echo "=== CAN Interface Setup ==="
|
||||
echo "Bitrate: $BITRATE bps"
|
||||
echo ""
|
||||
|
||||
# Проверка прав
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "Please run as root (sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Загрузка модулей
|
||||
echo "[1/4] Loading kernel modules..."
|
||||
modprobe can
|
||||
modprobe can_raw
|
||||
modprobe mcp251x 2>/dev/null || true # Для SPI CAN контроллеров
|
||||
|
||||
# Настройка can0
|
||||
echo "[2/4] Configuring can0..."
|
||||
if ip link show can0 &>/dev/null; then
|
||||
ip link set can0 down 2>/dev/null || true
|
||||
ip link set can0 type can bitrate $BITRATE
|
||||
ip link set can0 up
|
||||
echo " can0: UP at $BITRATE bps"
|
||||
else
|
||||
echo " can0: Not found (skipping)"
|
||||
fi
|
||||
|
||||
# Настройка can1
|
||||
echo "[3/4] Configuring can1..."
|
||||
if ip link show can1 &>/dev/null; then
|
||||
ip link set can1 down 2>/dev/null || true
|
||||
ip link set can1 type can bitrate $BITRATE
|
||||
ip link set can1 up
|
||||
echo " can1: UP at $BITRATE bps"
|
||||
else
|
||||
echo " can1: Not found (skipping)"
|
||||
fi
|
||||
|
||||
# Создание vcan0 для тестирования
|
||||
echo "[4/4] Creating virtual CAN (vcan0)..."
|
||||
modprobe vcan
|
||||
if ! ip link show vcan0 &>/dev/null; then
|
||||
ip link add dev vcan0 type vcan
|
||||
fi
|
||||
ip link set vcan0 up
|
||||
echo " vcan0: UP (virtual)"
|
||||
|
||||
echo ""
|
||||
echo "=== Status ==="
|
||||
ip -details link show type can 2>/dev/null || echo "No physical CAN interfaces"
|
||||
ip -details link show type vcan 2>/dev/null || echo "No virtual CAN interfaces"
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "You can now run: python src/main.py -i can1 -s city"
|
||||
157
scripts/test_obd2.py
Normal file
157
scripts/test_obd2.py
Normal file
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Тестовый скрипт для проверки OBD2 эмулятора.
|
||||
Отправляет OBD2 запросы и выводит ответы.
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import can
|
||||
except ImportError:
|
||||
print("Error: python-can not installed. Run: pip install python-can")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# OBD2 PIDs для тестирования
|
||||
TEST_PIDS = [
|
||||
(0x00, "Supported PIDs [01-20]"),
|
||||
(0x05, "Coolant Temperature"),
|
||||
(0x0C, "Engine RPM"),
|
||||
(0x0D, "Vehicle Speed"),
|
||||
(0x0F, "Intake Air Temperature"),
|
||||
(0x11, "Throttle Position"),
|
||||
(0x2F, "Fuel Tank Level"),
|
||||
(0x46, "Ambient Temperature"),
|
||||
]
|
||||
|
||||
|
||||
def decode_pid(pid: int, data: bytes) -> str:
|
||||
"""Декодировать значение PID."""
|
||||
if len(data) < 1:
|
||||
return "No data"
|
||||
|
||||
if pid == 0x00:
|
||||
# Supported PIDs bitmap
|
||||
if len(data) >= 4:
|
||||
mask = int.from_bytes(data[:4], "big")
|
||||
supported = []
|
||||
for i in range(32):
|
||||
if mask & (1 << (31 - i)):
|
||||
supported.append(f"{i+1:02X}")
|
||||
return f"PIDs: {', '.join(supported[:10])}..."
|
||||
return f"Raw: {data.hex()}"
|
||||
|
||||
elif pid == 0x05 or pid == 0x0F or pid == 0x46:
|
||||
# Temperature: A - 40
|
||||
return f"{data[0] - 40} °C"
|
||||
|
||||
elif pid == 0x0C:
|
||||
# RPM: (A*256 + B) / 4
|
||||
if len(data) >= 2:
|
||||
rpm = (data[0] * 256 + data[1]) / 4
|
||||
return f"{rpm:.0f} rpm"
|
||||
|
||||
elif pid == 0x0D:
|
||||
# Speed: A
|
||||
return f"{data[0]} km/h"
|
||||
|
||||
elif pid == 0x11 or pid == 0x2F:
|
||||
# Percentage: A * 100 / 255
|
||||
return f"{data[0] * 100 / 255:.1f} %"
|
||||
|
||||
return f"Raw: {data.hex()}"
|
||||
|
||||
|
||||
def send_obd2_request(bus: can.Bus, pid: int, timeout: float = 1.0) -> bytes | None:
|
||||
"""Отправить OBD2 запрос и получить ответ."""
|
||||
# Формируем запрос Mode 01
|
||||
request = can.Message(
|
||||
arbitration_id=0x7DF,
|
||||
data=[0x02, 0x01, pid, 0x00, 0x00, 0x00, 0x00, 0x00],
|
||||
is_extended_id=False
|
||||
)
|
||||
|
||||
# Отправляем
|
||||
bus.send(request)
|
||||
|
||||
# Ждём ответ
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
msg = bus.recv(timeout=0.1)
|
||||
if msg is None:
|
||||
continue
|
||||
|
||||
# Проверяем, что это ответ от ECU
|
||||
if msg.arbitration_id == 0x7E8:
|
||||
data = bytes(msg.data)
|
||||
# Проверяем формат ответа
|
||||
if len(data) >= 3 and data[1] == 0x41 and data[2] == pid:
|
||||
# Возвращаем данные (без заголовка)
|
||||
return data[3:3 + data[0] - 2]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Test OBD2 Emulator")
|
||||
parser.add_argument(
|
||||
"-i", "--interface",
|
||||
default="vcan0",
|
||||
help="CAN interface [default: vcan0]"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--continuous",
|
||||
action="store_true",
|
||||
help="Continuous mode (loop forever)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interval",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Interval between requests in continuous mode [default: 1.0]"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"=== OBD2 Emulator Test ===")
|
||||
print(f"Interface: {args.interface}")
|
||||
print("")
|
||||
|
||||
try:
|
||||
bus = can.Bus(interface="socketcan", channel=args.interface)
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to connect to {args.interface}: {e}")
|
||||
print("Make sure the interface is up: sudo ip link set vcan0 up")
|
||||
return 1
|
||||
|
||||
try:
|
||||
while True:
|
||||
print(f"Timestamp: {time.strftime('%H:%M:%S')}")
|
||||
print("-" * 50)
|
||||
|
||||
for pid, name in TEST_PIDS:
|
||||
data = send_obd2_request(bus, pid)
|
||||
if data:
|
||||
value = decode_pid(pid, data)
|
||||
print(f" PID {pid:02X} ({name}): {value}")
|
||||
else:
|
||||
print(f" PID {pid:02X} ({name}): No response")
|
||||
|
||||
print("")
|
||||
|
||||
if not args.continuous:
|
||||
break
|
||||
|
||||
time.sleep(args.interval)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped")
|
||||
finally:
|
||||
bus.shutdown()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
2
src/__init__.py
Normal file
2
src/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""OBD2 Emulator - ECU simulator for testing onboard computers."""
|
||||
__version__ = "1.0.0"
|
||||
171
src/can_interface.py
Normal file
171
src/can_interface.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Управление CAN интерфейсом с автоматической настройкой.
|
||||
Поддержка физических (can0, can1) и виртуальных (vcan0) интерфейсов.
|
||||
"""
|
||||
import subprocess
|
||||
import platform
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
import can
|
||||
|
||||
from config import CANConfig
|
||||
from logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CANInterfaceError(Exception):
|
||||
"""Ошибка настройки CAN интерфейса."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CANFrame:
|
||||
"""CAN сообщение."""
|
||||
arbitration_id: int
|
||||
data: bytes
|
||||
is_extended_id: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_can_message(cls, msg: can.Message) -> "CANFrame":
|
||||
return cls(
|
||||
arbitration_id=msg.arbitration_id,
|
||||
data=bytes(msg.data),
|
||||
is_extended_id=msg.is_extended_id
|
||||
)
|
||||
|
||||
def to_can_message(self) -> can.Message:
|
||||
return can.Message(
|
||||
arbitration_id=self.arbitration_id,
|
||||
data=self.data,
|
||||
is_extended_id=self.is_extended_id
|
||||
)
|
||||
|
||||
|
||||
class CANInterface:
|
||||
"""
|
||||
Управление CAN интерфейсом.
|
||||
Автоматически поднимает интерфейс при инициализации.
|
||||
"""
|
||||
|
||||
def __init__(self, config: CANConfig):
|
||||
self.config = config
|
||||
self._bus: can.Bus | None = None
|
||||
self._is_setup = False
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Настроить и поднять CAN интерфейс."""
|
||||
if self._is_setup:
|
||||
return
|
||||
|
||||
if platform.system() != "Linux":
|
||||
logger.warning("CAN interface setup is only supported on Linux. Skipping.")
|
||||
self._is_setup = True
|
||||
return
|
||||
|
||||
try:
|
||||
if self.config.is_virtual:
|
||||
self._setup_virtual_can()
|
||||
else:
|
||||
self._setup_physical_can()
|
||||
self._is_setup = True
|
||||
logger.info(f"CAN interface {self.config.interface} is ready")
|
||||
except Exception as e:
|
||||
raise CANInterfaceError(f"Failed to setup {self.config.interface}: {e}")
|
||||
|
||||
def _run_command(self, cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Выполнить shell команду."""
|
||||
logger.debug(f"Running: {' '.join(cmd)}")
|
||||
return subprocess.run(cmd, capture_output=True, text=True, check=check)
|
||||
|
||||
def _setup_virtual_can(self) -> None:
|
||||
"""Настройка виртуального CAN интерфейса."""
|
||||
interface = self.config.interface
|
||||
|
||||
# Загружаем модуль vcan
|
||||
self._run_command(["sudo", "modprobe", "vcan"], check=False)
|
||||
|
||||
# Проверяем, существует ли интерфейс
|
||||
result = self._run_command(["ip", "link", "show", interface], check=False)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Создаём интерфейс
|
||||
logger.info(f"Creating virtual CAN interface: {interface}")
|
||||
self._run_command(["sudo", "ip", "link", "add", "dev", interface, "type", "vcan"])
|
||||
|
||||
# Поднимаем интерфейс
|
||||
logger.info(f"Bringing up interface: {interface}")
|
||||
self._run_command(["sudo", "ip", "link", "set", "up", interface])
|
||||
|
||||
def _setup_physical_can(self) -> None:
|
||||
"""Настройка физического CAN интерфейса."""
|
||||
interface = self.config.interface
|
||||
bitrate = self.config.bitrate
|
||||
|
||||
# Сначала опускаем интерфейс (если был поднят)
|
||||
self._run_command(["sudo", "ip", "link", "set", "down", interface], check=False)
|
||||
|
||||
# Настраиваем bitrate и поднимаем
|
||||
logger.info(f"Setting up physical CAN interface: {interface} at {bitrate} bps")
|
||||
self._run_command([
|
||||
"sudo", "ip", "link", "set", interface,
|
||||
"type", "can", "bitrate", str(bitrate)
|
||||
])
|
||||
self._run_command(["sudo", "ip", "link", "set", "up", interface])
|
||||
|
||||
def connect(self) -> None:
|
||||
"""Подключиться к CAN шине."""
|
||||
if not self._is_setup:
|
||||
self.setup()
|
||||
|
||||
if self._bus is not None:
|
||||
return
|
||||
|
||||
logger.info(f"Connecting to CAN bus: {self.config.interface}")
|
||||
self._bus = can.Bus(
|
||||
interface="socketcan",
|
||||
channel=self.config.interface,
|
||||
receive_own_messages=False
|
||||
)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Отключиться от CAN шины."""
|
||||
if self._bus:
|
||||
self._bus.shutdown()
|
||||
self._bus = None
|
||||
logger.info(f"Disconnected from {self.config.interface}")
|
||||
|
||||
def send(self, frame: CANFrame) -> None:
|
||||
"""Отправить CAN сообщение."""
|
||||
if not self._bus:
|
||||
raise CANInterfaceError("Not connected to CAN bus")
|
||||
|
||||
msg = frame.to_can_message()
|
||||
self._bus.send(msg)
|
||||
|
||||
if logger.isEnabledFor(10): # DEBUG level
|
||||
logger.debug(f"TX: {frame.arbitration_id:03X}#{frame.data.hex().upper()}")
|
||||
|
||||
def receive(self, timeout: float = 0.1) -> CANFrame | None:
|
||||
"""Получить CAN сообщение."""
|
||||
if not self._bus:
|
||||
raise CANInterfaceError("Not connected to CAN bus")
|
||||
|
||||
msg = self._bus.recv(timeout=timeout)
|
||||
if msg is None:
|
||||
return None
|
||||
|
||||
frame = CANFrame.from_can_message(msg)
|
||||
|
||||
if logger.isEnabledFor(10): # DEBUG level
|
||||
logger.debug(f"RX: {frame.arbitration_id:03X}#{frame.data.hex().upper()}")
|
||||
|
||||
return frame
|
||||
|
||||
def __enter__(self) -> "CANInterface":
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.disconnect()
|
||||
25
src/config.json
Normal file
25
src/config.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"can": {
|
||||
"interface": "vcan0",
|
||||
"bitrate": 500000
|
||||
},
|
||||
"vehicle": {
|
||||
"idle_rpm": 850,
|
||||
"max_rpm": 6500,
|
||||
"redline_rpm": 6000,
|
||||
"ambient_temp": 20.0,
|
||||
"target_coolant_temp": 90.0,
|
||||
"fuel_tank_capacity": 58.0,
|
||||
"initial_fuel_level": 75.0,
|
||||
"max_speed": 220,
|
||||
"engine_displacement": 2.0
|
||||
},
|
||||
"simulator": {
|
||||
"update_rate_hz": 10,
|
||||
"scenario": "idle"
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"log_can_frames": false
|
||||
}
|
||||
}
|
||||
104
src/config.py
Normal file
104
src/config.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Конфигурация OBD2 эмулятора.
|
||||
Поддержка физических CAN интерфейсов (can0, can1) и виртуальных (vcan0).
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class CANConfig(BaseModel):
|
||||
"""Конфигурация CAN интерфейса."""
|
||||
interface: str = Field(default="vcan0", description="CAN interface name")
|
||||
bitrate: int = Field(default=500000, description="CAN bitrate (ignored for vcan)")
|
||||
|
||||
@field_validator("interface")
|
||||
@classmethod
|
||||
def validate_interface(cls, v: str) -> str:
|
||||
valid_prefixes = ("can", "vcan")
|
||||
if not any(v.startswith(prefix) for prefix in valid_prefixes):
|
||||
raise ValueError(f"Interface must start with: {valid_prefixes}")
|
||||
return v
|
||||
|
||||
@property
|
||||
def is_virtual(self) -> bool:
|
||||
"""Check if interface is virtual."""
|
||||
return self.interface.startswith("vcan")
|
||||
|
||||
|
||||
class VehicleConfig(BaseModel):
|
||||
"""Конфигурация симулируемого автомобиля."""
|
||||
# Базовые параметры двигателя
|
||||
idle_rpm: int = Field(default=850, ge=600, le=1200)
|
||||
max_rpm: int = Field(default=6500, ge=4000, le=9000)
|
||||
redline_rpm: int = Field(default=6000, ge=3500, le=8500)
|
||||
|
||||
# Температуры
|
||||
ambient_temp: float = Field(default=20.0, ge=-40, le=50)
|
||||
target_coolant_temp: float = Field(default=90.0, ge=80, le=105)
|
||||
|
||||
# Топливо
|
||||
fuel_tank_capacity: float = Field(default=58.0, ge=30, le=100)
|
||||
initial_fuel_level: float = Field(default=75.0, ge=0, le=100)
|
||||
|
||||
# Характеристики авто
|
||||
max_speed: int = Field(default=220, ge=100, le=350)
|
||||
engine_displacement: float = Field(default=2.0, description="Engine displacement in liters")
|
||||
|
||||
|
||||
class SimulatorConfig(BaseModel):
|
||||
"""Конфигурация симулятора."""
|
||||
update_rate_hz: int = Field(default=10, ge=1, le=100, description="Vehicle state update rate")
|
||||
scenario: Literal["idle", "city", "highway", "warmup", "manual"] = Field(default="idle")
|
||||
|
||||
|
||||
class LoggingConfig(BaseModel):
|
||||
"""Конфигурация логирования."""
|
||||
level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(default="INFO")
|
||||
format: str = Field(default="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s")
|
||||
log_can_frames: bool = Field(default=False, description="Log all CAN frames (verbose)")
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""Главная конфигурация приложения."""
|
||||
can: CANConfig = Field(default_factory=CANConfig)
|
||||
vehicle: VehicleConfig = Field(default_factory=VehicleConfig)
|
||||
simulator: SimulatorConfig = Field(default_factory=SimulatorConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
|
||||
|
||||
def load_config(config_path: Path | None = None) -> Config:
|
||||
"""Загрузка конфигурации из файла или defaults."""
|
||||
if config_path and config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return Config.model_validate(data)
|
||||
return Config()
|
||||
|
||||
|
||||
# Глобальный экземпляр конфигурации
|
||||
_config: Config | None = None
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Получить глобальную конфигурацию."""
|
||||
global _config
|
||||
if _config is None:
|
||||
config_path = Path(__file__).parent / "config.json"
|
||||
_config = load_config(config_path if config_path.exists() else None)
|
||||
return _config
|
||||
|
||||
|
||||
def set_config(config: Config) -> None:
|
||||
"""Установить глобальную конфигурацию."""
|
||||
global _config
|
||||
_config = config
|
||||
|
||||
|
||||
# Прокси для удобного доступа
|
||||
class _ConfigProxy:
|
||||
def __getattr__(self, name):
|
||||
return getattr(get_config(), name)
|
||||
|
||||
config = _ConfigProxy()
|
||||
30
src/logger.py
Normal file
30
src/logger.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Настройка логирования для OBD2 эмулятора.
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from config import config
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Настройка корневого логгера."""
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(config.logging.level)
|
||||
|
||||
# Очищаем существующие хендлеры
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(config.logging.level)
|
||||
console_handler.setFormatter(logging.Formatter(config.logging.format))
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Получить логгер по имени."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
# Инициализация при импорте
|
||||
setup_logging()
|
||||
325
src/main.py
Normal file
325
src/main.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
OBD2 Emulator - Главный модуль.
|
||||
Эмулятор ECU для тестирования бортовых компьютеров.
|
||||
"""
|
||||
import argparse
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from config import Config, CANConfig, VehicleConfig, SimulatorConfig, set_config, get_config
|
||||
from logger import get_logger, setup_logging
|
||||
from can_interface import CANInterface, CANFrame, CANInterfaceError
|
||||
from obd2 import OBD2Protocol
|
||||
from simulator import VehicleSimulator, ScenarioManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OBD2Emulator:
|
||||
"""
|
||||
Главный класс эмулятора OBD2.
|
||||
Объединяет CAN интерфейс, OBD2 протокол и симулятор автомобиля.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.can_interface = CANInterface(config.can)
|
||||
self.obd2_protocol = OBD2Protocol()
|
||||
self.vehicle_simulator = VehicleSimulator(config.vehicle)
|
||||
self.scenario_manager = ScenarioManager(self.vehicle_simulator)
|
||||
|
||||
self._running = False
|
||||
self._update_thread: threading.Thread | None = None
|
||||
self._can_thread: threading.Thread | None = None
|
||||
|
||||
def start(self, scenario: str = "idle") -> None:
|
||||
"""Запустить эмулятор."""
|
||||
logger.info("Starting OBD2 Emulator...")
|
||||
|
||||
# Настраиваем CAN интерфейс
|
||||
try:
|
||||
self.can_interface.setup()
|
||||
self.can_interface.connect()
|
||||
except CANInterfaceError as e:
|
||||
logger.error(f"Failed to setup CAN interface: {e}")
|
||||
raise
|
||||
|
||||
# Запускаем сценарий
|
||||
self.scenario_manager.start(scenario)
|
||||
|
||||
self._running = True
|
||||
|
||||
# Поток обновления симулятора
|
||||
self._update_thread = threading.Thread(
|
||||
target=self._update_loop,
|
||||
name="SimulatorUpdate",
|
||||
daemon=True
|
||||
)
|
||||
self._update_thread.start()
|
||||
|
||||
# Поток обработки CAN
|
||||
self._can_thread = threading.Thread(
|
||||
target=self._can_loop,
|
||||
name="CANHandler",
|
||||
daemon=True
|
||||
)
|
||||
self._can_thread.start()
|
||||
|
||||
logger.info(f"OBD2 Emulator started on {self.config.can.interface}")
|
||||
logger.info(f"Scenario: {scenario}")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Остановить эмулятор."""
|
||||
logger.info("Stopping OBD2 Emulator...")
|
||||
self._running = False
|
||||
|
||||
self.scenario_manager.stop()
|
||||
self.vehicle_simulator.stop_engine()
|
||||
|
||||
# Ждём завершения потоков
|
||||
if self._update_thread and self._update_thread.is_alive():
|
||||
self._update_thread.join(timeout=2.0)
|
||||
|
||||
if self._can_thread and self._can_thread.is_alive():
|
||||
self._can_thread.join(timeout=2.0)
|
||||
|
||||
self.can_interface.disconnect()
|
||||
logger.info("OBD2 Emulator stopped")
|
||||
|
||||
def _update_loop(self) -> None:
|
||||
"""Цикл обновления симулятора."""
|
||||
update_interval = 1.0 / self.config.simulator.update_rate_hz
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Обновляем сценарий
|
||||
self.scenario_manager.update()
|
||||
|
||||
# Обновляем симулятор
|
||||
self.vehicle_simulator.update()
|
||||
|
||||
time.sleep(update_interval)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in update loop: {e}", exc_info=True)
|
||||
time.sleep(0.1)
|
||||
|
||||
def _can_loop(self) -> None:
|
||||
"""Цикл обработки CAN сообщений."""
|
||||
while self._running:
|
||||
try:
|
||||
# Получаем CAN фрейм
|
||||
frame = self.can_interface.receive(timeout=0.1)
|
||||
if frame is None:
|
||||
continue
|
||||
|
||||
# Обрабатываем как OBD2 запрос
|
||||
response_frame = self.obd2_protocol.process_frame(
|
||||
frame,
|
||||
self.vehicle_simulator.get_state()
|
||||
)
|
||||
|
||||
# Отправляем ответ
|
||||
if response_frame:
|
||||
self.can_interface.send(response_frame)
|
||||
|
||||
except CANInterfaceError as e:
|
||||
logger.error(f"CAN error: {e}")
|
||||
time.sleep(0.1)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in CAN loop: {e}", exc_info=True)
|
||||
time.sleep(0.1)
|
||||
|
||||
def switch_scenario(self, scenario: str) -> None:
|
||||
"""Переключить сценарий."""
|
||||
self.scenario_manager.stop()
|
||||
self.scenario_manager.start(scenario)
|
||||
logger.info(f"Switched to scenario: {scenario}")
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Получить статус эмулятора."""
|
||||
state = self.vehicle_simulator.get_state()
|
||||
return {
|
||||
"running": self._running,
|
||||
"interface": self.config.can.interface,
|
||||
"scenario": self.scenario_manager.current_scenario.name if self.scenario_manager.current_scenario else None,
|
||||
"vehicle": {
|
||||
"engine_running": state.engine_running,
|
||||
"rpm": round(state.rpm),
|
||||
"speed": round(state.speed, 1),
|
||||
"coolant_temp": round(state.coolant_temp, 1),
|
||||
"throttle": round(state.throttle_pos, 1),
|
||||
"fuel_level": round(state.fuel_level, 1),
|
||||
},
|
||||
"obd2_stats": self.obd2_protocol.get_stats(),
|
||||
}
|
||||
|
||||
def print_status(self) -> None:
|
||||
"""Вывести статус в консоль."""
|
||||
state = self.vehicle_simulator.get_state()
|
||||
stats = self.obd2_protocol.get_stats()
|
||||
|
||||
print("\033[2J\033[H") # Clear screen
|
||||
print("=" * 60)
|
||||
print(" OBD2 EMULATOR STATUS")
|
||||
print("=" * 60)
|
||||
print(f" Interface: {self.config.can.interface}")
|
||||
print(f" Scenario: {self.scenario_manager.current_scenario.name if self.scenario_manager.current_scenario else 'None'}")
|
||||
print("-" * 60)
|
||||
print(" ENGINE:")
|
||||
print(f" Running: {'YES' if state.engine_running else 'NO'}")
|
||||
print(f" RPM: {state.rpm:>6.0f} rpm")
|
||||
print(f" Load: {state.engine_load:>6.1f} %")
|
||||
print(f" Throttle: {state.throttle_pos:>6.1f} %")
|
||||
print("-" * 60)
|
||||
print(" VEHICLE:")
|
||||
print(f" Speed: {state.speed:>6.1f} km/h")
|
||||
print(f" Gear: {state.gear:>6}")
|
||||
print(f" Distance: {state.total_distance:>6.1f} km")
|
||||
print("-" * 60)
|
||||
print(" TEMPERATURES:")
|
||||
print(f" Coolant: {state.coolant_temp:>6.1f} °C")
|
||||
print(f" Oil: {state.oil_temp:>6.1f} °C")
|
||||
print(f" Intake Air: {state.intake_temp:>6.1f} °C")
|
||||
print(f" Ambient: {state.ambient_temp:>6.1f} °C")
|
||||
print("-" * 60)
|
||||
print(" FUEL:")
|
||||
print(f" Level: {state.fuel_level:>6.1f} %")
|
||||
print(f" Rate: {state.fuel_rate:>6.2f} L/h")
|
||||
print("-" * 60)
|
||||
print(" OBD2 STATS:")
|
||||
print(f" Requests: {stats['requests_received']:>6}")
|
||||
print(f" Responses: {stats['responses_sent']:>6}")
|
||||
print(f" Unsupported: {stats['unsupported_requests']:>6}")
|
||||
print("=" * 60)
|
||||
print(" Press Ctrl+C to stop")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Парсинг аргументов командной строки."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="OBD2 Emulator - ECU simulator for testing onboard computers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Available scenarios:
|
||||
idle - Engine idling, vehicle stationary
|
||||
warmup - Cold start engine warmup
|
||||
city - City driving with stops
|
||||
highway - Highway cruising at 110-120 km/h
|
||||
acceleration - Full throttle acceleration test
|
||||
dynamic_city - Advanced city driving patterns
|
||||
manual - Manual control (no automation)
|
||||
|
||||
Examples:
|
||||
python main.py --interface can1 --scenario city
|
||||
python main.py --interface vcan0 --scenario highway
|
||||
python main.py -i vcan0 -s warmup --status-interval 5
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-i", "--interface",
|
||||
default="vcan0",
|
||||
help="CAN interface (can0, can1, vcan0, etc.) [default: vcan0]"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-b", "--bitrate",
|
||||
type=int,
|
||||
default=500000,
|
||||
help="CAN bitrate in bps (ignored for vcan) [default: 500000]"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-s", "--scenario",
|
||||
default="idle",
|
||||
choices=["idle", "warmup", "city", "highway", "acceleration", "dynamic_city", "manual"],
|
||||
help="Initial scenario [default: idle]"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--status-interval",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Status display interval in seconds [default: 1.0]"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-status",
|
||||
action="store_true",
|
||||
help="Disable status display"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Enable debug logging"
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Главная функция."""
|
||||
args = parse_args()
|
||||
|
||||
# Создаём конфигурацию
|
||||
config = Config(
|
||||
can=CANConfig(
|
||||
interface=args.interface,
|
||||
bitrate=args.bitrate,
|
||||
),
|
||||
vehicle=VehicleConfig(),
|
||||
simulator=SimulatorConfig(
|
||||
scenario=args.scenario,
|
||||
),
|
||||
)
|
||||
|
||||
if args.debug:
|
||||
config.logging.level = "DEBUG"
|
||||
|
||||
set_config(config)
|
||||
setup_logging()
|
||||
|
||||
# Создаём эмулятор
|
||||
emulator = OBD2Emulator(config)
|
||||
|
||||
# Обработчик сигналов
|
||||
def signal_handler(sig, frame):
|
||||
logger.info("Received shutdown signal")
|
||||
emulator.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
try:
|
||||
emulator.start(scenario=args.scenario)
|
||||
|
||||
# Главный цикл с отображением статуса
|
||||
while True:
|
||||
if not args.no_status:
|
||||
emulator.print_status()
|
||||
|
||||
time.sleep(args.status_interval)
|
||||
|
||||
except CANInterfaceError as e:
|
||||
logger.error(f"CAN interface error: {e}")
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||
return 1
|
||||
finally:
|
||||
emulator.stop()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
12
src/obd2/__init__.py
Normal file
12
src/obd2/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""OBD2 протокол и обработка PIDs."""
|
||||
from .pids import PID, PIDRegistry, get_pid_registry
|
||||
from .protocol import OBD2Protocol, OBD2Request, OBD2Response
|
||||
|
||||
__all__ = [
|
||||
"PID",
|
||||
"PIDRegistry",
|
||||
"get_pid_registry",
|
||||
"OBD2Protocol",
|
||||
"OBD2Request",
|
||||
"OBD2Response",
|
||||
]
|
||||
476
src/obd2/pids.py
Normal file
476
src/obd2/pids.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Определения OBD2 PIDs согласно SAE J1979.
|
||||
Поддержка Mode 01 (текущие данные) и Mode 09 (информация об авто).
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import Callable, Any
|
||||
|
||||
|
||||
class OBD2Mode(IntEnum):
|
||||
"""OBD2 режимы (сервисы)."""
|
||||
CURRENT_DATA = 0x01 # Текущие данные
|
||||
FREEZE_FRAME = 0x02 # Стоп-кадр
|
||||
STORED_DTC = 0x03 # Сохранённые ошибки
|
||||
CLEAR_DTC = 0x04 # Очистка ошибок
|
||||
TEST_RESULTS_O2 = 0x05 # Тесты кислородных датчиков
|
||||
TEST_RESULTS = 0x06 # Результаты тестов
|
||||
PENDING_DTC = 0x07 # Ожидающие ошибки
|
||||
CONTROL = 0x08 # Управление
|
||||
VEHICLE_INFO = 0x09 # Информация об автомобиле
|
||||
PERMANENT_DTC = 0x0A # Постоянные ошибки
|
||||
|
||||
|
||||
@dataclass
|
||||
class PID:
|
||||
"""Определение OBD2 PID."""
|
||||
pid: int
|
||||
name: str
|
||||
description: str
|
||||
unit: str
|
||||
min_value: float
|
||||
max_value: float
|
||||
bytes_count: int
|
||||
# Функция кодирования: value -> bytes
|
||||
encoder: Callable[[float], bytes]
|
||||
# Функция декодирования: bytes -> value (для отладки)
|
||||
decoder: Callable[[bytes], float] | None = None
|
||||
|
||||
|
||||
class PIDRegistry:
|
||||
"""Реестр всех поддерживаемых PIDs."""
|
||||
|
||||
def __init__(self):
|
||||
self._pids: dict[int, PID] = {}
|
||||
self._register_mode01_pids()
|
||||
|
||||
def register(self, pid: PID) -> None:
|
||||
"""Зарегистрировать PID."""
|
||||
self._pids[pid.pid] = pid
|
||||
|
||||
def get(self, pid_code: int) -> PID | None:
|
||||
"""Получить PID по коду."""
|
||||
return self._pids.get(pid_code)
|
||||
|
||||
def get_supported_pids_mask(self, start_pid: int) -> int:
|
||||
"""
|
||||
Получить битовую маску поддерживаемых PIDs.
|
||||
Для PIDs 0x00, 0x20, 0x40, 0x60, 0x80, 0xA0, 0xC0, 0xE0
|
||||
"""
|
||||
mask = 0
|
||||
for i in range(32):
|
||||
pid_code = start_pid + i + 1
|
||||
if pid_code in self._pids:
|
||||
mask |= (1 << (31 - i))
|
||||
return mask
|
||||
|
||||
def _register_mode01_pids(self) -> None:
|
||||
"""Регистрация Mode 01 PIDs."""
|
||||
|
||||
# PID 00: Supported PIDs [01-20]
|
||||
self.register(PID(
|
||||
pid=0x00,
|
||||
name="PIDS_SUPPORTED_01_20",
|
||||
description="Supported PIDs [01-20]",
|
||||
unit="",
|
||||
min_value=0, max_value=0xFFFFFFFF,
|
||||
bytes_count=4,
|
||||
encoder=lambda v: int(v).to_bytes(4, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big")
|
||||
))
|
||||
|
||||
# PID 01: Monitor status since DTCs cleared
|
||||
self.register(PID(
|
||||
pid=0x01,
|
||||
name="MONITOR_STATUS",
|
||||
description="Monitor status since DTCs cleared",
|
||||
unit="",
|
||||
min_value=0, max_value=0xFFFFFFFF,
|
||||
bytes_count=4,
|
||||
encoder=lambda v: int(v).to_bytes(4, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big")
|
||||
))
|
||||
|
||||
# PID 03: Fuel system status
|
||||
self.register(PID(
|
||||
pid=0x03,
|
||||
name="FUEL_SYSTEM_STATUS",
|
||||
description="Fuel system status",
|
||||
unit="",
|
||||
min_value=0, max_value=0xFFFF,
|
||||
bytes_count=2,
|
||||
encoder=lambda v: int(v).to_bytes(2, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big")
|
||||
))
|
||||
|
||||
# PID 04: Calculated engine load
|
||||
self.register(PID(
|
||||
pid=0x04,
|
||||
name="ENGINE_LOAD",
|
||||
description="Calculated engine load",
|
||||
unit="%",
|
||||
min_value=0, max_value=100,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v * 255 / 100)]),
|
||||
decoder=lambda b: b[0] * 100 / 255
|
||||
))
|
||||
|
||||
# PID 05: Engine coolant temperature
|
||||
self.register(PID(
|
||||
pid=0x05,
|
||||
name="COOLANT_TEMP",
|
||||
description="Engine coolant temperature",
|
||||
unit="°C",
|
||||
min_value=-40, max_value=215,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v + 40)]),
|
||||
decoder=lambda b: b[0] - 40
|
||||
))
|
||||
|
||||
# PID 06: Short term fuel trim - Bank 1
|
||||
self.register(PID(
|
||||
pid=0x06,
|
||||
name="SHORT_FUEL_TRIM_1",
|
||||
description="Short term fuel trim - Bank 1",
|
||||
unit="%",
|
||||
min_value=-100, max_value=99.2,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int((v + 100) * 128 / 100)]),
|
||||
decoder=lambda b: (b[0] - 128) * 100 / 128
|
||||
))
|
||||
|
||||
# PID 07: Long term fuel trim - Bank 1
|
||||
self.register(PID(
|
||||
pid=0x07,
|
||||
name="LONG_FUEL_TRIM_1",
|
||||
description="Long term fuel trim - Bank 1",
|
||||
unit="%",
|
||||
min_value=-100, max_value=99.2,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int((v + 100) * 128 / 100)]),
|
||||
decoder=lambda b: (b[0] - 128) * 100 / 128
|
||||
))
|
||||
|
||||
# PID 0B: Intake manifold absolute pressure
|
||||
self.register(PID(
|
||||
pid=0x0B,
|
||||
name="INTAKE_PRESSURE",
|
||||
description="Intake manifold absolute pressure",
|
||||
unit="kPa",
|
||||
min_value=0, max_value=255,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v)]),
|
||||
decoder=lambda b: b[0]
|
||||
))
|
||||
|
||||
# PID 0C: Engine RPM
|
||||
self.register(PID(
|
||||
pid=0x0C,
|
||||
name="ENGINE_RPM",
|
||||
description="Engine RPM",
|
||||
unit="rpm",
|
||||
min_value=0, max_value=16383.75,
|
||||
bytes_count=2,
|
||||
encoder=lambda v: int(v * 4).to_bytes(2, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big") / 4
|
||||
))
|
||||
|
||||
# PID 0D: Vehicle speed
|
||||
self.register(PID(
|
||||
pid=0x0D,
|
||||
name="VEHICLE_SPEED",
|
||||
description="Vehicle speed",
|
||||
unit="km/h",
|
||||
min_value=0, max_value=255,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v)]),
|
||||
decoder=lambda b: b[0]
|
||||
))
|
||||
|
||||
# PID 0E: Timing advance
|
||||
self.register(PID(
|
||||
pid=0x0E,
|
||||
name="TIMING_ADVANCE",
|
||||
description="Timing advance",
|
||||
unit="° before TDC",
|
||||
min_value=-64, max_value=63.5,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int((v + 64) * 2)]),
|
||||
decoder=lambda b: b[0] / 2 - 64
|
||||
))
|
||||
|
||||
# PID 0F: Intake air temperature
|
||||
self.register(PID(
|
||||
pid=0x0F,
|
||||
name="INTAKE_TEMP",
|
||||
description="Intake air temperature",
|
||||
unit="°C",
|
||||
min_value=-40, max_value=215,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v + 40)]),
|
||||
decoder=lambda b: b[0] - 40
|
||||
))
|
||||
|
||||
# PID 10: MAF air flow rate
|
||||
self.register(PID(
|
||||
pid=0x10,
|
||||
name="MAF_FLOW",
|
||||
description="MAF air flow rate",
|
||||
unit="g/s",
|
||||
min_value=0, max_value=655.35,
|
||||
bytes_count=2,
|
||||
encoder=lambda v: int(v * 100).to_bytes(2, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big") / 100
|
||||
))
|
||||
|
||||
# PID 11: Throttle position
|
||||
self.register(PID(
|
||||
pid=0x11,
|
||||
name="THROTTLE_POS",
|
||||
description="Throttle position",
|
||||
unit="%",
|
||||
min_value=0, max_value=100,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v * 255 / 100)]),
|
||||
decoder=lambda b: b[0] * 100 / 255
|
||||
))
|
||||
|
||||
# PID 1C: OBD standards this vehicle conforms to
|
||||
self.register(PID(
|
||||
pid=0x1C,
|
||||
name="OBD_STANDARD",
|
||||
description="OBD standards",
|
||||
unit="",
|
||||
min_value=0, max_value=255,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v)]),
|
||||
decoder=lambda b: b[0]
|
||||
))
|
||||
|
||||
# PID 1F: Run time since engine start
|
||||
self.register(PID(
|
||||
pid=0x1F,
|
||||
name="RUN_TIME",
|
||||
description="Run time since engine start",
|
||||
unit="s",
|
||||
min_value=0, max_value=65535,
|
||||
bytes_count=2,
|
||||
encoder=lambda v: int(v).to_bytes(2, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big")
|
||||
))
|
||||
|
||||
# PID 20: Supported PIDs [21-40]
|
||||
self.register(PID(
|
||||
pid=0x20,
|
||||
name="PIDS_SUPPORTED_21_40",
|
||||
description="Supported PIDs [21-40]",
|
||||
unit="",
|
||||
min_value=0, max_value=0xFFFFFFFF,
|
||||
bytes_count=4,
|
||||
encoder=lambda v: int(v).to_bytes(4, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big")
|
||||
))
|
||||
|
||||
# PID 21: Distance traveled with MIL on
|
||||
self.register(PID(
|
||||
pid=0x21,
|
||||
name="DISTANCE_MIL_ON",
|
||||
description="Distance traveled with MIL on",
|
||||
unit="km",
|
||||
min_value=0, max_value=65535,
|
||||
bytes_count=2,
|
||||
encoder=lambda v: int(v).to_bytes(2, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big")
|
||||
))
|
||||
|
||||
# PID 23: Fuel Rail Gauge Pressure
|
||||
self.register(PID(
|
||||
pid=0x23,
|
||||
name="FUEL_RAIL_PRESSURE",
|
||||
description="Fuel Rail Gauge Pressure",
|
||||
unit="kPa",
|
||||
min_value=0, max_value=655350,
|
||||
bytes_count=2,
|
||||
encoder=lambda v: int(v / 10).to_bytes(2, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big") * 10
|
||||
))
|
||||
|
||||
# PID 2F: Fuel Tank Level Input
|
||||
self.register(PID(
|
||||
pid=0x2F,
|
||||
name="FUEL_LEVEL",
|
||||
description="Fuel Tank Level Input",
|
||||
unit="%",
|
||||
min_value=0, max_value=100,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v * 255 / 100)]),
|
||||
decoder=lambda b: b[0] * 100 / 255
|
||||
))
|
||||
|
||||
# PID 31: Distance since codes cleared
|
||||
self.register(PID(
|
||||
pid=0x31,
|
||||
name="DISTANCE_SINCE_CLR",
|
||||
description="Distance since codes cleared",
|
||||
unit="km",
|
||||
min_value=0, max_value=65535,
|
||||
bytes_count=2,
|
||||
encoder=lambda v: int(v).to_bytes(2, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big")
|
||||
))
|
||||
|
||||
# PID 33: Absolute Barometric Pressure
|
||||
self.register(PID(
|
||||
pid=0x33,
|
||||
name="BAROMETRIC_PRESSURE",
|
||||
description="Absolute Barometric Pressure",
|
||||
unit="kPa",
|
||||
min_value=0, max_value=255,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v)]),
|
||||
decoder=lambda b: b[0]
|
||||
))
|
||||
|
||||
# PID 40: Supported PIDs [41-60]
|
||||
self.register(PID(
|
||||
pid=0x40,
|
||||
name="PIDS_SUPPORTED_41_60",
|
||||
description="Supported PIDs [41-60]",
|
||||
unit="",
|
||||
min_value=0, max_value=0xFFFFFFFF,
|
||||
bytes_count=4,
|
||||
encoder=lambda v: int(v).to_bytes(4, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big")
|
||||
))
|
||||
|
||||
# PID 42: Control module voltage
|
||||
self.register(PID(
|
||||
pid=0x42,
|
||||
name="CONTROL_MODULE_VOLTAGE",
|
||||
description="Control module voltage",
|
||||
unit="V",
|
||||
min_value=0, max_value=65.535,
|
||||
bytes_count=2,
|
||||
encoder=lambda v: int(v * 1000).to_bytes(2, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big") / 1000
|
||||
))
|
||||
|
||||
# PID 45: Relative throttle position
|
||||
self.register(PID(
|
||||
pid=0x45,
|
||||
name="RELATIVE_THROTTLE_POS",
|
||||
description="Relative throttle position",
|
||||
unit="%",
|
||||
min_value=0, max_value=100,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v * 255 / 100)]),
|
||||
decoder=lambda b: b[0] * 100 / 255
|
||||
))
|
||||
|
||||
# PID 46: Ambient air temperature
|
||||
self.register(PID(
|
||||
pid=0x46,
|
||||
name="AMBIENT_TEMP",
|
||||
description="Ambient air temperature",
|
||||
unit="°C",
|
||||
min_value=-40, max_value=215,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v + 40)]),
|
||||
decoder=lambda b: b[0] - 40
|
||||
))
|
||||
|
||||
# PID 49: Accelerator pedal position D
|
||||
self.register(PID(
|
||||
pid=0x49,
|
||||
name="ACCELERATOR_POS_D",
|
||||
description="Accelerator pedal position D",
|
||||
unit="%",
|
||||
min_value=0, max_value=100,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v * 255 / 100)]),
|
||||
decoder=lambda b: b[0] * 100 / 255
|
||||
))
|
||||
|
||||
# PID 4A: Accelerator pedal position E
|
||||
self.register(PID(
|
||||
pid=0x4A,
|
||||
name="ACCELERATOR_POS_E",
|
||||
description="Accelerator pedal position E",
|
||||
unit="%",
|
||||
min_value=0, max_value=100,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v * 255 / 100)]),
|
||||
decoder=lambda b: b[0] * 100 / 255
|
||||
))
|
||||
|
||||
# PID 4C: Commanded throttle actuator
|
||||
self.register(PID(
|
||||
pid=0x4C,
|
||||
name="COMMANDED_THROTTLE",
|
||||
description="Commanded throttle actuator",
|
||||
unit="%",
|
||||
min_value=0, max_value=100,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v * 255 / 100)]),
|
||||
decoder=lambda b: b[0] * 100 / 255
|
||||
))
|
||||
|
||||
# PID 5C: Engine oil temperature
|
||||
self.register(PID(
|
||||
pid=0x5C,
|
||||
name="OIL_TEMP",
|
||||
description="Engine oil temperature",
|
||||
unit="°C",
|
||||
min_value=-40, max_value=210,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v + 40)]),
|
||||
decoder=lambda b: b[0] - 40
|
||||
))
|
||||
|
||||
# PID 5E: Engine fuel rate
|
||||
self.register(PID(
|
||||
pid=0x5E,
|
||||
name="FUEL_RATE",
|
||||
description="Engine fuel rate",
|
||||
unit="L/h",
|
||||
min_value=0, max_value=3276.75,
|
||||
bytes_count=2,
|
||||
encoder=lambda v: int(v * 20).to_bytes(2, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big") / 20
|
||||
))
|
||||
|
||||
# PID 60: Supported PIDs [61-80]
|
||||
self.register(PID(
|
||||
pid=0x60,
|
||||
name="PIDS_SUPPORTED_61_80",
|
||||
description="Supported PIDs [61-80]",
|
||||
unit="",
|
||||
min_value=0, max_value=0xFFFFFFFF,
|
||||
bytes_count=4,
|
||||
encoder=lambda v: int(v).to_bytes(4, "big"),
|
||||
decoder=lambda b: int.from_bytes(b, "big")
|
||||
))
|
||||
|
||||
# PID 62: Actual engine percent torque
|
||||
self.register(PID(
|
||||
pid=0x62,
|
||||
name="ENGINE_TORQUE_ACTUAL",
|
||||
description="Actual engine percent torque",
|
||||
unit="%",
|
||||
min_value=-125, max_value=130,
|
||||
bytes_count=1,
|
||||
encoder=lambda v: bytes([int(v + 125)]),
|
||||
decoder=lambda b: b[0] - 125
|
||||
))
|
||||
|
||||
|
||||
# Синглтон реестра
|
||||
_pid_registry: PIDRegistry | None = None
|
||||
|
||||
|
||||
def get_pid_registry() -> PIDRegistry:
|
||||
"""Получить глобальный реестр PIDs."""
|
||||
global _pid_registry
|
||||
if _pid_registry is None:
|
||||
_pid_registry = PIDRegistry()
|
||||
return _pid_registry
|
||||
245
src/obd2/protocol.py
Normal file
245
src/obd2/protocol.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
OBD2 протокол поверх CAN (ISO 15765-4).
|
||||
Обработка запросов и формирование ответов.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from can_interface import CANFrame
|
||||
from logger import get_logger
|
||||
from .pids import OBD2Mode, get_pid_registry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from simulator.vehicle import VehicleState
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# Стандартные CAN ID для OBD2
|
||||
OBD2_REQUEST_ID = 0x7DF # Broadcast запрос ко всем ECU
|
||||
OBD2_RESPONSE_ID = 0x7E8 # Ответ от Engine ECU (первый ECU)
|
||||
OBD2_FUNCTIONAL_RANGE = range(0x7E0, 0x7E8) # ECU физические адреса
|
||||
|
||||
|
||||
@dataclass
|
||||
class OBD2Request:
|
||||
"""Разобранный OBD2 запрос."""
|
||||
mode: int
|
||||
pid: int
|
||||
raw_data: bytes
|
||||
|
||||
@classmethod
|
||||
def from_can_frame(cls, frame: CANFrame) -> "OBD2Request | None":
|
||||
"""Распарсить CAN фрейм как OBD2 запрос."""
|
||||
# Проверяем ID
|
||||
if frame.arbitration_id != OBD2_REQUEST_ID and frame.arbitration_id not in OBD2_FUNCTIONAL_RANGE:
|
||||
return None
|
||||
|
||||
data = frame.data
|
||||
if len(data) < 2:
|
||||
return None
|
||||
|
||||
# Первый байт - длина данных (PCI byte для Single Frame)
|
||||
length = data[0]
|
||||
if length < 1 or length > 7:
|
||||
return None
|
||||
|
||||
# Второй байт - режим (mode/service)
|
||||
mode = data[1]
|
||||
|
||||
# Третий байт - PID (если есть)
|
||||
pid = data[2] if length > 1 and len(data) > 2 else 0
|
||||
|
||||
return cls(mode=mode, pid=pid, raw_data=data)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"OBD2Request(mode={self.mode:02X}, pid={self.pid:02X})"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OBD2Response:
|
||||
"""OBD2 ответ для отправки."""
|
||||
mode: int # Mode + 0x40
|
||||
pid: int
|
||||
data: bytes
|
||||
|
||||
def to_can_frame(self) -> CANFrame:
|
||||
"""Преобразовать в CAN фрейм."""
|
||||
# Single Frame формат: [length, mode+0x40, pid, data...]
|
||||
payload = bytes([
|
||||
len(self.data) + 2, # Length: mode + pid + data
|
||||
self.mode + 0x40, # Response mode
|
||||
self.pid, # PID
|
||||
]) + self.data
|
||||
|
||||
# Дополняем до 8 байт
|
||||
payload = payload.ljust(8, b'\x00')
|
||||
|
||||
return CANFrame(
|
||||
arbitration_id=OBD2_RESPONSE_ID,
|
||||
data=payload
|
||||
)
|
||||
|
||||
|
||||
class OBD2Protocol:
|
||||
"""
|
||||
Обработчик OBD2 протокола.
|
||||
Получает запросы, формирует ответы на основе состояния автомобиля.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.pid_registry = get_pid_registry()
|
||||
self._stats = {
|
||||
"requests_received": 0,
|
||||
"responses_sent": 0,
|
||||
"unsupported_requests": 0,
|
||||
}
|
||||
|
||||
def process_frame(self, frame: CANFrame, vehicle_state: "VehicleState") -> CANFrame | None:
|
||||
"""
|
||||
Обработать входящий CAN фрейм.
|
||||
Возвращает ответный фрейм или None, если фрейм не является OBD2 запросом.
|
||||
"""
|
||||
request = OBD2Request.from_can_frame(frame)
|
||||
if request is None:
|
||||
return None
|
||||
|
||||
self._stats["requests_received"] += 1
|
||||
logger.debug(f"Received: {request}")
|
||||
|
||||
response = self._handle_request(request, vehicle_state)
|
||||
if response:
|
||||
self._stats["responses_sent"] += 1
|
||||
return response.to_can_frame()
|
||||
|
||||
self._stats["unsupported_requests"] += 1
|
||||
return None
|
||||
|
||||
def _handle_request(self, request: OBD2Request, state: "VehicleState") -> OBD2Response | None:
|
||||
"""Обработать OBD2 запрос и вернуть ответ."""
|
||||
|
||||
if request.mode == OBD2Mode.CURRENT_DATA:
|
||||
return self._handle_mode01(request, state)
|
||||
elif request.mode == OBD2Mode.VEHICLE_INFO:
|
||||
return self._handle_mode09(request)
|
||||
elif request.mode == OBD2Mode.STORED_DTC:
|
||||
return self._handle_mode03(request)
|
||||
elif request.mode == OBD2Mode.CLEAR_DTC:
|
||||
return self._handle_mode04(request)
|
||||
|
||||
logger.debug(f"Unsupported mode: {request.mode:02X}")
|
||||
return None
|
||||
|
||||
def _handle_mode01(self, request: OBD2Request, state: "VehicleState") -> OBD2Response | None:
|
||||
"""Обработка Mode 01 - текущие данные."""
|
||||
pid_code = request.pid
|
||||
|
||||
# Специальные PIDs для маски поддерживаемых PIDs
|
||||
if pid_code in (0x00, 0x20, 0x40, 0x60, 0x80, 0xA0, 0xC0, 0xE0):
|
||||
mask = self.pid_registry.get_supported_pids_mask(pid_code)
|
||||
return OBD2Response(
|
||||
mode=request.mode,
|
||||
pid=pid_code,
|
||||
data=mask.to_bytes(4, "big")
|
||||
)
|
||||
|
||||
# Получаем значение из состояния автомобиля
|
||||
value = self._get_pid_value(pid_code, state)
|
||||
if value is None:
|
||||
logger.debug(f"PID {pid_code:02X} not supported or no value")
|
||||
return None
|
||||
|
||||
pid_def = self.pid_registry.get(pid_code)
|
||||
if pid_def is None:
|
||||
return None
|
||||
|
||||
# Кодируем значение
|
||||
try:
|
||||
encoded = pid_def.encoder(value)
|
||||
return OBD2Response(
|
||||
mode=request.mode,
|
||||
pid=pid_code,
|
||||
data=encoded
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encode PID {pid_code:02X}: {e}")
|
||||
return None
|
||||
|
||||
def _handle_mode03(self, request: OBD2Request) -> OBD2Response:
|
||||
"""Mode 03 - чтение DTC. Возвращаем 0 ошибок."""
|
||||
return OBD2Response(
|
||||
mode=request.mode,
|
||||
pid=0x00,
|
||||
data=bytes([0x00]) # 0 DTCs
|
||||
)
|
||||
|
||||
def _handle_mode04(self, request: OBD2Request) -> OBD2Response:
|
||||
"""Mode 04 - очистка DTC."""
|
||||
return OBD2Response(
|
||||
mode=request.mode,
|
||||
pid=0x00,
|
||||
data=bytes()
|
||||
)
|
||||
|
||||
def _handle_mode09(self, request: OBD2Request) -> OBD2Response | None:
|
||||
"""Mode 09 - информация об автомобиле."""
|
||||
pid = request.pid
|
||||
|
||||
if pid == 0x00:
|
||||
# Supported PIDs
|
||||
# Поддерживаем: 02 (VIN), 04 (Calibration ID), 0A (ECU name)
|
||||
mask = 0x54400000 # PIDs 02, 04, 0A
|
||||
return OBD2Response(mode=request.mode, pid=pid, data=mask.to_bytes(4, "big"))
|
||||
|
||||
elif pid == 0x02:
|
||||
# VIN - для простоты возвращаем тестовый
|
||||
# Реальный VIN требует multi-frame ответа, упрощаем
|
||||
vin = b"TESTVIN123456789" # 17 символов
|
||||
return OBD2Response(mode=request.mode, pid=pid, data=bytes([1]) + vin[:7])
|
||||
|
||||
elif pid == 0x04:
|
||||
# Calibration ID
|
||||
return OBD2Response(mode=request.mode, pid=pid, data=b"OBD2EMU1")
|
||||
|
||||
elif pid == 0x0A:
|
||||
# ECU name
|
||||
return OBD2Response(mode=request.mode, pid=pid, data=b"ECU-SIM")
|
||||
|
||||
return None
|
||||
|
||||
def _get_pid_value(self, pid_code: int, state: "VehicleState") -> float | None:
|
||||
"""Получить значение PID из состояния автомобиля."""
|
||||
mapping = {
|
||||
0x04: state.engine_load,
|
||||
0x05: state.coolant_temp,
|
||||
0x06: state.short_fuel_trim,
|
||||
0x07: state.long_fuel_trim,
|
||||
0x0B: state.intake_pressure,
|
||||
0x0C: state.rpm,
|
||||
0x0D: state.speed,
|
||||
0x0E: state.timing_advance,
|
||||
0x0F: state.intake_temp,
|
||||
0x10: state.maf_flow,
|
||||
0x11: state.throttle_pos,
|
||||
0x1C: 6, # OBD-II as defined by ISO 15765-4 CAN
|
||||
0x1F: state.run_time,
|
||||
0x21: 0, # Distance with MIL on
|
||||
0x23: state.fuel_rail_pressure,
|
||||
0x2F: state.fuel_level,
|
||||
0x31: state.distance_since_clear,
|
||||
0x33: state.barometric_pressure,
|
||||
0x42: state.control_module_voltage,
|
||||
0x45: state.throttle_pos, # Relative = absolute for simplicity
|
||||
0x46: state.ambient_temp,
|
||||
0x49: state.accelerator_pos,
|
||||
0x4A: state.accelerator_pos,
|
||||
0x4C: state.throttle_pos,
|
||||
0x5C: state.oil_temp,
|
||||
0x5E: state.fuel_rate,
|
||||
0x62: state.engine_torque,
|
||||
}
|
||||
return mapping.get(pid_code)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Получить статистику протокола."""
|
||||
return self._stats.copy()
|
||||
11
src/simulator/__init__.py
Normal file
11
src/simulator/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Симулятор автомобиля с динамическими данными."""
|
||||
from .vehicle import VehicleState, VehicleSimulator
|
||||
from .scenarios import Scenario, ScenarioManager, get_scenario_manager
|
||||
|
||||
__all__ = [
|
||||
"VehicleState",
|
||||
"VehicleSimulator",
|
||||
"Scenario",
|
||||
"ScenarioManager",
|
||||
"get_scenario_manager",
|
||||
]
|
||||
324
src/simulator/scenarios.py
Normal file
324
src/simulator/scenarios.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Сценарии движения для симулятора.
|
||||
Каждый сценарий определяет поведение автомобиля во времени.
|
||||
"""
|
||||
import math
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from logger import get_logger
|
||||
from .vehicle import VehicleSimulator
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class Scenario(ABC):
|
||||
"""Базовый класс сценария."""
|
||||
|
||||
name: str = "base"
|
||||
description: str = "Base scenario"
|
||||
|
||||
def __init__(self, simulator: VehicleSimulator):
|
||||
self.simulator = simulator
|
||||
self._start_time: float | None = None
|
||||
self._is_running = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Запустить сценарий."""
|
||||
self._start_time = time.time()
|
||||
self._is_running = True
|
||||
self.simulator.start_engine()
|
||||
self.on_start()
|
||||
logger.info(f"Scenario '{self.name}' started")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Остановить сценарий."""
|
||||
self._is_running = False
|
||||
self.on_stop()
|
||||
logger.info(f"Scenario '{self.name}' stopped")
|
||||
|
||||
@property
|
||||
def elapsed_time(self) -> float:
|
||||
"""Время с начала сценария (секунды)."""
|
||||
if self._start_time is None:
|
||||
return 0
|
||||
return time.time() - self._start_time
|
||||
|
||||
@abstractmethod
|
||||
def update(self) -> None:
|
||||
"""Обновить состояние сценария."""
|
||||
pass
|
||||
|
||||
def on_start(self) -> None:
|
||||
"""Вызывается при запуске сценария."""
|
||||
pass
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""Вызывается при остановке сценария."""
|
||||
pass
|
||||
|
||||
|
||||
class IdleScenario(Scenario):
|
||||
"""
|
||||
Сценарий: Холостой ход.
|
||||
Двигатель работает на холостых, машина стоит.
|
||||
"""
|
||||
|
||||
name = "idle"
|
||||
description = "Engine idling, vehicle stationary"
|
||||
|
||||
def update(self) -> None:
|
||||
self.simulator.set_target_speed(0)
|
||||
self.simulator.set_throttle(0)
|
||||
|
||||
|
||||
class WarmupScenario(Scenario):
|
||||
"""
|
||||
Сценарий: Прогрев двигателя.
|
||||
Двигатель на холостых, температура постепенно растёт.
|
||||
"""
|
||||
|
||||
name = "warmup"
|
||||
description = "Engine warming up from cold start"
|
||||
|
||||
def on_start(self) -> None:
|
||||
# Холодный старт
|
||||
state = self.simulator.state
|
||||
state.coolant_temp = self.simulator.config.ambient_temp
|
||||
state.oil_temp = self.simulator.config.ambient_temp
|
||||
state.intake_temp = self.simulator.config.ambient_temp
|
||||
|
||||
def update(self) -> None:
|
||||
self.simulator.set_target_speed(0)
|
||||
|
||||
# Небольшие колебания холостого хода при холодном двигателе
|
||||
temp = self.simulator.state.coolant_temp
|
||||
if temp < 50:
|
||||
# Повышенные обороты при холодном двигателе
|
||||
self.simulator.set_throttle(5 + (50 - temp) * 0.1)
|
||||
else:
|
||||
self.simulator.set_throttle(0)
|
||||
|
||||
|
||||
class CityScenario(Scenario):
|
||||
"""
|
||||
Сценарий: Городской цикл.
|
||||
Разгон до 50, торможение, снова разгон. Имитация светофоров.
|
||||
"""
|
||||
|
||||
name = "city"
|
||||
description = "City driving with stops and accelerations"
|
||||
|
||||
CYCLE_DURATION = 60 # Длина одного цикла (секунды)
|
||||
|
||||
def update(self) -> None:
|
||||
t = self.elapsed_time % self.CYCLE_DURATION
|
||||
|
||||
if t < 10:
|
||||
# Стоим на светофоре
|
||||
self.simulator.set_target_speed(0)
|
||||
elif t < 25:
|
||||
# Разгон до 50 км/ч
|
||||
progress = (t - 10) / 15
|
||||
self.simulator.set_target_speed(50 * progress)
|
||||
elif t < 40:
|
||||
# Едем 50 км/ч
|
||||
self.simulator.set_target_speed(50)
|
||||
elif t < 50:
|
||||
# Торможение
|
||||
progress = (t - 40) / 10
|
||||
self.simulator.set_target_speed(50 * (1 - progress))
|
||||
else:
|
||||
# Стоим
|
||||
self.simulator.set_target_speed(0)
|
||||
|
||||
|
||||
class HighwayScenario(Scenario):
|
||||
"""
|
||||
Сценарий: Трасса.
|
||||
Набор скорости до 110-120 км/ч, круиз с небольшими колебаниями.
|
||||
"""
|
||||
|
||||
name = "highway"
|
||||
description = "Highway cruising at 110-120 km/h"
|
||||
|
||||
def update(self) -> None:
|
||||
t = self.elapsed_time
|
||||
|
||||
if t < 30:
|
||||
# Разгон до крейсерской скорости
|
||||
progress = t / 30
|
||||
self.simulator.set_target_speed(120 * progress)
|
||||
else:
|
||||
# Круиз с небольшими колебаниями (имитация обгонов, горок)
|
||||
variation = math.sin(t * 0.1) * 10
|
||||
self.simulator.set_target_speed(115 + variation)
|
||||
|
||||
|
||||
class AccelerationScenario(Scenario):
|
||||
"""
|
||||
Сценарий: Тест ускорения.
|
||||
Резкий разгон от 0 до максимума.
|
||||
"""
|
||||
|
||||
name = "acceleration"
|
||||
description = "Full throttle acceleration test"
|
||||
|
||||
def update(self) -> None:
|
||||
t = self.elapsed_time
|
||||
|
||||
if t < 2:
|
||||
# Готовимся
|
||||
self.simulator.set_target_speed(0)
|
||||
self.simulator.set_throttle(0)
|
||||
elif t < 15:
|
||||
# Полный газ!
|
||||
self.simulator.set_throttle(100)
|
||||
self.simulator.set_target_speed(0) # Отключаем автопилот
|
||||
elif t < 20:
|
||||
# Отпускаем газ
|
||||
self.simulator.set_throttle(0)
|
||||
else:
|
||||
# Сброс - начинаем заново
|
||||
self._start_time = time.time()
|
||||
|
||||
|
||||
class ManualScenario(Scenario):
|
||||
"""
|
||||
Сценарий: Ручное управление.
|
||||
Пользователь сам устанавливает скорость и газ через API.
|
||||
"""
|
||||
|
||||
name = "manual"
|
||||
description = "Manual control via API"
|
||||
|
||||
def update(self) -> None:
|
||||
# Ничего не делаем - управление внешнее
|
||||
pass
|
||||
|
||||
|
||||
class DynamicCityScenario(Scenario):
|
||||
"""
|
||||
Сценарий: Продвинутый городской цикл.
|
||||
Более реалистичное поведение с разной интенсивностью.
|
||||
"""
|
||||
|
||||
name = "dynamic_city"
|
||||
description = "Dynamic city driving with variable patterns"
|
||||
|
||||
def __init__(self, simulator: VehicleSimulator):
|
||||
super().__init__(simulator)
|
||||
self._phase = 0
|
||||
self._phase_start = 0
|
||||
self._phases = [
|
||||
("idle", 5), # Стоим 5 сек
|
||||
("accelerate", 8), # Разгон 8 сек
|
||||
("cruise_30", 10), # Едем 30 км/ч
|
||||
("accelerate", 5), # Ещё разгон
|
||||
("cruise_50", 15), # Едем 50 км/ч
|
||||
("decelerate", 5), # Торможение
|
||||
("cruise_30", 8), # Едем 30 км/ч
|
||||
("stop", 8), # Полная остановка
|
||||
]
|
||||
|
||||
def update(self) -> None:
|
||||
# Определяем текущую фазу
|
||||
t = self.elapsed_time
|
||||
phase_elapsed = t - self._phase_start
|
||||
phase_name, phase_duration = self._phases[self._phase]
|
||||
|
||||
if phase_elapsed >= phase_duration:
|
||||
# Переход к следующей фазе
|
||||
self._phase = (self._phase + 1) % len(self._phases)
|
||||
self._phase_start = t
|
||||
phase_elapsed = 0
|
||||
phase_name, phase_duration = self._phases[self._phase]
|
||||
|
||||
# Выполняем фазу
|
||||
progress = phase_elapsed / phase_duration
|
||||
|
||||
if phase_name == "idle":
|
||||
self.simulator.set_target_speed(0)
|
||||
elif phase_name == "accelerate":
|
||||
current = self.simulator.state.speed
|
||||
self.simulator.set_target_speed(current + 20)
|
||||
elif phase_name == "cruise_30":
|
||||
self.simulator.set_target_speed(30)
|
||||
elif phase_name == "cruise_50":
|
||||
self.simulator.set_target_speed(50)
|
||||
elif phase_name == "decelerate":
|
||||
current = self.simulator.state.speed
|
||||
self.simulator.set_target_speed(max(0, current - 15))
|
||||
elif phase_name == "stop":
|
||||
self.simulator.set_target_speed(0)
|
||||
|
||||
|
||||
# Реестр сценариев
|
||||
SCENARIOS: dict[str, type[Scenario]] = {
|
||||
"idle": IdleScenario,
|
||||
"warmup": WarmupScenario,
|
||||
"city": CityScenario,
|
||||
"highway": HighwayScenario,
|
||||
"acceleration": AccelerationScenario,
|
||||
"manual": ManualScenario,
|
||||
"dynamic_city": DynamicCityScenario,
|
||||
}
|
||||
|
||||
|
||||
class ScenarioManager:
|
||||
"""Управление сценариями."""
|
||||
|
||||
def __init__(self, simulator: VehicleSimulator):
|
||||
self.simulator = simulator
|
||||
self._current_scenario: Scenario | None = None
|
||||
|
||||
def load_scenario(self, name: str) -> Scenario:
|
||||
"""Загрузить сценарий по имени."""
|
||||
if name not in SCENARIOS:
|
||||
raise ValueError(f"Unknown scenario: {name}. Available: {list(SCENARIOS.keys())}")
|
||||
|
||||
scenario_class = SCENARIOS[name]
|
||||
self._current_scenario = scenario_class(self.simulator)
|
||||
return self._current_scenario
|
||||
|
||||
def start(self, name: str | None = None) -> None:
|
||||
"""Запустить сценарий."""
|
||||
if name:
|
||||
self.load_scenario(name)
|
||||
|
||||
if self._current_scenario:
|
||||
self._current_scenario.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Остановить текущий сценарий."""
|
||||
if self._current_scenario:
|
||||
self._current_scenario.stop()
|
||||
|
||||
def update(self) -> None:
|
||||
"""Обновить текущий сценарий."""
|
||||
if self._current_scenario and self._current_scenario._is_running:
|
||||
self._current_scenario.update()
|
||||
|
||||
@property
|
||||
def current_scenario(self) -> Scenario | None:
|
||||
return self._current_scenario
|
||||
|
||||
@property
|
||||
def available_scenarios(self) -> list[str]:
|
||||
return list(SCENARIOS.keys())
|
||||
|
||||
|
||||
# Синглтон
|
||||
_scenario_manager: ScenarioManager | None = None
|
||||
|
||||
|
||||
def get_scenario_manager(simulator: VehicleSimulator | None = None) -> ScenarioManager:
|
||||
"""Получить менеджер сценариев."""
|
||||
global _scenario_manager
|
||||
if _scenario_manager is None:
|
||||
if simulator is None:
|
||||
raise ValueError("Simulator required for first initialization")
|
||||
_scenario_manager = ScenarioManager(simulator)
|
||||
return _scenario_manager
|
||||
432
src/simulator/vehicle.py
Normal file
432
src/simulator/vehicle.py
Normal file
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
Модель автомобиля с физически правдоподобным поведением.
|
||||
Симулирует двигатель, трансмиссию, температуры и расход топлива.
|
||||
"""
|
||||
import math
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from config import VehicleConfig
|
||||
from logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VehicleState:
|
||||
"""Текущее состояние автомобиля."""
|
||||
# Двигатель
|
||||
rpm: float = 0.0
|
||||
engine_load: float = 0.0
|
||||
throttle_pos: float = 0.0
|
||||
accelerator_pos: float = 0.0
|
||||
|
||||
# Движение
|
||||
speed: float = 0.0 # km/h
|
||||
|
||||
# Температуры
|
||||
coolant_temp: float = 20.0
|
||||
intake_temp: float = 20.0
|
||||
oil_temp: float = 20.0
|
||||
ambient_temp: float = 20.0
|
||||
|
||||
# Топливо
|
||||
fuel_level: float = 75.0 # %
|
||||
fuel_rate: float = 0.0 # L/h
|
||||
fuel_rail_pressure: float = 35000.0 # kPa
|
||||
|
||||
# Воздух
|
||||
maf_flow: float = 0.0 # g/s
|
||||
intake_pressure: float = 101.0 # kPa
|
||||
|
||||
# Другое
|
||||
timing_advance: float = 10.0 # degrees
|
||||
short_fuel_trim: float = 0.0 # %
|
||||
long_fuel_trim: float = 0.0 # %
|
||||
barometric_pressure: float = 101.0 # kPa
|
||||
control_module_voltage: float = 14.2 # V
|
||||
engine_torque: float = 0.0 # %
|
||||
|
||||
# Статистика
|
||||
run_time: float = 0.0 # seconds
|
||||
distance_since_clear: float = 0.0 # km
|
||||
total_distance: float = 0.0 # km
|
||||
|
||||
# Внутреннее состояние
|
||||
engine_running: bool = False
|
||||
gear: int = 0 # 0 = N/P, 1-6 = gears
|
||||
|
||||
def __post_init__(self):
|
||||
self._start_time = time.time()
|
||||
|
||||
|
||||
class EngineModel:
|
||||
"""Модель двигателя."""
|
||||
|
||||
# Передаточные числа (примерные для 6-ступенчатой АКПП)
|
||||
GEAR_RATIOS = {
|
||||
0: 0, # Neutral
|
||||
1: 3.5,
|
||||
2: 2.1,
|
||||
3: 1.4,
|
||||
4: 1.0,
|
||||
5: 0.75,
|
||||
6: 0.6,
|
||||
}
|
||||
FINAL_DRIVE = 3.5
|
||||
WHEEL_RADIUS = 0.32 # meters
|
||||
|
||||
def __init__(self, config: VehicleConfig):
|
||||
self.config = config
|
||||
self.idle_rpm = config.idle_rpm
|
||||
self.max_rpm = config.max_rpm
|
||||
self.redline = config.redline_rpm
|
||||
self.displacement = config.engine_displacement
|
||||
|
||||
def calculate_rpm_from_speed(self, speed_kmh: float, gear: int) -> float:
|
||||
"""Рассчитать RPM из скорости и передачи."""
|
||||
if gear == 0 or speed_kmh < 1:
|
||||
return self.idle_rpm
|
||||
|
||||
speed_ms = speed_kmh / 3.6
|
||||
wheel_rpm = (speed_ms / self.WHEEL_RADIUS) * 60 / (2 * math.pi)
|
||||
engine_rpm = wheel_rpm * self.GEAR_RATIOS[gear] * self.FINAL_DRIVE
|
||||
|
||||
return max(self.idle_rpm, min(engine_rpm, self.max_rpm))
|
||||
|
||||
def calculate_optimal_gear(self, speed_kmh: float, throttle: float) -> int:
|
||||
"""Определить оптимальную передачу."""
|
||||
if speed_kmh < 5:
|
||||
return 1 if throttle > 0 else 0
|
||||
|
||||
# Точки переключения (примерные)
|
||||
shift_points = [
|
||||
(0, 1),
|
||||
(15, 2),
|
||||
(30, 3),
|
||||
(50, 4),
|
||||
(70, 5),
|
||||
(90, 6),
|
||||
]
|
||||
|
||||
gear = 1
|
||||
for speed, g in shift_points:
|
||||
if speed_kmh >= speed:
|
||||
gear = g
|
||||
|
||||
# При высоком газе держим передачу дольше
|
||||
if throttle > 70 and gear > 1:
|
||||
# Проверяем, не уйдут ли обороты в красную зону
|
||||
rpm = self.calculate_rpm_from_speed(speed_kmh, gear - 1)
|
||||
if rpm < self.redline:
|
||||
gear -= 1
|
||||
|
||||
return gear
|
||||
|
||||
def calculate_engine_load(self, rpm: float, throttle: float, speed: float) -> float:
|
||||
"""Рассчитать нагрузку на двигатель."""
|
||||
# Базовая нагрузка от оборотов
|
||||
rpm_factor = rpm / self.max_rpm
|
||||
|
||||
# Нагрузка от газа
|
||||
throttle_factor = throttle / 100
|
||||
|
||||
# Нагрузка от скорости (сопротивление воздуха)
|
||||
aero_factor = (speed / 200) ** 2
|
||||
|
||||
load = (throttle_factor * 0.6 + rpm_factor * 0.2 + aero_factor * 0.2) * 100
|
||||
return min(100, max(0, load))
|
||||
|
||||
def calculate_maf(self, rpm: float, load: float) -> float:
|
||||
"""Рассчитать расход воздуха (MAF)."""
|
||||
# Упрощённая модель: MAF пропорционален RPM * нагрузке * объём двигателя
|
||||
volumetric_efficiency = 0.85
|
||||
air_density = 1.2 # kg/m³
|
||||
|
||||
# Объём воздуха за минуту (л/мин)
|
||||
air_volume = (rpm * self.displacement * volumetric_efficiency * (load / 100)) / 2
|
||||
|
||||
# Преобразуем в g/s
|
||||
maf = (air_volume / 60) * air_density
|
||||
return max(0, maf)
|
||||
|
||||
def calculate_fuel_rate(self, rpm: float, load: float) -> float:
|
||||
"""Рассчитать расход топлива (L/h)."""
|
||||
if rpm < 100:
|
||||
return 0
|
||||
|
||||
# Базовый расход на холостых
|
||||
idle_consumption = 0.8 # L/h
|
||||
|
||||
# Расход пропорционален MAF (стехиометрия ~14.7:1)
|
||||
maf = self.calculate_maf(rpm, load)
|
||||
fuel_mass_rate = maf / 14.7 # g/s
|
||||
fuel_volume_rate = fuel_mass_rate / 750 # L/s (плотность бензина ~750 g/L)
|
||||
fuel_rate_lh = fuel_volume_rate * 3600 # L/h
|
||||
|
||||
return max(idle_consumption, fuel_rate_lh)
|
||||
|
||||
|
||||
class TemperatureModel:
|
||||
"""Модель температур двигателя."""
|
||||
|
||||
def __init__(self, config: VehicleConfig):
|
||||
self.ambient = config.ambient_temp
|
||||
self.target_coolant = config.target_coolant_temp
|
||||
|
||||
def update_coolant_temp(
|
||||
self,
|
||||
current: float,
|
||||
rpm: float,
|
||||
speed: float,
|
||||
dt: float
|
||||
) -> float:
|
||||
"""Обновить температуру охлаждающей жидкости."""
|
||||
# Нагрев от работы двигателя
|
||||
heat_rate = 0.5 if rpm > 0 else 0 # °C/s при работающем двигателе
|
||||
|
||||
# Охлаждение от радиатора (зависит от скорости)
|
||||
cooling_rate = 0.1 + (speed / 100) * 0.3 # °C/s
|
||||
|
||||
# Целевая температура
|
||||
target = self.target_coolant if rpm > 0 else self.ambient
|
||||
|
||||
# Плавное изменение к цели
|
||||
diff = target - current
|
||||
change = diff * 0.02 * dt # Медленное изменение
|
||||
|
||||
# Добавляем нагрев/охлаждение
|
||||
if rpm > 0:
|
||||
change += heat_rate * dt * (1 - current / self.target_coolant)
|
||||
|
||||
return current + change
|
||||
|
||||
def update_oil_temp(
|
||||
self,
|
||||
current: float,
|
||||
coolant_temp: float,
|
||||
rpm: float,
|
||||
dt: float
|
||||
) -> float:
|
||||
"""Обновить температуру масла (следует за coolant с задержкой)."""
|
||||
# Масло нагревается медленнее и до более высокой температуры
|
||||
target = coolant_temp + 10 if rpm > 0 else self.ambient
|
||||
diff = target - current
|
||||
change = diff * 0.01 * dt # Очень медленное изменение
|
||||
return current + change
|
||||
|
||||
def update_intake_temp(
|
||||
self,
|
||||
current: float,
|
||||
coolant_temp: float,
|
||||
speed: float,
|
||||
dt: float
|
||||
) -> float:
|
||||
"""Обновить температуру впускного воздуха."""
|
||||
# Зависит от скорости (охлаждение потоком) и температуры двигателя
|
||||
target = self.ambient + (coolant_temp - self.ambient) * 0.1
|
||||
if speed > 30:
|
||||
target = self.ambient + 5 # Охлаждение потоком
|
||||
diff = target - current
|
||||
change = diff * 0.1 * dt
|
||||
return current + change
|
||||
|
||||
|
||||
class VehicleSimulator:
|
||||
"""
|
||||
Главный симулятор автомобиля.
|
||||
Объединяет все модели и обновляет состояние.
|
||||
"""
|
||||
|
||||
def __init__(self, config: VehicleConfig):
|
||||
self.config = config
|
||||
self.engine = EngineModel(config)
|
||||
self.temperature = TemperatureModel(config)
|
||||
|
||||
# Начальное состояние
|
||||
self.state = VehicleState(
|
||||
ambient_temp=config.ambient_temp,
|
||||
intake_temp=config.ambient_temp,
|
||||
coolant_temp=config.ambient_temp,
|
||||
oil_temp=config.ambient_temp,
|
||||
fuel_level=config.initial_fuel_level,
|
||||
)
|
||||
|
||||
self._last_update = time.time()
|
||||
self._target_speed = 0.0
|
||||
self._target_throttle = 0.0
|
||||
|
||||
def start_engine(self) -> None:
|
||||
"""Запустить двигатель."""
|
||||
if not self.state.engine_running:
|
||||
self.state.engine_running = True
|
||||
self.state.rpm = self.config.idle_rpm
|
||||
self.state.gear = 0
|
||||
logger.info("Engine started")
|
||||
|
||||
def stop_engine(self) -> None:
|
||||
"""Заглушить двигатель."""
|
||||
if self.state.engine_running:
|
||||
self.state.engine_running = False
|
||||
self.state.rpm = 0
|
||||
self.state.gear = 0
|
||||
self.state.speed = 0
|
||||
self.state.throttle_pos = 0
|
||||
self.state.engine_load = 0
|
||||
logger.info("Engine stopped")
|
||||
|
||||
def set_target_speed(self, speed: float) -> None:
|
||||
"""Установить целевую скорость (для сценариев)."""
|
||||
self._target_speed = max(0, min(speed, self.config.max_speed))
|
||||
|
||||
def set_throttle(self, throttle: float) -> None:
|
||||
"""Установить положение газа (0-100%)."""
|
||||
self._target_throttle = max(0, min(throttle, 100))
|
||||
|
||||
def update(self, dt: float | None = None) -> VehicleState:
|
||||
"""
|
||||
Обновить состояние автомобиля.
|
||||
|
||||
Args:
|
||||
dt: Время с последнего обновления (секунды).
|
||||
Если None, вычисляется автоматически.
|
||||
"""
|
||||
now = time.time()
|
||||
if dt is None:
|
||||
dt = now - self._last_update
|
||||
self._last_update = now
|
||||
|
||||
if not self.state.engine_running:
|
||||
self.state.run_time = 0
|
||||
return self.state
|
||||
|
||||
# Обновляем время работы
|
||||
self.state.run_time += dt
|
||||
|
||||
# Плавное изменение газа
|
||||
throttle_diff = self._target_throttle - self.state.throttle_pos
|
||||
self.state.throttle_pos += throttle_diff * min(1.0, dt * 5) # Сглаживание
|
||||
self.state.accelerator_pos = self.state.throttle_pos
|
||||
|
||||
# Обновляем скорость на основе целевой скорости или газа
|
||||
self._update_speed(dt)
|
||||
|
||||
# Определяем передачу
|
||||
self.state.gear = self.engine.calculate_optimal_gear(
|
||||
self.state.speed,
|
||||
self.state.throttle_pos
|
||||
)
|
||||
|
||||
# Рассчитываем обороты
|
||||
if self.state.speed < 1:
|
||||
self.state.rpm = self.config.idle_rpm + self.state.throttle_pos * 10
|
||||
else:
|
||||
self.state.rpm = self.engine.calculate_rpm_from_speed(
|
||||
self.state.speed,
|
||||
self.state.gear
|
||||
)
|
||||
|
||||
# Нагрузка двигателя
|
||||
self.state.engine_load = self.engine.calculate_engine_load(
|
||||
self.state.rpm,
|
||||
self.state.throttle_pos,
|
||||
self.state.speed
|
||||
)
|
||||
|
||||
# MAF
|
||||
self.state.maf_flow = self.engine.calculate_maf(
|
||||
self.state.rpm,
|
||||
self.state.engine_load
|
||||
)
|
||||
|
||||
# Расход топлива
|
||||
self.state.fuel_rate = self.engine.calculate_fuel_rate(
|
||||
self.state.rpm,
|
||||
self.state.engine_load
|
||||
)
|
||||
|
||||
# Уменьшаем уровень топлива
|
||||
fuel_consumed = self.state.fuel_rate * dt / 3600 # L
|
||||
fuel_percent = (fuel_consumed / self.config.fuel_tank_capacity) * 100
|
||||
self.state.fuel_level = max(0, self.state.fuel_level - fuel_percent)
|
||||
|
||||
# Давление во впуске (упрощённо)
|
||||
self.state.intake_pressure = 101 - (self.state.throttle_pos * 0.6)
|
||||
|
||||
# Опережение зажигания
|
||||
self.state.timing_advance = 10 + (self.state.rpm / 1000) * 3
|
||||
|
||||
# Крутящий момент (упрощённо)
|
||||
self.state.engine_torque = self.state.engine_load * 0.8
|
||||
|
||||
# Температуры
|
||||
self.state.coolant_temp = self.temperature.update_coolant_temp(
|
||||
self.state.coolant_temp,
|
||||
self.state.rpm,
|
||||
self.state.speed,
|
||||
dt
|
||||
)
|
||||
self.state.oil_temp = self.temperature.update_oil_temp(
|
||||
self.state.oil_temp,
|
||||
self.state.coolant_temp,
|
||||
self.state.rpm,
|
||||
dt
|
||||
)
|
||||
self.state.intake_temp = self.temperature.update_intake_temp(
|
||||
self.state.intake_temp,
|
||||
self.state.coolant_temp,
|
||||
self.state.speed,
|
||||
dt
|
||||
)
|
||||
|
||||
# Пробег
|
||||
distance_km = (self.state.speed * dt) / 3600
|
||||
self.state.total_distance += distance_km
|
||||
self.state.distance_since_clear += distance_km
|
||||
|
||||
return self.state
|
||||
|
||||
def _update_speed(self, dt: float) -> None:
|
||||
"""Обновить скорость автомобиля."""
|
||||
current = self.state.speed
|
||||
target = self._target_speed
|
||||
|
||||
if target > 0:
|
||||
# Режим целевой скорости (автопилот сценария)
|
||||
diff = target - current
|
||||
# Ускорение/замедление до 3 м/с² (примерно 10 км/ч за секунду)
|
||||
max_change = 10 * dt
|
||||
|
||||
if abs(diff) < max_change:
|
||||
self.state.speed = target
|
||||
else:
|
||||
self.state.speed += max_change if diff > 0 else -max_change
|
||||
|
||||
# Подстраиваем газ под скорость
|
||||
if diff > 5:
|
||||
self._target_throttle = min(100, 30 + diff)
|
||||
elif diff < -5:
|
||||
self._target_throttle = 0
|
||||
else:
|
||||
self._target_throttle = 15 + current / 10
|
||||
|
||||
else:
|
||||
# Режим ручного газа
|
||||
throttle = self.state.throttle_pos
|
||||
|
||||
if throttle > 5:
|
||||
# Ускорение
|
||||
acceleration = (throttle / 100) * 15 # Max ~15 km/h/s
|
||||
self.state.speed = min(
|
||||
self.config.max_speed,
|
||||
current + acceleration * dt
|
||||
)
|
||||
else:
|
||||
# Торможение (двигателем + сопротивление)
|
||||
deceleration = 5 + current * 0.02 # Зависит от скорости
|
||||
self.state.speed = max(0, current - deceleration * dt)
|
||||
|
||||
def get_state(self) -> VehicleState:
|
||||
"""Получить текущее состояние."""
|
||||
return self.state
|
||||
Reference in New Issue
Block a user