From cfccf5e75ccc01366915e19e77a8ffadff50871e Mon Sep 17 00:00:00 2001 From: Alexander Poletaev Date: Fri, 30 Jan 2026 16:53:05 +0300 Subject: [PATCH] Update readme and add UDS --- README.md | 149 ++++++++++ obd2_client/README.md | 472 ++++++++++++++++++++++++------- obd2_client/config.json.example | 9 + obd2_client/src/config.py | 28 ++ obd2_client/src/flipper/pages.py | 71 ++++- obd2_client/src/main.py | 76 +++++ obd2_client/src/uds/__init__.py | 25 ++ obd2_client/src/uds/pids.py | 404 ++++++++++++++++++++++++++ obd2_client/src/uds/poller.py | 314 ++++++++++++++++++++ obd2_client/src/uds/protocol.py | 293 +++++++++++++++++++ 10 files changed, 1736 insertions(+), 105 deletions(-) create mode 100644 obd2_client/src/uds/__init__.py create mode 100644 obd2_client/src/uds/pids.py create mode 100644 obd2_client/src/uds/poller.py create mode 100644 obd2_client/src/uds/protocol.py diff --git a/README.md b/README.md index e69de29..fe7f6b7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/obd2_client/README.md b/obd2_client/README.md index c59a159..a87a16e 100644 --- a/obd2_client/README.md +++ b/obd2_client/README.md @@ -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 diff --git a/obd2_client/config.json.example b/obd2_client/config.json.example index d13d85a..7bf1ae9 100644 --- a/obd2_client/config.json.example +++ b/obd2_client/config.json.example @@ -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"] } } diff --git a/obd2_client/src/config.py b/obd2_client/src/config.py index 40bb313..1111c1e 100644 --- a/obd2_client/src/config.py +++ b/obd2_client/src/config.py @@ -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.""" diff --git a/obd2_client/src/flipper/pages.py b/obd2_client/src/flipper/pages.py index 663b02a..d87b2d3 100644 --- a/obd2_client/src/flipper/pages.py +++ b/obd2_client/src/flipper/pages.py @@ -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 = [ diff --git a/obd2_client/src/main.py b/obd2_client/src/main.py index 062adc9..fdb9541 100644 --- a/obd2_client/src/main.py +++ b/obd2_client/src/main.py @@ -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...") diff --git a/obd2_client/src/uds/__init__.py b/obd2_client/src/uds/__init__.py new file mode 100644 index 0000000..b7c94b5 --- /dev/null +++ b/obd2_client/src/uds/__init__.py @@ -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", +] diff --git a/obd2_client/src/uds/pids.py b/obd2_client/src/uds/pids.py new file mode 100644 index 0000000..9a80d5d --- /dev/null +++ b/obd2_client/src/uds/pids.py @@ -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", +)) diff --git a/obd2_client/src/uds/poller.py b/obd2_client/src/uds/poller.py new file mode 100644 index 0000000..39a2d79 --- /dev/null +++ b/obd2_client/src/uds/poller.py @@ -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), + ) diff --git a/obd2_client/src/uds/protocol.py b/obd2_client/src/uds/protocol.py new file mode 100644 index 0000000..d932731 --- /dev/null +++ b/obd2_client/src/uds/protocol.py @@ -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