Update readme and add UDS

This commit is contained in:
2026-01-30 16:53:05 +03:00
parent 19d3885bb2
commit cfccf5e75c
10 changed files with 1736 additions and 105 deletions

149
README.md
View File

@@ -0,0 +1,149 @@
# Carpibord
Автомобильная диагностическая система для **Skoda Kodiaq 2021** на базе Raspberry Pi 5 и Flipper Zero.
## Архитектура
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ SKODA KODIAQ 2021 │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Comfort │ │ OBD-II │ │
│ │ CAN Bus │ │ CAN Bus │ │
│ │ (двери, │ │ (двигатель,│ │
│ │ свет...) │ │ скорость) │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
└──────────┼───────────────────┼──────────────────────────────────────────────┘
│ │
│ ┌──────────────┴──────────────┐
│ │ OBD-II Разъём │
│ │ Pin 6 (CANH) Pin 14 (CANL)│
│ └──────────────┬──────────────┘
│ │
┌──────────┴───────────────────┴──────────────────────────────────────────────┐
│ RASPBERRY PI 5 │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ WaveShare 2-CH CAN HAT │ │
│ │ (MCP2515, 500 kbps) │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ OBD2 Client (Python) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────────────────┐ │ │
│ │ │ CAN │ │ OBD2 │ │ Vehicle │ │ Handler Pipeline │ │ │
│ │ │Interface │→│ Protocol │→│ Poller │→│ ┌────────┐ ┌────────────┐ │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │ SQLite │→│ PostgreSQL │ │ │ │
│ │ │ └────────┘ └────────────┘ │ │ │
│ │ └────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────┴──────────────────────────────────────────┐ │
│ │ UART (/dev/serial0, 115200) │ │
│ └─────────────────────────────┬──────────────────────────────────────────┘ │
│ ┌───────────────────────┴───────────────────────┐ │
│ │ X120x UPS HAT (опционально) │ │
│ │ I2C мониторинг батареи │ │
│ └───────────────────────────────────────────────┘ │
└────────────────────────────────┬────────────────────────────────────────────┘
┌────────────────────────────────┴────────────────────────────────────────────┐
│ FLIPPER ZERO │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ can_monitor.fap - Приложение для отображения данных │ │
│ │ │ │
│ │ Страницы: Live Data → Statistics → System → App Status → UPS → Actions│ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Компоненты
| Директория | Описание |
|------------|----------|
| `obd2_client/` | Python приложение для RPi5 — OBD2 мониторинг, storage, PostgreSQL sync |
| `flip_monitor/` | C приложение для Flipper Zero — GUI отображение данных |
| `systemd/` | Systemd сервисы для автозапуска |
## Возможности
- **OBD2 мониторинг** — чтение данных двигателя через стандартный протокол
- **Offline-first хранение** — SQLite локально, синхронизация с PostgreSQL
- **Flipper Zero UI** — отображение данных на портативном устройстве
- **UPS мониторинг** — контроль батареи X120x HAT
- **Автозапуск** — systemd сервисы для работы в автомобиле
## Быстрый старт
### 1. Настройка RPi5
```bash
# /boot/firmware/config.txt
dtparam=spi=on
dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=25
enable_uart=1
dtoverlay=disable-bt
```
### 2. Установка
```bash
cd obd2_client
pip install -r requirements.txt
```
### 3. Настройка CAN
```bash
sudo ip link set can0 type can bitrate 500000
sudo ip link set can0 up
```
### 4. Запуск
```bash
# Базовый запуск
python -m src.main --interface can0
# С Flipper Zero
python -m src.main --interface can0 --flipper /dev/serial0
# С отладкой
python -m src.main --interface can0 --flipper /dev/serial0 --debug
```
### 5. Автозапуск (опционально)
```bash
cd systemd
sudo ./install.sh
```
## Подключение
### OBD-II → CAN HAT
| OBD-II | CAN HAT |
|--------|---------|
| Pin 6 (CAN-H) | CH0 CANH |
| Pin 14 (CAN-L) | CH0 CANL |
| Pin 4/5 (GND) | GND |
### RPi5 → Flipper Zero
| RPi5 | Flipper Zero |
|------|--------------|
| GPIO14 (TX) | Pin 14 (RX) |
| GPIO15 (RX) | Pin 13 (TX) |
| GND | Pin 18 (GND) |
## Документация
- [OBD2 Client README](obd2_client/README.md) — детальная документация Python приложения
- [Flipper App README](flip_monitor/README.md) — документация приложения Flipper Zero
- [CLAUDE.md](CLAUDE.md) — техническое описание для AI-ассистентов
## Лицензия
MIT

View File

@@ -1,111 +1,95 @@
# OBD2 Client для Skoda Kodiaq 2021 на RPi5
# OBD2 Client
Python приложение для чтения OBD2 данных через WaveShare 2-CH CAN HAT.
Python приложение для мониторинга OBD2 данных Skoda Kodiaq 2021 на Raspberry Pi 5.
## Возможности
- **OBD2 Protocol** — чтение стандартных PID (Mode 01)
- **Offline-first Storage** — SQLite с автосинхронизацией в PostgreSQL
- **Handler Pipeline** — модульная обработка данных
- **Flipper Zero UI** — отображение на портативном дисплее
- **UPS Monitoring** — мониторинг X120x UPS HAT
- **Systemd Services** — автозапуск при загрузке
## Установка
```bash
# Основные зависимости
pip install -r requirements.txt
# Для PostgreSQL (опционально)
pip install psycopg2-binary
# Для UPS мониторинга (опционально, только RPi)
pip install smbus2 gpiozero
```
## Конфигурация RPi5
### /boot/config.txt
```
### /boot/firmware/config.txt
```ini
# CAN HAT (MCP2515)
dtparam=spi=on
dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=23
dtoverlay=mcp2515-can0,oscillator=16000000,interrupt=25
# UART для Flipper Zero
enable_uart=1
dtoverlay=disable-bt
```
### Инициализация CAN интерфейса
### Инициализация CAN
```bash
sudo ip link set can0 up type can bitrate 500000
sudo ifconfig can0 txqueuelen 65536
sudo ip link set can0 type can bitrate 500000
sudo ip link set can0 up
```
## Использование
### Запуск с реальным авто
### Базовый запуск
```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 |
| `--flipper PORT` | Включить Flipper Zero сервер на указанном порту |
| `--debug` | Включить отладочный вывод |
## Интеграция с Flipper Zero
### Подключение
```
RPi5 Flipper Zero
GPIO14 (TX) --------> RX (pin 14)
GPIO15 (RX) <-------- TX (pin 13)
GND ---------- GND (pin 18)
```
### Запуск с Flipper
### С Flipper Zero
```bash
python -m src.main --interface can0 --flipper /dev/serial0
```
### Страницы на Flipper
### Только сканирование PID
| Страница | Тип | Описание |
|----------|-----|----------|
| Live Data | Info | RPM, Speed, Coolant, Throttle, Fuel |
| Statistics | Info | Queries, Success rate, Uptime |
| System Info | Info | IP, CPU temp, Memory, CAN interface |
| Actions | Menu | Reconnect, Clear cache, Reboot, Shutdown |
```bash
python -m src.main --interface can0 --scan-only
```
### Управление
### Тестовый режим (виртуальный CAN)
- **←/→** - переключение страниц
- **↑/↓** - выбор пункта меню / прокрутка
- **OK** - подтверждение действия
- **Back** - отмена / возврат
```bash
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0
## Поддерживаемые PID
python -m src.main --interface vcan0 --virtual
```
| 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 |
### Параметры CLI
| Параметр | Описание |
|----------|----------|
| `-i, --interface` | CAN интерфейс (can0, vcan0) |
| `-c, --config` | Путь к config.json |
| `-v, --virtual` | Виртуальный CAN режим |
| `--scan-only` | Только сканирование PID |
| `--flipper PORT` | Включить Flipper Zero сервер |
| `--no-monitor` | Отключить консольный вывод |
| `--debug` | Отладочный режим |
## Конфигурация
Файл `config.json`:
### config.json
```json
{
@@ -117,49 +101,335 @@ python -m src.main --interface can0 --flipper /dev/serial0
"obd2": {
"request_id": "0x7DF",
"response_id": "0x7E8",
"timeout": 0.1
"timeout": 0.2
},
"polling": {
"interval_fast": 0.1,
"interval_slow": 1.0,
"fast_pids": ["0x0C", "0x0D", "0x11"],
"slow_pids": ["0x05", "0x2F", "0x5C"]
"fast_pids": ["0x0C", "0x0D", "0x11", "0x04"],
"slow_pids": ["0x05", "0x5C", "0x0F", "0x2F"]
},
"storage": {
"enabled": true,
"db_path": null,
"wal_mode": true,
"retention_days": 30,
"batch_size": 50,
"flush_interval": 1.0
},
"postgresql": {
"enabled": false,
"host": "localhost",
"port": 5432,
"database": "carpibord",
"user": "carpibord",
"password": "",
"sync_interval": 30.0,
"batch_size": 500
},
"flipper": {
"enabled": true,
"port": "/dev/serial0",
"baudrate": 115200,
"show_ups": true
}
}
```
## Переменные окружения
### Переменные окружения
- `OBD2_CONFIG_PATH` - путь к config.json
- `OBD2_CAN_INTERFACE` - CAN интерфейс
- `OBD2_CAN_BITRATE` - битрейт
- `OBD2_CAN_VIRTUAL` - использовать виртуальный CAN (true/false)
- `OBD2_TIMEOUT` - таймаут ответа
```bash
# CAN
export OBD2_CAN_INTERFACE=can0
export OBD2_CAN_BITRATE=500000
## Структура проекта
# Storage
export OBD2_STORAGE_ENABLED=true
export OBD2_STORAGE_PATH=/data/obd2.db
# PostgreSQL
export OBD2_PG_ENABLED=true
export OBD2_PG_HOST=postgres.example.com
export OBD2_PG_DATABASE=carpibord
export OBD2_PG_USER=carpibord
export OBD2_PG_PASSWORD=secret
# Flipper
export OBD2_FLIPPER_ENABLED=true
export OBD2_FLIPPER_PORT=/dev/serial0
```
## Архитектура
### Handler Pipeline (Offline-First)
```
VehiclePoller ──► HandlerPipeline
├── StorageHandler ──► SQLite (obd2_data.db)
│ │ │
│ │ [unsynced data]
│ │ │
│ └─────── background sync ───────┐
│ │
└── PostgreSQLHandler ◄──────────────────┘
PostgreSQL (remote)
```
**Принцип работы:**
1. `VehiclePoller` опрашивает PID и вызывает callbacks
2. `StorageHandler` батчит данные и пишет в SQLite
3. `PostgreSQLHandler` в фоне синхронизирует unsynced записи в PostgreSQL
4. При потере связи данные накапливаются локально
5. При восстановлении — автоматическая досинхронизация
### Структура проекта
```
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 # Циклический опрос
└── flipper/
── protocol.py # UART протокол
├── pages.py # Генераторы страниц
└── server.py # UART сервер
│ ├── main.py # Точка входа, CLI
│ ├── config.py # Конфигурация
│ ├── logger.py # Логирование
│ ├── can/ # CAN абстракция
│ │ ├── frame.py # CANFrame dataclass
│ │ └── interface.py # CANInterface
│ ├── obd2/ # OBD2 протокол
│ │ ├── pids.py # Определения PID, декодеры
│ │ ── protocol.py # Request/Response
│ └── scanner.py # Автодетект PID
│ │
├── vehicle/ # Состояние автомобиля
│ ├── state.py # VehicleState (thread-safe)
── poller.py # VehiclePoller (fast/slow)
├── handlers/ # Data Pipeline
│ │ ├── base.py # BaseHandler, OBD2Reading
│ │ ├── pipeline.py # HandlerPipeline
│ │ ├── storage_handler.py # SQLite handler
│ │ └── postgresql_handler.py # PostgreSQL sync
│ │
│ ├── storage/ # Persistence
│ │ └── storage.py # SQLite + sessions
│ │
│ └── flipper/ # Flipper Zero интеграция
│ ├── protocol.py # UART протокол
│ ├── server.py # FlipperServer
│ ├── pages.py # PageManager, страницы
│ └── providers/ # Data providers
│ ├── base.py # BaseProvider
│ └── ups_provider.py # X120x UPS
├── config.json
├── config.json.example
├── requirements.txt
└── README.md
```
## Поддерживаемые PID
### OBD2 Standard (Mode 0x01)
| PID | Параметр | Единицы | Интервал |
|-----|----------|---------|----------|
| 0x04 | Engine Load | % | Fast |
| 0x05 | Coolant Temp | °C | Slow |
| 0x0C | Engine RPM | RPM | Fast |
| 0x0D | Vehicle Speed | km/h | Fast |
| 0x0F | Intake Air Temp | °C | Slow |
| 0x10 | MAF Rate | g/s | Slow |
| 0x11 | Throttle Position | % | Fast |
| 0x2F | Fuel Level | % | Slow |
| 0x5C | Oil Temperature | °C | Slow |
### UDS Extended (Mode 0x22) — MQB Platform
UDS (ISO 14229) позволяет читать расширенные данные напрямую из ECU.
**Это read-only протокол, не влияющий на работу автомобиля.**
#### Engine ECU (0x7E0 → 0x7E8)
| PID | Параметр | Единицы | Min | Max |
|-----|----------|---------|-----|-----|
| 0x202A | Boost Pressure Actual | kPa | 0 | 400 |
| 0x2029 | Boost Pressure Target | kPa | 0 | 400 |
| 0xF423 | Fuel Rail Pressure | kPa | 0 | 25000 |
| 0x10C0 | Lambda Actual | λ | 0.5 | 2.0 |
| 0x1456 | Lambda Target | λ | 0.5 | 2.0 |
| 0x437C | Torque Actual | Nm | 0 | 500 |
| 0x39A2 | Wastegate Position | % | 0 | 100 |
| 0x39A3 | Wastegate Target | % | 0 | 100 |
| 0x2004 | Ignition Timing | ° | -10 | 50 |
| 0x200A | Timing Correction Cyl 1 | ° | -15 | 5 |
| 0x200B | Timing Correction Cyl 2 | ° | -15 | 5 |
| 0x200C | Timing Correction Cyl 3 | ° | -15 | 5 |
| 0x200D | Timing Correction Cyl 4 | ° | -15 | 5 |
#### Instrument Cluster (0x714 → 0x77E)
| PID | Параметр | Единицы | Min | Max |
|-----|----------|---------|-----|-----|
| 0x22D1 | RPM (from cluster) | RPM | 0 | 8000 |
| 0x224D | Ambient Light Sensor | - | 0 | 255 |
#### DSG Transmission (0x7E1 → 0x7E9)
| PID | Параметр | Единицы | Min | Max |
|-----|----------|---------|-----|-----|
| 0x3816 | Current Gear | gear | -1 | 7 |
| 0x3815 | Gear Selector (PRNDM) | mode | 0 | 5 |
| 0x1940 | Transmission Temp | °C | -40 | 150 |
## UDS конфигурация
```json
"uds": {
"enabled": true,
"interval_fast": 0.2,
"interval_slow": 1.0,
"fast_pids": ["0x202A", "0x437C", "0x10C0", "0x2004"],
"slow_pids": ["0x39A2", "0x200A", "0x3816", "0x3815"]
}
```
### Flipper Zero — страница UDS Data
```
┌──────────────────────┐
│ UDS Data │
├──────────────────────┤
│ Boost: 185 kPa │
│ Torque: 280 Nm │
│ Lambda: 1.000 │
│ Timing: 12.5 deg │
│ Wastegate: 45% │
│ Gear: 4 │
└──────────────────────┘
```
## Flipper Zero интеграция
### Подключение
```
RPi5 GPIO Flipper Zero
───────── ────────────
GPIO14 (TX) ────── Pin 14 (RX)
GPIO15 (RX) ────── Pin 13 (TX)
GND ────── Pin 18 (GND)
```
### Страницы
| # | Страница | Тип | Описание |
|---|----------|-----|----------|
| 0 | Live Data | Info | RPM, Speed, Temps, Throttle, Fuel |
| 1 | Statistics | Info | Queries, Success rate, Uptime |
| 2 | System Info | Info | IP, CPU, Memory, CAN interface |
| 3 | App Status | Info | Pipeline, Storage, PostgreSQL sync |
| 4 | UPS Status | Info | Battery %, Voltage, Power status |
| 5 | Actions | Menu | Reconnect, Clear cache, Reboot, Shutdown |
### Управление
| Кнопка | Действие |
|--------|----------|
| ← / → | Переключение страниц |
| ↑ / ↓ | Прокрутка / выбор пункта меню |
| OK | Подтверждение |
| Back | Отмена |
## Systemd сервисы
### Установка
```bash
cd ../systemd
sudo ./install.sh
```
### Управление
```bash
# Запуск
sudo systemctl start carpibord
# Статус
sudo systemctl status carpibord
# Логи
journalctl -u carpibord -f
# Остановка
sudo systemctl stop carpibord
```
### Сервисы
| Сервис | Описание |
|--------|----------|
| `can0-link.service` | Поднимает CAN интерфейс на 500 kbps |
| `carpibord.service` | Запускает OBD2 Client после CAN и сети |
## PostgreSQL Setup
### Создание базы
```sql
CREATE DATABASE carpibord;
CREATE USER carpibord WITH PASSWORD 'your-password';
GRANT ALL PRIVILEGES ON DATABASE carpibord TO carpibord;
```
### Таблицы (создаются автоматически)
```sql
-- Readings
CREATE TABLE obd2_readings (
id SERIAL PRIMARY KEY,
device_id TEXT DEFAULT 'rpi5',
session_id TEXT,
pid INTEGER NOT NULL,
name TEXT NOT NULL,
value REAL NOT NULL,
unit TEXT NOT NULL,
timestamp TIMESTAMPTZ NOT NULL
);
-- Sessions
CREATE TABLE obd2_sessions (
id TEXT PRIMARY KEY,
device_id TEXT DEFAULT 'rpi5',
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
duration_seconds REAL,
reading_count INTEGER,
avg_speed REAL,
max_speed REAL,
avg_rpm REAL,
max_rpm REAL
);
```
## UPS мониторинг (X120x)
Поддерживаются UPS HAT на базе MAX17048 fuel gauge:
- X1201, X1202 и совместимые
- Подключение по I2C (адрес 0x36)
- GPIO 6 для детекции потери питания
### Отображаемые данные
- Battery % с визуализацией `[==== ]`
- Напряжение батареи (V)
- Статус: Charging / Full / Battery Mode
- Input voltage (V)
- Предупреждения при низком заряде
## Лицензия
MIT

View File

@@ -56,5 +56,14 @@
"port": "/dev/serial0",
"baudrate": 115200,
"show_ups": true
},
"uds": {
"_comment": "UDS extended diagnostics (MQB platform)",
"enabled": true,
"interval_fast": 0.2,
"interval_slow": 1.0,
"fast_pids": ["0x202A", "0x437C", "0x10C0", "0x2004"],
"slow_pids": ["0x39A2", "0x200A", "0x3816", "0x3815"]
}
}

View File

@@ -82,6 +82,19 @@ class FlipperConfig:
show_ups: bool = True
@dataclass
class UDSConfig:
"""UDS (extended diagnostics) configuration."""
enabled: bool = False
interval_fast: float = 0.2
interval_slow: float = 1.0
# Fast PIDs: boost, torque, lambda, timing
fast_pids: List[int] = field(default_factory=lambda: [0x202A, 0x437C, 0x10C0, 0x2004])
# Slow PIDs: wastegate, timing corrections, gear
slow_pids: List[int] = field(default_factory=lambda: [0x39A2, 0x200A, 0x3816, 0x3815])
@dataclass
class Config:
"""Main configuration container."""
@@ -93,6 +106,7 @@ class Config:
storage: StorageConfig = field(default_factory=StorageConfig)
postgresql: PostgreSQLConfig = field(default_factory=PostgreSQLConfig)
flipper: FlipperConfig = field(default_factory=FlipperConfig)
uds: UDSConfig = field(default_factory=UDSConfig)
@classmethod
def load(cls, config_path: Optional[str] = None) -> "Config":
@@ -204,6 +218,16 @@ class Config:
self.flipper.baudrate = flipper_data.get("baudrate", self.flipper.baudrate)
self.flipper.show_ups = flipper_data.get("show_ups", self.flipper.show_ups)
if "uds" in data:
uds_data = data["uds"]
self.uds.enabled = uds_data.get("enabled", self.uds.enabled)
self.uds.interval_fast = uds_data.get("interval_fast", self.uds.interval_fast)
self.uds.interval_slow = uds_data.get("interval_slow", self.uds.interval_slow)
if "fast_pids" in uds_data:
self.uds.fast_pids = [self._parse_hex(p) for p in uds_data["fast_pids"]]
if "slow_pids" in uds_data:
self.uds.slow_pids = [self._parse_hex(p) for p in uds_data["slow_pids"]]
def _load_from_env(self) -> None:
"""Load configuration from environment variables."""
# CAN config
@@ -242,6 +266,10 @@ class Config:
if "OBD2_FLIPPER_PORT" in os.environ:
self.flipper.port = os.environ["OBD2_FLIPPER_PORT"]
# UDS config
if "OBD2_UDS_ENABLED" in os.environ:
self.uds.enabled = os.environ["OBD2_UDS_ENABLED"].lower() == "true"
@staticmethod
def _parse_hex(value) -> int:
"""Parse hex string or int to int."""

View File

@@ -78,14 +78,21 @@ class PageManager:
generator=self._generate_app_status_page,
))
# Page 4: UPS Status
# Page 4: UDS Extended Data
self._pages.append(PageDefinition(
page_type=PageType.INFO,
title="UDS Data",
generator=self._generate_uds_page,
))
# Page 5: UPS Status
self._pages.append(PageDefinition(
page_type=PageType.INFO,
title="UPS Status",
generator=self._generate_ups_page,
))
# Page 5: Actions Menu (last navigable page)
# Page 6: Actions Menu (last navigable page)
self._pages.append(PageDefinition(
page_type=PageType.MENU,
title="Actions",
@@ -343,14 +350,14 @@ class PageManager:
if self._pending_action:
action = self._pending_action
self._pending_action = None
self._current_index = 5 # Back to actions menu
self._current_index = 6 # Back to actions menu
return self.execute_action(action)
return False, "No pending action"
def cancel_action(self) -> None:
"""Cancel pending action."""
self._pending_action = None
self._current_index = 5 # Back to actions menu
self._current_index = 6 # Back to actions menu
def _get_action_success_message(self, action_id: ActionID) -> str:
"""Get success message for action."""
@@ -516,6 +523,62 @@ class PageManager:
return Page(PageType.INFO, "System", lines)
def _generate_uds_page(self, mgr: "PageManager") -> Page:
"""Generate UDS extended diagnostics page."""
uds_values = mgr.get_data("uds_values", {})
uds_stats = mgr.get_data("uds_stats", {})
if not uds_values and not uds_stats:
lines = ["UDS not enabled", "", "Enable in config.json:", '"uds": {"enabled": true}']
return Page(PageType.INFO, "UDS Data", lines)
lines = []
# Key UDS values
# Boost pressure (0x202A)
if 0x202A in uds_values:
v = uds_values[0x202A]
lines.append(f"Boost: {v.value:.0f} kPa")
# Torque (0x437C)
if 0x437C in uds_values:
v = uds_values[0x437C]
lines.append(f"Torque: {v.value:.0f} Nm")
# Lambda (0x10C0)
if 0x10C0 in uds_values:
v = uds_values[0x10C0]
lines.append(f"Lambda: {v.value:.3f}")
# Ignition timing (0x2004)
if 0x2004 in uds_values:
v = uds_values[0x2004]
lines.append(f"Timing: {v.value:.1f} deg")
# Wastegate (0x39A2)
if 0x39A2 in uds_values:
v = uds_values[0x39A2]
lines.append(f"Wastegate: {v.value:.0f}%")
# Gear (0x3816)
if 0x3816 in uds_values:
v = uds_values[0x3816]
gear = int(v.value)
gear_str = "R" if gear == -1 else ("N" if gear == 0 else str(gear))
lines.append(f"Gear: {gear_str}")
# Stats
if uds_stats:
queries = uds_stats.get("queries", 0)
successes = uds_stats.get("successes", 0)
rate = (successes / queries * 100) if queries > 0 else 0
lines.append(f"UDS: {rate:.0f}% success")
if not lines:
lines = ["Waiting for UDS data..."]
return Page(PageType.INFO, "UDS Data", lines)
def _generate_actions_page(self, mgr: "PageManager") -> Page:
"""Generate actions menu page."""
actions = [

View File

@@ -19,6 +19,7 @@ from .flipper.server import FlipperServer
from .flipper.pages import ActionID
from .handlers import HandlerPipeline, StorageHandler, PostgreSQLHandler, OBD2Reading
from .flipper.providers.ups_provider import UPSProvider
from .uds import UDSProtocol, UDSPoller, MQB_PIDS
class OBD2Client:
@@ -65,6 +66,12 @@ class OBD2Client:
if self.pipeline.get_handler_names():
self.poller.add_reading_callback(self._on_reading)
# UDS Protocol and Poller (extended diagnostics)
self.uds_protocol = None
self.uds_poller = None
if config.uds.enabled:
self._setup_uds()
# Flipper Zero server
self.flipper_server = None
if flipper_port or config.flipper.enabled:
@@ -103,6 +110,53 @@ class OBD2Client:
self.pipeline.register(pg_handler)
self._logger.info("PostgreSQL handler registered")
def _setup_uds(self) -> None:
"""Set up UDS extended diagnostics."""
self.uds_protocol = UDSProtocol(
can_interface=self.can_interface,
timeout=self.config.obd2.timeout,
pid_registry=MQB_PIDS,
)
self.uds_poller = UDSPoller(
protocol=self.uds_protocol,
fast_interval=self.config.uds.interval_fast,
slow_interval=self.config.uds.interval_slow,
fast_pids=self.config.uds.fast_pids,
slow_pids=self.config.uds.slow_pids,
)
# Connect UDS poller to pipeline
if self.pipeline.get_handler_names():
self.uds_poller.add_reading_callback(self._on_uds_reading)
self._logger.info("UDS extended diagnostics enabled")
def _on_uds_reading(
self,
pid: int,
name: str,
value: float,
unit: str,
timestamp: datetime,
ecu_name: str,
) -> None:
"""Handle new UDS reading - send to pipeline."""
if not self.pipeline.is_initialized():
return
# Prefix name with ECU for clarity
full_name = f"[{ecu_name}] {name}"
reading = OBD2Reading(
pid=pid,
name=full_name,
value=value,
unit=unit,
timestamp=timestamp,
)
self.pipeline.handle(reading)
def _on_reading(
self,
pid: int,
@@ -136,6 +190,8 @@ class OBD2Client:
pm.set_data_provider("pipeline_stats", lambda: self.pipeline.get_stats())
pm.set_data_provider("config", lambda: self.config)
pm.set_data_provider("session_id", self._get_session_id)
pm.set_data_provider("uds_values", self._get_uds_values)
pm.set_data_provider("uds_stats", self._get_uds_stats)
# Add UPS status page if enabled
if self.config.flipper.show_ups:
@@ -160,6 +216,18 @@ class OBD2Client:
return storage.get_current_session_id() or "No session"
return "N/A"
def _get_uds_values(self) -> dict:
"""Get latest UDS values for Flipper display."""
if not self.uds_poller:
return {}
return self.uds_poller.get_all_values()
def _get_uds_stats(self) -> dict:
"""Get UDS poller stats."""
if not self.uds_poller:
return {}
return self.uds_poller.get_stats()
def _action_force_sync(self) -> bool:
"""Force sync to PostgreSQL."""
pg_handler = self.pipeline.get_handler("postgresql")
@@ -282,6 +350,11 @@ class OBD2Client:
self._logger.info("Starting poller...")
self.poller.start()
# Start UDS poller if enabled
if self.uds_poller:
self._logger.info("Starting UDS poller...")
self.uds_poller.start()
if monitor:
self._monitor_loop()
else:
@@ -380,6 +453,9 @@ class OBD2Client:
if self.poller.is_running:
self.poller.stop()
if self.uds_poller and self.uds_poller.is_running:
self.uds_poller.stop()
# Shutdown handler pipeline (flushes pending data)
if self.pipeline.is_initialized():
self._logger.info("Shutting down handler pipeline...")

View File

@@ -0,0 +1,25 @@
"""
UDS (Unified Diagnostic Services) protocol implementation.
ISO 14229 - Read-only diagnostic access to ECU data.
SAFETY: This module only implements Service 0x22 (Read Data By Identifier).
It does NOT implement write services (0x2E) or control services (0x31).
Reading diagnostic data does not affect vehicle operation.
"""
from .protocol import UDSProtocol, UDSResponse
from .pids import UDSPID, UDSPIDRegistry, MQB_PIDS, ECUType, ECU_ADDRESSES
from .poller import UDSPoller, UDSPollerState
__all__ = [
"UDSProtocol",
"UDSResponse",
"UDSPID",
"UDSPIDRegistry",
"MQB_PIDS",
"ECUType",
"ECU_ADDRESSES",
"UDSPoller",
"UDSPollerState",
]

404
obd2_client/src/uds/pids.py Normal file
View File

@@ -0,0 +1,404 @@
"""
UDS PID definitions for MQB platform vehicles.
Based on:
- https://github.com/bri3d/MQBSimosLogVariables
- https://github.com/mrfixpl/MQB-sniffer
"""
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional
from enum import Enum
class ECUType(Enum):
"""ECU module types."""
ENGINE = "engine" # ECU / Simos (0x7E0)
CLUSTER = "cluster" # Instrument Cluster (0x714)
TRANSMISSION = "transmission" # DSG Gearbox (0x7E1)
@dataclass
class ECUAddress:
"""ECU CAN addresses."""
request_id: int
response_id: int
name: str
# MQB ECU addresses
ECU_ADDRESSES: Dict[ECUType, ECUAddress] = {
ECUType.ENGINE: ECUAddress(0x7E0, 0x7E8, "Engine ECU"),
ECUType.CLUSTER: ECUAddress(0x714, 0x77E, "Instrument Cluster"),
ECUType.TRANSMISSION: ECUAddress(0x7E1, 0x7E9, "DSG Transmission"),
}
@dataclass
class UDSPID:
"""
UDS Parameter ID definition.
Attributes:
pid: 2-byte PID code (e.g., 0x202A)
name: Human-readable name
short_name: Short identifier for logging
ecu: Target ECU type
decoder: Function to decode raw bytes to value
unit: Unit of measurement
min_value: Minimum expected value
max_value: Maximum expected value
description: Detailed description
"""
pid: int
name: str
short_name: str
ecu: ECUType
decoder: Callable[[bytes], float]
unit: str
min_value: float = 0
max_value: float = 100
description: str = ""
def decode(self, data: bytes) -> float:
"""Decode raw bytes to value."""
return self.decoder(data)
@property
def request_id(self) -> int:
"""Get CAN request ID for this PID's ECU."""
return ECU_ADDRESSES[self.ecu].request_id
@property
def response_id(self) -> int:
"""Get CAN response ID for this PID's ECU."""
return ECU_ADDRESSES[self.ecu].response_id
def __str__(self) -> str:
return f"UDS[0x{self.pid:04X}] {self.name} ({self.unit})"
# Decoder functions
def _decode_uint16_div10(data: bytes) -> float:
"""Decode unsigned 16-bit, divide by 10."""
if len(data) < 2:
return 0.0
return (data[0] * 256 + data[1]) * 0.1
def _decode_uint16_mul10(data: bytes) -> float:
"""Decode unsigned 16-bit, multiply by 10."""
if len(data) < 2:
return 0.0
return (data[0] * 256 + data[1]) * 10
def _decode_uint16_div100(data: bytes) -> float:
"""Decode unsigned 16-bit, divide by 100."""
if len(data) < 2:
return 0.0
return (data[0] * 256 + data[1]) * 0.01
def _decode_uint16_div4(data: bytes) -> float:
"""Decode unsigned 16-bit, divide by 4 (RPM from cluster)."""
if len(data) < 2:
return 0.0
return (data[0] * 256 + data[1]) / 4
def _decode_lambda(data: bytes) -> float:
"""Decode lambda value."""
if len(data) < 2:
return 0.0
return (data[0] * 256 + data[1]) * 0.000977
def _decode_signed16_div100(data: bytes) -> float:
"""Decode signed 16-bit, divide by 100 (timing)."""
if len(data) < 2:
return 0.0
value = data[0] * 256 + data[1]
if value > 32767:
value -= 65536
return value * 0.01
def _decode_uint8(data: bytes) -> float:
"""Decode single unsigned byte."""
if len(data) < 1:
return 0.0
return float(data[0])
def _decode_gear(data: bytes) -> float:
"""Decode DSG gear position."""
if len(data) < 1:
return 0.0
gear_map = {
0x00: 0, # Neutral
0x0C: -1, # Reverse
0x02: 1, # 1st
0x04: 2, # 2nd
0x06: 3, # 3rd
0x08: 4, # 4th
0x0A: 5, # 5th
0x0E: 6, # 6th
0x10: 7, # 7th
}
return float(gear_map.get(data[0], 0))
def _decode_gear_mode(data: bytes) -> float:
"""Decode DSG gear mode (P/R/N/D/S/M)."""
if len(data) < 1:
return 0.0
# 0=P, 1=R, 2=N, 3=D, 4=S, 5=M
return float(data[0])
def _decode_temperature(data: bytes) -> float:
"""Decode temperature (offset -40)."""
if len(data) < 1:
return 0.0
return float(data[0]) - 40
def _decode_uint16(data: bytes) -> float:
"""Decode unsigned 16-bit raw."""
if len(data) < 2:
return 0.0
return float(data[0] * 256 + data[1])
class UDSPIDRegistry:
"""Registry of UDS PIDs."""
def __init__(self):
self._pids: Dict[int, UDSPID] = {}
self._by_ecu: Dict[ECUType, List[UDSPID]] = {
ECUType.ENGINE: [],
ECUType.CLUSTER: [],
ECUType.TRANSMISSION: [],
}
def register(self, pid: UDSPID) -> None:
"""Register a PID."""
self._pids[pid.pid] = pid
self._by_ecu[pid.ecu].append(pid)
def get(self, pid_code: int) -> Optional[UDSPID]:
"""Get PID by code."""
return self._pids.get(pid_code)
def get_all(self) -> List[UDSPID]:
"""Get all registered PIDs."""
return list(self._pids.values())
def get_by_ecu(self, ecu: ECUType) -> List[UDSPID]:
"""Get PIDs for specific ECU."""
return self._by_ecu.get(ecu, [])
def __contains__(self, pid_code: int) -> bool:
return pid_code in self._pids
def __getitem__(self, pid_code: int) -> UDSPID:
return self._pids[pid_code]
# Create MQB PID registry
MQB_PIDS = UDSPIDRegistry()
# ============================================================================
# ENGINE ECU (Simos) PIDs - 0x7E0
# ============================================================================
MQB_PIDS.register(UDSPID(
pid=0x202A,
name="Boost Pressure Actual",
short_name="boost",
ecu=ECUType.ENGINE,
decoder=_decode_uint16_div10,
unit="kPa",
min_value=0,
max_value=400,
description="Actual charge/boost pressure",
))
MQB_PIDS.register(UDSPID(
pid=0x2029,
name="Boost Pressure Target",
short_name="boost_tgt",
ecu=ECUType.ENGINE,
decoder=_decode_uint16_div10,
unit="kPa",
min_value=0,
max_value=400,
description="Target charge/boost pressure",
))
MQB_PIDS.register(UDSPID(
pid=0xF423,
name="Fuel Rail Pressure",
short_name="frp",
ecu=ECUType.ENGINE,
decoder=_decode_uint16_mul10,
unit="kPa",
min_value=0,
max_value=25000,
description="High pressure fuel rail",
))
MQB_PIDS.register(UDSPID(
pid=0x10C0,
name="Lambda Actual",
short_name="lambda",
ecu=ECUType.ENGINE,
decoder=_decode_lambda,
unit="λ",
min_value=0.5,
max_value=2.0,
description="Actual air-fuel ratio (1.0 = stoich)",
))
MQB_PIDS.register(UDSPID(
pid=0x1456,
name="Lambda Target",
short_name="lambda_tgt",
ecu=ECUType.ENGINE,
decoder=_decode_lambda,
unit="λ",
min_value=0.5,
max_value=2.0,
description="Target air-fuel ratio",
))
MQB_PIDS.register(UDSPID(
pid=0x437C,
name="Torque Actual",
short_name="torque",
ecu=ECUType.ENGINE,
decoder=_decode_uint16_div10,
unit="Nm",
min_value=0,
max_value=500,
description="Actual engine torque output",
))
MQB_PIDS.register(UDSPID(
pid=0x39A2,
name="Wastegate Position",
short_name="wg_pos",
ecu=ECUType.ENGINE,
decoder=_decode_uint16_div100,
unit="%",
min_value=0,
max_value=100,
description="Turbo wastegate position",
))
MQB_PIDS.register(UDSPID(
pid=0x39A3,
name="Wastegate Target",
short_name="wg_tgt",
ecu=ECUType.ENGINE,
decoder=_decode_uint16_div100,
unit="%",
min_value=0,
max_value=100,
description="Target wastegate position",
))
# Timing corrections per cylinder
for cyl, pid_offset in enumerate([0x200A, 0x200B, 0x200C, 0x200D], 1):
MQB_PIDS.register(UDSPID(
pid=pid_offset,
name=f"Timing Correction Cyl {cyl}",
short_name=f"tc_c{cyl}",
ecu=ECUType.ENGINE,
decoder=_decode_signed16_div100,
unit="°",
min_value=-15,
max_value=5,
description=f"Knock-related timing correction cylinder {cyl}",
))
MQB_PIDS.register(UDSPID(
pid=0x2004,
name="Ignition Timing",
short_name="ign_timing",
ecu=ECUType.ENGINE,
decoder=_decode_signed16_div100,
unit="°",
min_value=-10,
max_value=50,
description="Ignition timing advance",
))
# ============================================================================
# INSTRUMENT CLUSTER PIDs - 0x714
# ============================================================================
MQB_PIDS.register(UDSPID(
pid=0x22D1,
name="RPM (Cluster)",
short_name="rpm_cl",
ecu=ECUType.CLUSTER,
decoder=_decode_uint16_div4,
unit="RPM",
min_value=0,
max_value=8000,
description="Engine RPM from instrument cluster",
))
MQB_PIDS.register(UDSPID(
pid=0x224D,
name="Ambient Light",
short_name="amb_light",
ecu=ECUType.CLUSTER,
decoder=_decode_uint8,
unit="",
min_value=0,
max_value=255,
description="Ambient light sensor (0=dark, 255=bright)",
))
# ============================================================================
# DSG TRANSMISSION PIDs - 0x7E1
# ============================================================================
MQB_PIDS.register(UDSPID(
pid=0x3816,
name="Current Gear",
short_name="gear",
ecu=ECUType.TRANSMISSION,
decoder=_decode_gear,
unit="",
min_value=-1,
max_value=7,
description="Current engaged gear (-1=R, 0=N, 1-7=gears)",
))
MQB_PIDS.register(UDSPID(
pid=0x3815,
name="Gear Selector",
short_name="prndm",
ecu=ECUType.TRANSMISSION,
decoder=_decode_gear_mode,
unit="",
min_value=0,
max_value=5,
description="Gear selector position (0=P, 1=R, 2=N, 3=D, 4=S, 5=M)",
))
MQB_PIDS.register(UDSPID(
pid=0x1940,
name="Transmission Temp",
short_name="trans_temp",
ecu=ECUType.TRANSMISSION,
decoder=_decode_temperature,
unit="°C",
min_value=-40,
max_value=150,
description="Transmission oil temperature",
))

View File

@@ -0,0 +1,314 @@
"""
UDS PID polling service.
Polls extended diagnostic data from MQB platform ECUs.
"""
import threading
import time
from datetime import datetime
from typing import List, Optional, Set, Dict, Callable, Any
from enum import Enum
from .protocol import UDSProtocol, UDSResponse
from .pids import MQB_PIDS, ECUType, UDSPID
from ..logger import get_logger
# Type alias for reading callback
UDSReadingCallback = Callable[[int, str, float, str, datetime, str], None]
class UDSPollerState(Enum):
"""Poller state enum."""
STOPPED = "stopped"
RUNNING = "running"
PAUSED = "paused"
class UDSPoller:
"""
Background poller for UDS PIDs.
Polls extended diagnostic data from Engine ECU, Cluster, and DSG.
Supports priority-based polling similar to OBD2 poller.
"""
def __init__(
self,
protocol: UDSProtocol,
fast_interval: float = 0.2,
slow_interval: float = 1.0,
fast_pids: Optional[List[int]] = None,
slow_pids: Optional[List[int]] = None,
):
"""
Initialize UDS poller.
Args:
protocol: UDSProtocol instance
fast_interval: Polling interval for fast PIDs (seconds)
slow_interval: Polling interval for slow PIDs (seconds)
fast_pids: PIDs to poll frequently (boost, torque, timing)
slow_pids: PIDs to poll less frequently (temps, gear)
"""
self.protocol = protocol
self.fast_interval = fast_interval
self.slow_interval = slow_interval
# Default fast PIDs: boost, torque, lambda, timing
self.fast_pids: Set[int] = set(fast_pids or [
0x202A, # Boost Actual
0x437C, # Torque
0x10C0, # Lambda
0x2004, # Ignition Timing
])
# Default slow PIDs: wastegate, timing corrections, gear
self.slow_pids: Set[int] = set(slow_pids or [
0x39A2, # Wastegate Position
0x200A, # Timing Correction Cyl 1
0x3816, # Current Gear
0x3815, # Gear Selector (PRNDM)
])
self._state = UDSPollerState.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("uds.poller")
self._stats = {
"queries": 0,
"successes": 0,
"failures": 0,
"by_ecu": {
ECUType.ENGINE.value: {"queries": 0, "successes": 0},
ECUType.CLUSTER.value: {"queries": 0, "successes": 0},
ECUType.TRANSMISSION.value: {"queries": 0, "successes": 0},
}
}
self._latest_values: Dict[int, UDSResponse] = {}
self._values_lock = threading.Lock()
self._reading_callbacks: List[UDSReadingCallback] = []
@property
def poller_state(self) -> UDSPollerState:
"""Get current poller state."""
return self._state
@property
def is_running(self) -> bool:
"""Check if poller is running."""
return self._state == UDSPollerState.RUNNING
def add_reading_callback(self, callback: UDSReadingCallback) -> None:
"""
Add callback for each successful reading.
Args:
callback: Function(pid, name, value, unit, timestamp, ecu_name)
"""
self._reading_callbacks.append(callback)
def remove_reading_callback(self, callback: UDSReadingCallback) -> None:
"""Remove a reading callback."""
if callback in self._reading_callbacks:
self._reading_callbacks.remove(callback)
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 fast PIDs (or None to keep current)
slow_pids: New 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("UDS poller already running")
return
self._stop_event.clear()
self._pause_event.set()
self._state = UDSPollerState.RUNNING
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
self._thread.start()
self._logger.info("UDS 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("UDS poller thread did not stop cleanly")
self._thread = None
self._state = UDSPollerState.STOPPED
self._logger.info("UDS poller stopped")
def pause(self) -> None:
"""Pause polling."""
self._pause_event.clear()
self._state = UDSPollerState.PAUSED
self._logger.info("UDS poller paused")
def resume(self) -> None:
"""Resume polling after pause."""
self._pause_event.set()
self._state = UDSPollerState.RUNNING
self._logger.info("UDS poller resumed")
def get_stats(self) -> dict:
"""Get polling statistics."""
return dict(self._stats)
def get_value(self, pid_code: int) -> Optional[UDSResponse]:
"""
Get latest value for a PID.
Args:
pid_code: PID to get
Returns:
Latest UDSResponse or None
"""
with self._values_lock:
return self._latest_values.get(pid_code)
def get_all_values(self) -> Dict[int, UDSResponse]:
"""Get all latest values."""
with self._values_lock:
return dict(self._latest_values)
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()
# Poll fast PIDs
for pid_code in self.fast_pids:
if self._stop_event.is_set():
break
self._poll_pid(pid_code)
# Poll slow PIDs at slower interval
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 for fast interval
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 UDS PID.
Args:
pid_code: PID to poll
Returns:
True if successful
"""
self._stats["queries"] += 1
pid = self.protocol.pids.get(pid_code)
if pid is None:
self._stats["failures"] += 1
return False
ecu_name = pid.ecu.value
self._stats["by_ecu"][ecu_name]["queries"] += 1
response = self.protocol.query_pid(pid_code)
if response is None:
self._stats["failures"] += 1
return False
self._stats["successes"] += 1
self._stats["by_ecu"][ecu_name]["successes"] += 1
timestamp = datetime.now()
# Store latest value
with self._values_lock:
self._latest_values[pid_code] = response
# Notify callbacks
for callback in self._reading_callbacks:
try:
callback(
pid_code,
response.pid.name,
response.value,
response.unit,
timestamp,
ecu_name,
)
except Exception as e:
self._logger.debug(f"UDS reading callback error: {e}")
return True
def create_uds_poller_from_config(
protocol: UDSProtocol,
config: Any,
) -> UDSPoller:
"""
Create UDS poller from config object.
Args:
protocol: UDSProtocol instance
config: Config object with uds section
Returns:
Configured UDSPoller
"""
uds_config = getattr(config, "uds", None)
if uds_config is None:
return UDSPoller(protocol)
return UDSPoller(
protocol=protocol,
fast_interval=getattr(uds_config, "interval_fast", 0.2),
slow_interval=getattr(uds_config, "interval_slow", 1.0),
fast_pids=getattr(uds_config, "fast_pids", None),
slow_pids=getattr(uds_config, "slow_pids", None),
)

View File

@@ -0,0 +1,293 @@
"""
UDS Protocol implementation (ISO 14229).
Read-only diagnostic access using Service 0x22 (Read Data By Identifier).
This is a SAFE, read-only protocol that does not modify vehicle behavior.
"""
from dataclasses import dataclass
from typing import Optional, List, Dict, Any
from enum import Enum
from ..can.frame import CANFrame
from ..can.interface import CANInterface
from ..logger import get_logger
from .pids import UDSPID, UDSPIDRegistry, MQB_PIDS, ECUType, ECU_ADDRESSES
class UDSService(Enum):
"""UDS Service IDs (only safe read services)."""
READ_DATA_BY_ID = 0x22 # Read Data By Identifier
READ_DATA_BY_ID_RESPONSE = 0x62 # Positive response
class UDSError(Enum):
"""UDS Negative Response Codes."""
SERVICE_NOT_SUPPORTED = 0x11
SUB_FUNCTION_NOT_SUPPORTED = 0x12
INCORRECT_MESSAGE_LENGTH = 0x13
CONDITIONS_NOT_CORRECT = 0x22
REQUEST_OUT_OF_RANGE = 0x31
SECURITY_ACCESS_DENIED = 0x33
REQUEST_SEQUENCE_ERROR = 0x24
@dataclass
class UDSResponse:
"""
UDS response data.
Attributes:
pid: PID that was queried
raw_data: Raw response data bytes
value: Decoded value
unit: Unit of measurement
ecu: Source ECU type
"""
pid: UDSPID
raw_data: bytes
value: float
unit: str
ecu: ECUType
def __str__(self) -> str:
return f"{self.pid.name}: {self.value:.2f} {self.unit}"
class UDSProtocol:
"""
UDS protocol handler for MQB platform vehicles.
Implements ISO 14229 Service 0x22 (Read Data By Identifier).
This is READ-ONLY and does not affect vehicle operation.
Safety notes:
- Only uses Service 0x22 (read)
- Does NOT use 0x2E (write), 0x31 (routine), 0x27 (security)
- Does NOT change diagnostic session (stays in default)
"""
def __init__(
self,
can_interface: CANInterface,
timeout: float = 0.1,
pid_registry: Optional[UDSPIDRegistry] = None,
):
"""
Initialize UDS protocol handler.
Args:
can_interface: CAN interface to use
timeout: Response timeout in seconds
pid_registry: PID registry (default: MQB_PIDS)
"""
self.can = can_interface
self.timeout = timeout
self.pids = pid_registry or MQB_PIDS
self._logger = get_logger("uds.protocol")
def build_request(self, pid: int, ecu: ECUType) -> CANFrame:
"""
Build a UDS Read Data By Identifier request.
Args:
pid: 2-byte PID code
ecu: Target ECU type
Returns:
CANFrame ready to send
"""
ecu_addr = ECU_ADDRESSES[ecu]
pid_high = (pid >> 8) & 0xFF
pid_low = pid & 0xFF
# UDS frame format:
# [length, service_id, pid_high, pid_low, padding...]
data = bytes([
0x03, # Length: 3 bytes (service + 2-byte PID)
UDSService.READ_DATA_BY_ID.value, # 0x22
pid_high,
pid_low,
0x55, 0x55, 0x55, 0x55 # Padding
])
return CANFrame(arbitration_id=ecu_addr.request_id, data=data)
def parse_response(
self,
frame: CANFrame,
expected_pid: int,
ecu: ECUType,
) -> Optional[bytes]:
"""
Parse UDS response frame.
Args:
frame: Received CAN frame
expected_pid: PID we're expecting response for
ecu: Expected source ECU
Returns:
Data bytes if valid positive response, None otherwise
"""
ecu_addr = ECU_ADDRESSES[ecu]
# Verify response is from correct ECU
if frame.arbitration_id != ecu_addr.response_id:
return None
if len(frame.data) < 4:
return None
length = frame.data[0]
service = frame.data[1]
# Check for negative response (0x7F)
if service == 0x7F:
if len(frame.data) >= 4:
error_service = frame.data[2]
error_code = frame.data[3]
self._logger.debug(
f"UDS negative response: service=0x{error_service:02X}, "
f"error=0x{error_code:02X}"
)
return None
# Check for positive response (0x62)
if service != UDSService.READ_DATA_BY_ID_RESPONSE.value:
self._logger.debug(f"Unexpected service: 0x{service:02X}")
return None
# Verify PID in response
response_pid = (frame.data[2] << 8) | frame.data[3]
if response_pid != expected_pid:
self._logger.debug(
f"PID mismatch: expected 0x{expected_pid:04X}, "
f"got 0x{response_pid:04X}"
)
return None
# Extract data bytes (after service + PID)
data_length = length - 3 # Subtract service(1) + pid(2)
if data_length <= 0:
return None
return frame.data[4:4 + data_length]
def query_pid(self, pid_code: int) -> Optional[UDSResponse]:
"""
Query a single UDS PID.
Args:
pid_code: PID code to query
Returns:
UDSResponse if successful, None otherwise
"""
pid = self.pids.get(pid_code)
if pid is None:
self._logger.warning(f"Unknown UDS PID: 0x{pid_code:04X}")
return None
request = self.build_request(pid_code, pid.ecu)
if not self.can.send(request):
self._logger.error(f"Failed to send UDS request for 0x{pid_code:04X}")
return None
# Wait for response from the specific ECU
response_id = pid.response_id
frame = self.can.receive_filtered([response_id], timeout=self.timeout)
if frame is None:
self._logger.debug(f"No response for UDS PID 0x{pid_code:04X}")
return None
raw_data = self.parse_response(frame, pid_code, pid.ecu)
if raw_data is None:
return None
try:
value = pid.decode(raw_data)
return UDSResponse(
pid=pid,
raw_data=raw_data,
value=value,
unit=pid.unit,
ecu=pid.ecu,
)
except Exception as e:
self._logger.error(f"Failed to decode UDS PID 0x{pid_code:04X}: {e}")
return None
def query_ecu(self, ecu: ECUType) -> Dict[int, UDSResponse]:
"""
Query all known PIDs for a specific ECU.
Args:
ecu: ECU type to query
Returns:
Dict mapping PID code to response
"""
results = {}
pids = self.pids.get_by_ecu(ecu)
for pid in pids:
response = self.query_pid(pid.pid)
if response is not None:
results[pid.pid] = response
return results
def scan_pid(self, pid_code: int, ecu: ECUType) -> bool:
"""
Check if a PID is supported by sending a test query.
Args:
pid_code: PID to test
ecu: Target ECU
Returns:
True if ECU responds positively
"""
request = self.build_request(pid_code, ecu)
if not self.can.send(request):
return False
response_id = ECU_ADDRESSES[ecu].response_id
frame = self.can.receive_filtered([response_id], timeout=self.timeout)
if frame is None:
return False
# Check for positive response
if len(frame.data) >= 2:
service = frame.data[1]
return service == UDSService.READ_DATA_BY_ID_RESPONSE.value
return False
def get_supported_pids(self, ecu: ECUType) -> List[int]:
"""
Find which registered PIDs are supported by ECU.
Args:
ecu: ECU to scan
Returns:
List of supported PID codes
"""
supported = []
pids = self.pids.get_by_ecu(ecu)
self._logger.info(f"Scanning {len(pids)} PIDs for {ecu.value}...")
for pid in pids:
if self.scan_pid(pid.pid, ecu):
supported.append(pid.pid)
self._logger.debug(f" Found: 0x{pid.pid:04X} ({pid.name})")
self._logger.info(f"Found {len(supported)} supported PIDs for {ecu.value}")
return supported