Rewrite all project for obd2 support

This commit is contained in:
2026-01-29 16:55:33 +03:00
parent fd12954077
commit 75b8810430
30 changed files with 5327 additions and 3103 deletions

View File

@@ -0,0 +1,723 @@
# Техническое задание
## Модернизация CAN Sniffer → OBD2 Onboard Computer
**Версия:** 1.0
**Дата:** 2025-01-29
**Проект:** carpibord/can_sniffer
---
## 1. Введение
### 1.1 Цель документа
Данное техническое задание описывает требования к модернизации существующего проекта `can_sniffer` из пассивного слушателя CAN-шины в полноценный OBD2 клиент для бортового компьютера.
### 1.2 Текущее состояние
**Архитектура can_sniffer (AS-IS):**
```
┌─────────────────────────────────────────────────────────────────┐
│ CAN Sniffer (текущий) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ CANBusHandler│─────►│MessageProcessor│──►│ Handlers │ │
│ │ (читает) │ │ (очередь) │ │ (обработка) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ CAN Bus │ │ SQLite │ │
│ │ (только RX)│ │ PostgreSQL │ │
│ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
**Ключевые характеристики:**
- Пассивный режим (только чтение)
- Сохранение сырых CAN-фреймов
- Нет понимания OBD2 протокола
- Нет отправки запросов
- Нет парсинга данных
### 1.3 Целевое состояние
**Архитектура OBD2 Client (TO-BE):**
```
┌─────────────────────────────────────────────────────────────────────────┐
│ OBD2 Onboard Computer (целевой) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ OBD2 Poller │────►│ CAN TX/RX │◄────│ Response │ │
│ │ (запросы) │ │ Interface │ │ Matcher │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PID Registry │ │ CAN Bus │ │ PID Decoder │ │
│ │ (конфиг) │ │ (TX + RX) │ │ (парсинг) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │
│ ┌─────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Data Pipeline │ │
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │
│ │ │ Storage │ │ Realtime │ │ WebSocket │ │ │
│ │ │ Handler │ │ State │ │ Publisher │ │ │
│ │ └────────────┘ └────────────┘ └────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 2. Анализ текущей кодовой базы
### 2.1 Сильные стороны (сохранить)
| Компонент | Описание | Рекомендация |
|-----------|----------|--------------|
| `CANFrame` | Иммутабельная модель CAN-сообщения | ✅ Сохранить без изменений |
| `BaseHandler` | Абстрактный интерфейс обработчиков | ✅ Расширить для OBD2 |
| `MessageProcessor` | Очередь с backpressure | ✅ Адаптировать для request/response |
| `Storage` | SQLite с WAL и batch-операциями | ✅ Расширить схему для OBD2 |
| `Config (Pydantic)` | Валидация конфигурации | ✅ Добавить OBD2 секцию |
| `Logger` | Структурированное логирование | ✅ Без изменений |
### 2.2 Требующие модификации
| Компонент | Текущее | Требуемое |
|-----------|---------|-----------|
| `CANBusHandler` | Только RX | TX + RX с корреляцией |
| `CANSniffer` | Пассивный режим | Активный polling |
| Handlers | Сохранение сырых данных | Парсинг + сохранение значений |
### 2.3 Новые компоненты
| Компонент | Назначение |
|-----------|------------|
| `OBD2Poller` | Периодическая отправка OBD2 запросов |
| `OBD2Protocol` | Кодирование/декодирование OBD2 |
| `PIDRegistry` | Реестр поддерживаемых PIDs |
| `ResponseMatcher` | Сопоставление запросов и ответов |
| `VehicleState` | Актуальное состояние автомобиля |
| `RealtimeHandler` | Обновление in-memory состояния |
---
## 3. Функциональные требования
### 3.1 OBD2 Protocol Layer
#### FR-3.1.1 Поддержка стандарта ISO 15765-4 (CAN)
**Требования:**
- Отправка запросов на CAN ID `0x7DF` (broadcast) или `0x7E0` (физический адрес ECU)
- Приём ответов с CAN ID `0x7E8` - `0x7EF`
- Поддержка Single Frame (SF) для коротких сообщений
- Поддержка Multi Frame (FF/CF) для VIN и длинных ответов
**Формат Single Frame запроса:**
```
Byte 0: Length (0x02 для Mode 01)
Byte 1: Mode (0x01 = Current Data)
Byte 2: PID
Bytes 3-7: Padding (0x00)
```
**Формат Single Frame ответа:**
```
Byte 0: Length
Byte 1: Mode + 0x40 (0x41 для Mode 01)
Byte 2: PID
Bytes 3+: Data
```
#### FR-3.1.2 Поддерживаемые режимы (Modes)
| Mode | Название | Приоритет | Описание |
|------|----------|-----------|----------|
| 01 | Current Data | P0 (обязательно) | Текущие значения датчиков |
| 03 | Stored DTCs | P1 | Сохранённые коды ошибок |
| 09 | Vehicle Info | P1 | VIN, Calibration ID |
| 04 | Clear DTCs | P2 | Очистка ошибок |
#### FR-3.1.3 Минимальный набор PIDs (Mode 01)
**Обязательные (P0):**
| PID | Название | Формула | Единицы |
|-----|----------|---------|---------|
| 00 | Supported PIDs | Bitmap | - |
| 0C | Engine RPM | (A×256+B)/4 | rpm |
| 0D | Vehicle Speed | A | km/h |
| 05 | Coolant Temp | A-40 | °C |
| 04 | Engine Load | A×100/255 | % |
| 11 | Throttle Position | A×100/255 | % |
| 2F | Fuel Level | A×100/255 | % |
**Желательные (P1):**
| PID | Название | Формула | Единицы |
|-----|----------|---------|---------|
| 0F | Intake Air Temp | A-40 | °C |
| 10 | MAF Flow | (A×256+B)/100 | g/s |
| 46 | Ambient Temp | A-40 | °C |
| 5C | Oil Temp | A-40 | °C |
| 5E | Fuel Rate | (A×256+B)/20 | L/h |
| 1F | Run Time | A×256+B | s |
### 3.2 Polling Engine
#### FR-3.2.1 Конфигурируемый polling
```python
# Пример конфигурации
{
"obd2": {
"enabled": true,
"polling_groups": [
{
"name": "fast",
"interval_ms": 100,
"pids": ["0C", "0D", "11"] # RPM, Speed, Throttle
},
{
"name": "medium",
"interval_ms": 1000,
"pids": ["05", "04", "2F"] # Coolant, Load, Fuel
},
{
"name": "slow",
"interval_ms": 5000,
"pids": ["46", "5C", "0F"] # Temps
}
],
"request_timeout_ms": 100,
"retry_count": 2
}
}
```
#### FR-3.2.2 Request/Response корреляция
**Требования:**
- Каждый запрос имеет уникальный correlation ID
- Таймаут ожидания ответа (configurable)
- Retry логика при отсутствии ответа
- Метрики: latency, success rate, timeouts
### 3.3 Data Storage
#### FR-3.3.1 Расширение SQLite схемы
**Новая таблица `obd2_readings`:**
```sql
CREATE TABLE obd2_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp_ns INTEGER NOT NULL,
pid TEXT NOT NULL,
pid_name TEXT,
raw_value BLOB,
decoded_value REAL,
unit TEXT,
is_valid BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_obd2_timestamp ON obd2_readings(timestamp_ns);
CREATE INDEX idx_obd2_pid ON obd2_readings(pid);
```
**Новая таблица `obd2_sessions`:**
```sql
CREATE TABLE obd2_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TIMESTAMP NOT NULL,
ended_at TIMESTAMP,
vin TEXT,
total_distance_km REAL,
avg_speed_kmh REAL,
max_speed_kmh REAL,
fuel_consumed_l REAL
);
```
#### FR-3.3.2 Режимы хранения
| Режим | Описание | Retention |
|-------|----------|-----------|
| `full` | Все readings | 7 дней |
| `aggregated` | Средние за интервал | 30 дней |
| `events_only` | Только аномалии | 90 дней |
### 3.4 Realtime State
#### FR-3.4.1 In-memory состояние автомобиля
```python
@dataclass
class VehicleState:
"""Актуальное состояние автомобиля в памяти."""
# Обновляется при каждом успешном ответе
timestamp: float
# Двигатель
rpm: float = 0
engine_load: float = 0
coolant_temp: float = 0
oil_temp: float = 0
# Движение
speed: float = 0
throttle_pos: float = 0
# Топливо
fuel_level: float = 0
fuel_rate: float = 0
# Мета
vin: str = ""
dtc_count: int = 0
# Состояние связи
ecu_connected: bool = False
last_response_time: float = 0
```
#### FR-3.4.2 Подписка на изменения
```python
class VehicleStateManager:
def subscribe(self, callback: Callable[[str, Any], None]) -> None:
"""Подписаться на изменения параметров."""
pass
def get_current_state(self) -> VehicleState:
"""Получить текущее состояние."""
pass
```
---
## 4. Нефункциональные требования
### 4.1 Производительность
| Метрика | Требование |
|---------|------------|
| Polling latency | < 50ms (95 percentile) |
| State update delay | < 100ms |
| Memory footprint | < 100MB RSS |
| CPU usage (idle) | < 5% |
| CPU usage (polling) | < 15% |
### 4.2 Надёжность
| Требование | Описание |
|------------|----------|
| ECU disconnect | Автоматическое reconnect с backoff |
| Data integrity | Транзакции SQLite, fsync |
| Graceful shutdown | Сохранение всех pending данных |
| Error isolation | Ошибка одного PID не блокирует другие |
### 4.3 Совместимость
| Аспект | Требование |
|--------|------------|
| Python | 3.11+ |
| OS | Linux (Raspberry Pi OS) |
| CAN Hardware | SocketCAN совместимые (MCP2515, MCP251xFD) |
| OBD2 Protocol | ISO 15765-4 CAN (500 kbps) |
---
## 5. Архитектура решения
### 5.1 Структура каталогов (целевая)
```
can_sniffer/
├── src/
│ ├── main.py
│ ├── config.py # + OBD2Config
│ ├── can_frame.py # без изменений
│ ├── logger.py # без изменений
│ │
│ ├── socket_can/
│ │ ├── src.py # CANBusHandler + TX
│ │ ├── message_processor.py # адаптация
│ │ └── can_transceiver.py # НОВЫЙ: TX/RX с корреляцией
│ │
│ ├── obd2/ # НОВЫЙ модуль
│ │ ├── __init__.py
│ │ ├── protocol.py # OBD2 encoding/decoding
│ │ ├── pids.py # PID registry
│ │ ├── poller.py # Polling engine
│ │ ├── response_matcher.py # Request/Response correlation
│ │ └── multi_frame.py # Multi-frame support
│ │
│ ├── vehicle/ # НОВЫЙ модуль
│ │ ├── __init__.py
│ │ ├── state.py # VehicleState dataclass
│ │ └── state_manager.py # State management + subscriptions
│ │
│ ├── handlers/
│ │ ├── base.py # без изменений
│ │ ├── storage_handler.py # расширить для obd2_readings
│ │ ├── postgresql_handler.py # расширить схему
│ │ ├── flipper_handler.py # адаптировать вывод
│ │ └── realtime_handler.py # НОВЫЙ: обновление VehicleState
│ │
│ └── storage/
│ └── storage.py # + новые таблицы
├── deploy/
│ └── ...
└── tests/
├── test_obd2_protocol.py
├── test_poller.py
└── test_pid_decoder.py
```
### 5.2 Диаграмма потоков данных
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Main Thread │
│ ┌─────────────┐ │
│ │ OBD2Poller │──── Polling Timer ────┐ │
│ └─────────────┘ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ CANTransceiver │ │
│ │ │ │
│ │ TX Queue ────► │──── CAN Bus │
│ │ RX Handler ◄── │◄─── CAN Bus │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ ResponseMatcher │ │
│ │ │ │
│ │ Pending: { │ │
│ │ req_id: PID │ │
│ │ } │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ PID Decoder │ │
│ │ │ │
│ │ 0x0C → 3000 rpm │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────────────────────┼────────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Realtime │ │ Storage │ │ WebSocket │ │
│ │ Handler │ │ Handler │ │ Publisher │ │
│ │ │ │ │ │ (future) │ │
│ │ VehicleState│ │ SQLite │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────┘
```
### 5.3 Последовательность обработки
```
┌──────────┐ ┌───────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Poller │ │Transceiver│ │ ECU │ │ Matcher │ │ Decoder │
└────┬─────┘ └─────┬─────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │ │
│ request(0x0C) │ │ │ │
│────────────────►│ │ │ │
│ │ │ │ │
│ │ TX: 7DF#02010C │ │ │
│ │───────────────►│ │ │
│ │ │ │ │
│ │ register(0x0C) │ │ │
│ │───────────────────────────────►│ │
│ │ │ │ │
│ │ │ RX: 7E8#04410C2EE0 │
│ │◄───────────────│ │ │
│ │ │ │ │
│ │ match(7E8, 0x0C) │ │
│ │───────────────────────────────►│ │
│ │ │ │ │
│ │ │ matched(data) │
│ │◄──────────────────────────────│ │
│ │ │ │ │
│ │ decode(0x0C, data) │
│ │──────────────────────────────────────────────►│
│ │ │ │ │
│ │ │ │ 3000 rpm │
│ │◄─────────────────────────────────────────────│
│ │ │ │ │
│ callback(rpm=3000) │ │ │
│◄────────────────│ │ │ │
│ │ │ │ │
```
---
## 6. План реализации
### 6.1 Фазы разработки
#### Фаза 1: Core OBD2 (1-2 недели)
**Задачи:**
- [ ] Создать модуль `obd2/protocol.py` encoding/decoding
- [ ] Создать `obd2/pids.py` реестр PIDs с формулами
- [ ] Модифицировать `CANBusHandler` для TX
- [ ] Создать `CANTransceiver` unified TX/RX
- [ ] Написать unit-тесты для protocol и pids
**Definition of Done:**
- Можно отправить OBD2 запрос и получить ответ
- Ответ корректно декодируется
#### Фаза 2: Polling Engine (1 неделя)
**Задачи:**
- [ ] Создать `obd2/poller.py` polling с группами
- [ ] Создать `obd2/response_matcher.py` корреляция
- [ ] Добавить таймауты и retry логику
- [ ] Интегрировать с конфигурацией
**Definition of Done:**
- Автоматический опрос PIDs по расписанию
- Метрики latency и success rate
#### Фаза 3: Data Layer (1 неделя)
**Задачи:**
- [ ] Расширить SQLite схему
- [ ] Создать `vehicle/state.py` VehicleState
- [ ] Создать `vehicle/state_manager.py` управление состоянием
- [ ] Создать `handlers/realtime_handler.py`
- [ ] Модифицировать `storage_handler.py`
**Definition of Done:**
- Данные сохраняются в БД
- VehicleState обновляется в realtime
#### Фаза 4: Integration & Testing (1 неделя)
**Задачи:**
- [ ] Интеграция всех компонентов
- [ ] Тестирование с OBD2 эмулятором
- [ ] Тестирование на реальном автомобиле (осторожно!)
- [ ] Оптимизация производительности
- [ ] Документация
**Definition of Done:**
- Система работает стабильно
- Все метрики в пределах требований
### 6.2 Приоритеты задач
```
P0 (Must Have)
├── OBD2 Protocol encoding/decoding
├── Basic PID support (RPM, Speed, Coolant)
├── Single Frame TX/RX
├── Polling engine
└── SQLite storage
P1 (Should Have)
├── Extended PIDs
├── Multi Frame support
├── PostgreSQL sync
├── Retry/timeout logic
└── Graceful error handling
P2 (Nice to Have)
├── DTC reading
├── VIN parsing
├── Trip statistics
├── WebSocket publisher
└── Flipper Zero display
```
---
## 7. API изменения
### 7.1 Новые классы
```python
# obd2/protocol.py
class OBD2Request:
mode: int
pid: int
def to_can_frame(self) -> CANFrame: ...
class OBD2Response:
mode: int
pid: int
data: bytes
@classmethod
def from_can_frame(cls, frame: CANFrame) -> Optional['OBD2Response']: ...
# obd2/pids.py
@dataclass
class PIDDefinition:
pid: int
name: str
unit: str
decoder: Callable[[bytes], float]
min_value: float
max_value: float
class PIDRegistry:
def get(self, pid: int) -> Optional[PIDDefinition]: ...
def decode(self, pid: int, data: bytes) -> Optional[float]: ...
# obd2/poller.py
class OBD2Poller:
def __init__(self, transceiver: CANTransceiver, config: OBD2Config): ...
def start(self) -> None: ...
def stop(self) -> None: ...
def request_pid(self, pid: int) -> Future[float]: ...
# vehicle/state_manager.py
class VehicleStateManager:
def get_state(self) -> VehicleState: ...
def update(self, pid: int, value: float) -> None: ...
def subscribe(self, callback: Callable[[str, Any], None]) -> None: ...
```
### 7.2 Изменения конфигурации
```python
# config.py - добавить
class OBD2Config(BaseModel):
enabled: bool = True
request_id: int = 0x7DF
response_id_start: int = 0x7E8
response_id_end: int = 0x7EF
request_timeout_ms: int = 100
retry_count: int = 2
polling_groups: list[PollingGroup] = [
PollingGroup(name="fast", interval_ms=100, pids=["0C", "0D"]),
PollingGroup(name="medium", interval_ms=1000, pids=["05", "04"]),
]
class PollingGroup(BaseModel):
name: str
interval_ms: int
pids: list[str]
enabled: bool = True
```
---
## 8. Тестирование
### 8.1 Unit Tests
| Модуль | Тесты |
|--------|-------|
| `obd2/protocol.py` | Encoding/decoding всех режимов |
| `obd2/pids.py` | Декодирование каждого PID |
| `obd2/poller.py` | Timing, retry, timeout |
| `vehicle/state.py` | State updates, validation |
### 8.2 Integration Tests
| Сценарий | Описание |
|----------|----------|
| Emulator test | Полный цикл с OBD2 эмулятором |
| Reconnect test | Disconnect/reconnect ECU |
| High load test | 100+ PIDs/sec |
| Long run test | 24h stability |
### 8.3 Тестовая среда
```bash
# Запуск эмулятора
python obd2_emulator/src/main.py -i vcan0 -s city
# Запуск модифицированного sniffer
python can_sniffer/src/main.py -i vcan0
```
---
## 9. Риски и митигация
| Риск | Вероятность | Влияние | Митигация |
|------|-------------|---------|-----------|
| ECU не отвечает на все PIDs | Высокая | Среднее | Discovery поддерживаемых PIDs при старте |
| Timing issues (CAN bus busy) | Средняя | Высокое | Adaptive polling, backoff |
| Memory leak при долгой работе | Низкая | Высокое | Periodic GC, memory limits |
| Data corruption при crash | Низкая | Высокое | WAL mode, transactions |
---
## 10. Приложения
### 10.1 Справочник PID формул
```python
PID_FORMULAS = {
0x04: lambda a: a * 100 / 255, # Engine Load %
0x05: lambda a: a - 40, # Coolant Temp °C
0x0C: lambda a, b: (a * 256 + b) / 4, # RPM
0x0D: lambda a: a, # Speed km/h
0x0F: lambda a: a - 40, # Intake Temp °C
0x10: lambda a, b: (a * 256 + b) / 100, # MAF g/s
0x11: lambda a: a * 100 / 255, # Throttle %
0x1F: lambda a, b: a * 256 + b, # Run Time s
0x2F: lambda a: a * 100 / 255, # Fuel Level %
0x46: lambda a: a - 40, # Ambient Temp °C
0x5C: lambda a: a - 40, # Oil Temp °C
0x5E: lambda a, b: (a * 256 + b) / 20, # Fuel Rate L/h
}
```
### 10.2 CAN Frame Examples
```
# Запрос RPM
TX: 7DF # 02 01 0C 00 00 00 00 00
│ │ │
│ │ └── PID: 0x0C (RPM)
│ └───── Mode: 0x01 (Current Data)
└──────── Length: 2 bytes
# Ответ RPM = 3000
RX: 7E8 # 04 41 0C 2E E0 00 00 00
│ │ │ └──┴── Data: (0x2E * 256 + 0xE0) / 4 = 3000
│ │ └─────── PID: 0x0C
│ └────────── Mode + 0x40: 0x41
└───────────── Length: 4 bytes
```
---
## 11. Контакты и согласования
**Автор ТЗ:** Claude (AI Assistant)
**Дата создания:** 2025-01-29
**Статус:** Draft
---
*Данное техническое задание является основой для разработки. Детали реализации могут уточняться в процессе работы.*

View File

@@ -0,0 +1,67 @@
{
"can": {
"interface": "can0",
"bitrate": 500000
},
"obd2": {
"enabled": true,
"request_id": 2015,
"response_id_start": 2024,
"response_id_end": 2031,
"request_timeout_ms": 100,
"retry_count": 2,
"auto_discover": true,
"polling_groups": [
{
"name": "fast",
"interval_ms": 100,
"pids": ["0C", "0D", "11"],
"enabled": true
},
{
"name": "medium",
"interval_ms": 1000,
"pids": ["05", "04", "2F", "0F"],
"enabled": true
},
{
"name": "slow",
"interval_ms": 5000,
"pids": ["46", "5C", "1F"],
"enabled": true
}
]
},
"storage": {
"database_path": "obd2_data.db",
"wal_mode": true,
"sync_mode": "NORMAL",
"retention_days": 7,
"aggregation_retention_days": 30
},
"postgresql": {
"enabled": false,
"host": "localhost",
"port": 5432,
"database": "obd2_data",
"user": "postgres",
"password": ""
},
"flipper": {
"enabled": false,
"device": "/dev/ttyAMA0",
"baudrate": 115200,
"update_interval": 0.5
},
"logging": {
"level": "INFO",
"file": "obd2_client.log",
"max_bytes": 10485760,
"backup_count": 5
},
"vehicle": {
"stale_threshold_s": 5.0,
"disconnect_timeout_s": 10.0,
"session_auto_start": true
}
}

View File

@@ -1,8 +1,8 @@
"""
Модуль конфигурации для CAN Sniffer проекта.
Configuration module for OBD2 Client.
Использует pydantic-settings для типобезопасной конфигурации с валидацией
и поддержкой загрузки из файла и переменных окружения.
Uses pydantic-settings for type-safe configuration with validation
and support for loading from JSON files and environment variables.
"""
from pathlib import Path
@@ -12,240 +12,285 @@ from pydantic import BaseModel, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class CanConfig(BaseModel):
"""Конфигурация CAN интерфейсов."""
class CANConfig(BaseModel):
"""CAN interface configuration for OBD2."""
model_config = {"extra": "ignore"}
interfaces: List[str] = Field(
default=["can0", "can1"],
description="Список CAN интерфейсов для мониторинга"
)
listen_only: bool = Field(
default=True,
description="Режим только чтения (listen-only mode)"
interface: str = Field(
default="can0",
description="CAN interface for OBD2 communication"
)
bitrate: int = Field(
default=1000000,
description="Скорость передачи CAN (бит/с). Должна соответствовать настройкам интерфейса (ip link set canX type can bitrate X)"
default=500000,
description="CAN bus bitrate (500000 for standard OBD2)"
)
filters: List[dict] = Field(
class PollingGroupConfig(BaseModel):
"""Configuration for a PID polling group."""
model_config = {"extra": "ignore"}
name: str = Field(
description="Group identifier"
)
interval_ms: int = Field(
default=100,
description="Polling interval in milliseconds"
)
pids: List[str] = Field(
default_factory=list,
description="Список фильтров SocketCAN: [{'can_id': 0x123, 'can_mask': 0x7FF}, ...]"
description="List of PIDs to poll (hex strings like '0C', '0D')"
)
@field_validator('interfaces', mode='before')
enabled: bool = Field(
default=True,
description="Whether this group is active"
)
@field_validator('pids', mode='before')
@classmethod
def parse_interfaces(cls, v):
"""Парсинг интерфейсов из строки (для env переменных)."""
def parse_pids(cls, v):
"""Parse PIDs from comma-separated string."""
if isinstance(v, str):
return [item.strip() for item in v.split(',')]
return [item.strip() for item in v.split(',') if item.strip()]
return v
class StorageConfig(BaseModel):
"""Конфигурация локального хранилища (SQLite)."""
class OBD2Config(BaseModel):
"""OBD2 protocol configuration."""
model_config = {"extra": "ignore"}
type: str = Field(
default="sqlite",
description="Тип хранилища"
enabled: bool = Field(
default=True,
description="Enable OBD2 polling"
)
request_id: int = Field(
default=0x7DF,
description="CAN ID for OBD2 requests (0x7DF = broadcast)"
)
response_id_start: int = Field(
default=0x7E8,
description="Start of OBD2 response CAN ID range"
)
response_id_end: int = Field(
default=0x7EF,
description="End of OBD2 response CAN ID range"
)
request_timeout_ms: int = Field(
default=100,
description="Timeout for OBD2 request in milliseconds"
)
retry_count: int = Field(
default=2,
description="Number of retries for failed requests"
)
auto_discover: bool = Field(
default=True,
description="Auto-discover supported PIDs on startup"
)
polling_groups: List[PollingGroupConfig] = Field(
default_factory=lambda: [
PollingGroupConfig(
name="fast",
interval_ms=100,
pids=["0C", "0D", "11"], # RPM, Speed, Throttle
),
PollingGroupConfig(
name="medium",
interval_ms=1000,
pids=["05", "04", "2F", "0F"], # Coolant, Load, Fuel, Intake Temp
),
PollingGroupConfig(
name="slow",
interval_ms=5000,
pids=["46", "5C", "1F"], # Ambient, Oil Temp, Runtime
),
],
description="Polling groups with different intervals"
)
class StorageConfig(BaseModel):
"""Local storage configuration (SQLite)."""
model_config = {"extra": "ignore"}
database_path: str = Field(
default="can_offline.db",
description="Путь к файлу базы данных SQLite"
default="obd2_data.db",
description="Path to SQLite database file"
)
wal_mode: bool = Field(
default=True,
description="Включить режим WAL (Write-Ahead Logging)"
description="Enable WAL mode for concurrent access"
)
sync_mode: str = Field(
default="NORMAL",
description="Режим синхронизации: NORMAL, FULL, OFF"
description="Sync mode: NORMAL, FULL, OFF"
)
retention_days: int = Field(
default=7,
description="Дней хранения обработанных записей (для автоочистки)"
description="Days to keep detailed readings"
)
aggregation_retention_days: int = Field(
default=30,
description="Days to keep aggregated data"
)
class PostgreSQLConfig(BaseModel):
"""Конфигурация PostgreSQL."""
model_config = {"extra": "ignore"}
enabled: bool = Field(
default=True,
description="Включить отправку данных в PostgreSQL"
)
host: str = Field(
default="localhost",
description="Хост PostgreSQL сервера"
)
port: int = Field(
default=5432,
description="Порт PostgreSQL сервера"
)
database: str = Field(
default="can_bus",
description="Имя базы данных"
)
user: str = Field(
default="postgres",
description="Имя пользователя PostgreSQL"
)
password: str = Field(
default="",
description="Пароль пользователя PostgreSQL"
)
batch_size: int = Field(
default=1000,
description="Размер батча для отправки данных"
)
flush_interval: int = Field(
default=5,
description="Интервал отправки батча (секунды)"
)
max_retries: int = Field(
default=3,
description="Максимальное количество попыток повтора при ошибке"
)
retry_backoff: float = Field(
default=1.0,
description="Базовый интервал backoff для повторов (секунды)"
)
connection_pool_size: int = Field(
default=5,
description="Размер пула соединений"
)
connection_timeout: int = Field(
default=10,
description="Таймаут подключения (секунды)"
)
sync_interval: float = Field(
default=30.0,
description="Интервал синхронизации из SQLite в PostgreSQL (секунды)"
)
class LoggingConfig(BaseModel):
"""Конфигурация логирования."""
model_config = {"extra": "ignore"}
level: str = Field(
default="INFO",
description="Уровень логирования: DEBUG, INFO, WARNING, ERROR, CRITICAL"
)
format: str = Field(
default="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
description="Формат логов"
)
file: str = Field(
default="can_edge.log",
description="Имя файла для логов"
)
max_bytes: int = Field(
default=10485760,
description="Максимальный размер файла лога (байты)"
)
backup_count: int = Field(
default=5,
description="Количество резервных копий логов"
)
class FlipperConfig(BaseModel):
"""Конфигурация Flipper Zero UART."""
"""PostgreSQL configuration."""
model_config = {"extra": "ignore"}
enabled: bool = Field(
default=False,
description="Включить отправку статистики на Flipper Zero"
description="Enable PostgreSQL synchronization"
)
device: str = Field(
default="/dev/ttyAMA0",
description="UART устройство для подключения Flipper Zero"
host: str = Field(
default="localhost",
description="PostgreSQL server host"
)
baudrate: int = Field(
default=115200,
description="Скорость UART (бод)"
port: int = Field(
default=5432,
description="PostgreSQL server port"
)
send_interval: float = Field(
default=1.0,
description="Интервал отправки статистики (секунды)"
database: str = Field(
default="obd2_data",
description="Database name"
)
class GeneralConfig(BaseModel):
"""Общие настройки."""
model_config = {"extra": "ignore"}
buffer_size: int = Field(
default=10000,
description="Размер буфера для данных"
user: str = Field(
default="postgres",
description="PostgreSQL username"
)
password: str = Field(
default="",
description="PostgreSQL password"
)
batch_size: int = Field(
default=1000,
description="Размер батча для обработки сообщений"
default=100,
description="Batch size for sync operations"
)
batch_interval: float = Field(
default=0.1,
description="Интервал обработки батча (секунды)"
sync_interval: float = Field(
default=30.0,
description="Sync interval in seconds"
)
connection_timeout: int = Field(
default=10,
description="Connection timeout in seconds"
)
max_retries: int = Field(
default=3,
description="Максимальное количество попыток повтора"
description="Maximum retry attempts"
)
retry_delay: float = Field(
default=1.0,
description="Задержка между попытками (секунды)"
class LoggingConfig(BaseModel):
"""Logging configuration."""
model_config = {"extra": "ignore"}
level: str = Field(
default="INFO",
description="Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL"
)
format: str = Field(
default="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
description="Log format"
)
file: str = Field(
default="obd2_client.log",
description="Log file name"
)
max_bytes: int = Field(
default=10485760,
description="Maximum log file size in bytes"
)
backup_count: int = Field(
default=5,
description="Number of backup log files"
)
class FlipperConfig(BaseModel):
"""Flipper Zero UART configuration."""
model_config = {"extra": "ignore"}
enabled: bool = Field(
default=False,
description="Enable Flipper Zero display"
)
device: str = Field(
default="/dev/ttyAMA0",
description="UART device for Flipper Zero"
)
baudrate: int = Field(
default=115200,
description="UART baudrate"
)
update_interval: float = Field(
default=0.5,
description="Display update interval in seconds"
)
class VehicleConfig(BaseModel):
"""Vehicle state configuration."""
model_config = {"extra": "ignore"}
stale_threshold_s: float = Field(
default=5.0,
description="Threshold in seconds to consider a value stale"
)
disconnect_timeout_s: float = Field(
default=10.0,
description="Timeout to consider ECU disconnected"
)
session_auto_start: bool = Field(
default=True,
description="Auto-start session when engine starts"
)
class Config(BaseSettings):
"""Главный класс конфигурации проекта."""
"""Main configuration class for OBD2 Client."""
model_config = SettingsConfigDict(
env_prefix="CAN_SNIFFER_",
env_prefix="OBD2_",
env_nested_delimiter="__",
case_sensitive=False,
extra="ignore",
)
can: CanConfig = Field(default_factory=CanConfig)
can: CANConfig = Field(default_factory=CANConfig)
obd2: OBD2Config = Field(default_factory=OBD2Config)
storage: StorageConfig = Field(default_factory=StorageConfig)
postgresql: PostgreSQLConfig = Field(default_factory=PostgreSQLConfig)
flipper: FlipperConfig = Field(default_factory=FlipperConfig)
logging: LoggingConfig = Field(default_factory=LoggingConfig)
general: GeneralConfig = Field(default_factory=GeneralConfig)
flipper: FlipperConfig = Field(default_factory=FlipperConfig)
vehicle: VehicleConfig = Field(default_factory=VehicleConfig)
@classmethod
def _find_config_file(cls) -> Optional[Path]:
"""Поиск конфигурационного файла."""
# Определяем правильный путь к корню проекта can_sniffer
# __file__ = can_sniffer/src/config.py
# parent = can_sniffer/src
# parent.parent = can_sniffer
"""Find configuration file."""
project_root = Path(__file__).parent.parent
config_paths = [
project_root / "config.json", # can_sniffer/config.json
Path(__file__).parent / "config.json", # can_sniffer/src/config.json
Path.home() / ".can_sniffer" / "config.json",
project_root / "config.json",
Path(__file__).parent / "config.json",
Path.home() / ".obd2_client" / "config.json",
]
for config_path in config_paths:
if config_path.exists():
return config_path
return None
def __init__(self, **kwargs):
"""Инициализация конфигурации с загрузкой из JSON файла."""
# Если kwargs пусты, пытаемся загрузить из файла
"""Initialize configuration with JSON file loading."""
if not kwargs:
config_file = self._find_config_file()
if config_file:
@@ -253,37 +298,23 @@ class Config(BaseSettings):
try:
with open(config_file, 'r', encoding='utf-8') as f:
json_data = json.load(f)
# Передаем данные из JSON в super().__init__()
# Pydantic автоматически создаст вложенные объекты CanConfig, StorageConfig и т.д.
super().__init__(**json_data)
return
except Exception as e:
# Если не удалось загрузить JSON, выводим предупреждение
import warnings
import traceback
warnings.warn(
f"Failed to load config from {config_file}: {e}\n"
f"Traceback: {traceback.format_exc()}\n"
f"Failed to load config from {config_file}: {e}. "
f"Using defaults."
)
# Инициализация с переданными kwargs или defaults
super().__init__(**kwargs)
@classmethod
def load_from_file(cls, file_path: Optional[Path] = None) -> 'Config':
"""Загрузка конфигурации из указанного файла или поиск автоматически.
Args:
file_path: Путь к конфигурационному файлу. Если None, выполняется поиск.
Returns:
Экземпляр Config
"""
"""Load configuration from specified file or auto-find."""
if file_path is None:
file_path = cls._find_config_file()
if file_path and file_path.exists():
import json
try:
@@ -293,26 +324,14 @@ class Config(BaseSettings):
except Exception as e:
import warnings
warnings.warn(f"Failed to load config from {file_path}: {e}")
return cls()
def get(self, key_path: str, default=None):
"""Получение значения конфигурации по пути через точку.
Args:
key_path: Путь к значению через точку, например 'can.interfaces'
default: Значение по умолчанию, если ключ не найден
Returns:
Значение конфигурации или default
Example:
>>> config.get('can.interfaces')
['can0', 'can1']
"""
"""Get configuration value by dot-separated path."""
keys = key_path.split('.')
current = self
for key in keys:
if hasattr(current, key):
current = getattr(current, key)
@@ -320,45 +339,20 @@ class Config(BaseSettings):
current = current[key]
else:
return default
return current
def get_section(self, section: str):
"""Получение всей секции конфигурации.
Args:
section: Имя секции, например 'can', 'postgresql'
Returns:
Объект конфигурации секции
Example:
>>> can_config = config.get_section('can')
>>> print(can_config.interfaces)
"""
"""Get configuration section by name."""
return getattr(self, section, None)
# Глобальный экземпляр конфигурации (singleton)
# Global configuration instance (singleton)
_config_instance: Optional[Config] = None
def get_config(reload: bool = False) -> Config:
"""Получение глобального экземпляра конфигурации.
Args:
reload: Если True, перезагружает конфигурацию из файла
Returns:
Экземпляр Config
Example:
>>> from config import get_config
>>> config = get_config()
>>> interfaces = config.can.interfaces
>>> # Перезагрузить конфигурацию после изменения файла
>>> config = get_config(reload=True)
"""
"""Get global configuration instance."""
global _config_instance
if _config_instance is None or reload:
_config_instance = Config()
@@ -366,48 +360,30 @@ def get_config(reload: bool = False) -> Config:
def reload_config() -> Config:
"""Перезагрузка конфигурации из файла.
Returns:
Перезагруженный экземпляр Config
Example:
>>> from config import reload_config
>>> config = reload_config()
"""
"""Reload configuration from file."""
return get_config(reload=True)
# Для обратной совместимости и удобства
# Используем прокси для автоматического доступа к актуальной конфигурации
class _ConfigProxy:
"""Прокси для глобального доступа к конфигурации с поддержкой перезагрузки."""
"""Proxy for global configuration access with reload support."""
def __getattr__(self, name):
"""Делегирование доступа к атрибутам конфигурации."""
# Всегда получаем актуальный экземпляр конфигурации
return getattr(get_config(), name)
def reload(self):
"""Перезагрузка конфигурации из файла."""
global _config_instance
_config_instance = None # Сбрасываем singleton
_config_instance = None
return reload_config()
def __repr__(self):
"""Строковое представление прокси."""
return f"ConfigProxy({get_config()})"
# Поддержка прямого доступа к методам Config
def get(self, key_path: str, default=None):
"""Получение значения по пути."""
return get_config().get(key_path, default)
def get_section(self, section: str):
"""Получение секции конфигурации."""
return get_config().get_section(section)
# Глобальный прокси для удобного доступа
# ВАЖНО: После изменения config.json нужно вызвать config.reload() или перезапустить приложение
# Global proxy for convenient access
config = _ConfigProxy()

View File

@@ -6,7 +6,7 @@ and handles user actions.
"""
from flipper.pages.base import BasePage
from flipper.pages.can_stats import CANStatsPage
from flipper.pages.obd2_stats import OBD2StatsPage, OBD2CommPage
from flipper.pages.ups_status import UPSStatusPage
from flipper.pages.system_info import SystemInfoPage
from flipper.pages.actions import ActionsPage
@@ -14,7 +14,8 @@ from flipper.pages.app_status import AppStatusPage
__all__ = [
"BasePage",
"CANStatsPage",
"OBD2StatsPage",
"OBD2CommPage",
"UPSStatusPage",
"SystemInfoPage",
"ActionsPage",

View File

@@ -1,68 +0,0 @@
"""
CAN Statistics Page.
Displays CAN sniffer statistics on Flipper Zero.
"""
from flipper.pages.base import InfoPage
from flipper.providers.can_provider import CANProvider
class CANStatsPage(InfoPage):
"""
Page displaying CAN bus statistics.
Shows:
- Total frames received
- Pending/processed frames
- Queue status
- Dropped frames (if any)
"""
def __init__(self):
super().__init__(
name="can_stats",
title="CAN Statistics",
icon="can"
)
self._provider = CANProvider()
def get_lines(self) -> list[str]:
"""Get statistics lines for display."""
# Force refresh to get fresh data
self._provider.refresh()
data = self._provider.get_data()
lines = [
f"Total: {self._format_number(data.total_frames)}",
f"Processed: {self._format_number(data.processed_frames)}",
f"Queue: {data.queue_size}/{data.queue_capacity}",
]
# Add interface breakdown if available
if data.interfaces:
iface_str = ", ".join(
f"{k}: {self._format_number(v)}"
for k, v in data.interfaces.items()
)
if len(iface_str) <= 25:
lines.append(iface_str)
# Show dropped count if non-zero
if data.dropped_frames > 0:
lines.append(f"Dropped: {data.dropped_frames}")
return lines
def _format_number(self, num: int) -> str:
"""Format large numbers with K/M suffix."""
if num >= 1_000_000:
return f"{num / 1_000_000:.1f}M"
elif num >= 1_000:
return f"{num / 1_000:.1f}K"
else:
return str(num)
def get_provider(self) -> CANProvider:
"""Get the CAN provider instance."""
return self._provider

View File

@@ -0,0 +1,104 @@
"""
OBD2 Statistics Page.
Displays OBD2 vehicle data on Flipper Zero.
"""
from flipper.pages.base import InfoPage
from flipper.providers.obd2_provider import OBD2Provider
class OBD2StatsPage(InfoPage):
"""
Page displaying OBD2 vehicle statistics.
Shows:
- RPM and Speed
- Coolant and Oil temperature
- Throttle and Engine load
- Fuel level
"""
def __init__(self):
super().__init__(
name="obd2_stats",
title="OBD2 Data",
icon="car"
)
self._provider = OBD2Provider()
def get_lines(self) -> list[str]:
"""Get OBD2 data lines for display."""
self._provider.refresh()
data = self._provider.get_data()
lines = []
# Connection status
if data.ecu_connected:
status = "ECU: OK"
else:
status = "ECU: ---"
lines.append(status)
# Engine data - RPM and Speed on same line
rpm_str = f"{data.rpm:.0f}" if data.rpm is not None else "---"
spd_str = f"{data.speed:.0f}" if data.speed is not None else "---"
lines.append(f"RPM:{rpm_str} SPD:{spd_str}km/h")
# Temperatures
cool_str = f"{data.coolant_temp:.0f}" if data.coolant_temp is not None else "--"
oil_str = f"{data.oil_temp:.0f}" if data.oil_temp is not None else "--"
lines.append(f"Cool:{cool_str}C Oil:{oil_str}C")
# Throttle and Load
thr_str = f"{data.throttle:.0f}" if data.throttle is not None else "--"
load_str = f"{data.engine_load:.0f}" if data.engine_load is not None else "--"
lines.append(f"Thr:{thr_str}% Load:{load_str}%")
# Fuel
if data.fuel_level is not None:
lines.append(f"Fuel: {data.fuel_level:.0f}%")
return lines
def get_provider(self) -> OBD2Provider:
"""Get the OBD2 provider instance."""
return self._provider
class OBD2CommPage(InfoPage):
"""
Page displaying OBD2 communication statistics.
Shows:
- Request/Response counts
- Success rate
- Average latency
"""
def __init__(self):
super().__init__(
name="obd2_comm",
title="OBD2 Comm",
icon="signal"
)
self._provider = OBD2Provider()
def get_lines(self) -> list[str]:
"""Get communication stats for display."""
self._provider.refresh()
data = self._provider.get_data()
lines = [
f"Requests: {data.total_requests}",
f"Responses: {data.successful_responses}",
f"Success: {data.success_rate:.1f}%",
f"Latency: {data.avg_latency_ms:.1f}ms",
]
return lines
def get_provider(self) -> OBD2Provider:
"""Get the OBD2 provider instance."""
return self._provider

View File

@@ -1,20 +1,20 @@
"""
Data Providers for Flipper Zero Pages.
Providers abstract data sources (UPS, system metrics, CAN stats)
Providers abstract data sources (UPS, system metrics, OBD2 stats)
from the pages that display them.
"""
from flipper.providers.base import BaseProvider
from flipper.providers.ups_provider import UPSProvider
from flipper.providers.system_provider import SystemProvider
from flipper.providers.can_provider import CANProvider
from flipper.providers.obd2_provider import OBD2Provider
from flipper.providers.app_status_provider import AppStatusProvider
__all__ = [
"BaseProvider",
"UPSProvider",
"SystemProvider",
"CANProvider",
"OBD2Provider",
"AppStatusProvider",
]

View File

@@ -1,135 +0,0 @@
"""
CAN Statistics Provider.
Provides CAN sniffer statistics from the message processor.
"""
from typing import Dict, Any, Optional, Callable
from dataclasses import dataclass, field
from flipper.providers.base import BaseProvider
@dataclass
class CANData:
"""CAN statistics data."""
total_frames: int = 0
pending_frames: int = 0
processed_frames: int = 0
dropped_frames: int = 0
queue_size: int = 0
queue_capacity: int = 100000
interfaces: Dict[str, int] = field(default_factory=dict)
class CANProvider(BaseProvider):
"""
Provider for CAN sniffer statistics.
This provider is updated by the FlipperHandler when
CAN frames are processed.
"""
def __init__(self):
super().__init__(name="can", cache_ttl=0.5)
self._data = CANData()
self._stats_callback: Optional[Callable[[], Dict[str, Any]]] = None
def set_stats_callback(self, callback: Callable[[], Dict[str, Any]]) -> None:
"""
Set callback to retrieve stats from message processor.
Args:
callback: Function that returns stats dictionary
"""
self._stats_callback = callback
def update_stats(
self,
total: int = 0,
pending: int = 0,
processed: int = 0,
dropped: int = 0,
queue_size: int = 0
) -> None:
"""
Update CAN statistics directly.
Args:
total: Total frames received
pending: Pending frames in queue
processed: Processed frames
dropped: Dropped frames
queue_size: Current queue size
"""
self._data.total_frames = total
self._data.pending_frames = pending
self._data.processed_frames = processed
self._data.dropped_frames = dropped
self._data.queue_size = queue_size
self._set_cached("data", self._data)
def update_interface_stats(self, interface: str, count: int) -> None:
"""
Update per-interface statistics.
Args:
interface: Interface name (e.g., "can0")
count: Message count for this interface
"""
self._data.interfaces[interface] = count
def refresh(self) -> bool:
"""Refresh stats from callback if available."""
if self._stats_callback:
try:
stats = self._stats_callback()
self._data.total_frames = stats.get("total_frames", 0)
self._data.pending_frames = stats.get("pending_frames", 0)
self._data.processed_frames = stats.get("processed_frames", 0)
self._data.dropped_frames = stats.get("dropped_count", 0)
self._data.queue_size = stats.get("queue_size", 0)
self._set_cached("data", self._data)
return True
except Exception as e:
self._last_error = str(e)
return False
return True
def get_data(self) -> CANData:
"""Get current CAN statistics."""
cached = self._get_cached("data")
if cached is not None:
return cached
self.refresh()
return self._data
def get_total_frames(self) -> int:
"""Get total frames received."""
return self.get_data().total_frames
def get_pending_frames(self) -> int:
"""Get pending frames in queue."""
return self.get_data().pending_frames
def get_processed_frames(self) -> int:
"""Get processed frames."""
return self.get_data().processed_frames
def get_dropped_frames(self) -> int:
"""Get dropped frames."""
return self.get_data().dropped_frames
def get_queue_fill_percent(self) -> float:
"""Get queue fill percentage."""
data = self.get_data()
if data.queue_capacity == 0:
return 0.0
return (data.queue_size / data.queue_capacity) * 100
def get_interface_count(self, interface: str) -> int:
"""Get message count for specific interface."""
return self.get_data().interfaces.get(interface, 0)

View File

@@ -0,0 +1,137 @@
"""
OBD2 Data Provider.
Provides OBD2 vehicle data from VehicleStateManager.
"""
from typing import Dict, Any, Optional, Callable
from dataclasses import dataclass, field
from flipper.providers.base import BaseProvider
@dataclass
class OBD2Data:
"""OBD2 vehicle data for display."""
# Connection
ecu_connected: bool = False
last_update: float = 0.0
# Engine
rpm: Optional[float] = None
engine_load: Optional[float] = None
coolant_temp: Optional[float] = None
oil_temp: Optional[float] = None
# Movement
speed: Optional[float] = None
throttle: Optional[float] = None
# Fuel
fuel_level: Optional[float] = None
fuel_rate: Optional[float] = None
# Stats
total_requests: int = 0
successful_responses: int = 0
success_rate: float = 0.0
avg_latency_ms: float = 0.0
class OBD2Provider(BaseProvider):
"""
Provider for OBD2 vehicle data.
This provider reads data from VehicleStateManager
and poller statistics.
"""
def __init__(self):
super().__init__(name="obd2", cache_ttl=0.25)
self._data = OBD2Data()
self._state_callback: Optional[Callable[[], Any]] = None
self._stats_callback: Optional[Callable[[], Dict[str, Any]]] = None
def set_state_callback(self, callback: Callable[[], Any]) -> None:
"""
Set callback to retrieve VehicleState.
Args:
callback: Function that returns VehicleState
"""
self._state_callback = callback
def set_stats_callback(self, callback: Callable[[], Dict[str, Any]]) -> None:
"""
Set callback to retrieve poller statistics.
Args:
callback: Function that returns stats dictionary
"""
self._stats_callback = callback
def refresh(self) -> bool:
"""Refresh data from VehicleState."""
try:
# Get vehicle state
if self._state_callback:
state = self._state_callback()
if state:
self._data.ecu_connected = state.ecu_connected
self._data.last_update = state.last_response_time
self._data.rpm = state.rpm
self._data.engine_load = state.engine_load
self._data.coolant_temp = state.coolant_temp
self._data.oil_temp = state.oil_temp
self._data.speed = state.speed
self._data.throttle = state.throttle_pos
self._data.fuel_level = state.fuel_level
self._data.fuel_rate = state.fuel_rate
# Get poller stats
if self._stats_callback:
stats = self._stats_callback()
poller = stats.get("poller", {})
self._data.total_requests = poller.get("total_requests", 0)
self._data.successful_responses = poller.get("successful_responses", 0)
self._data.success_rate = poller.get("success_rate", 0.0)
self._data.avg_latency_ms = poller.get("avg_latency_ms", 0.0)
self._set_cached("data", self._data)
return True
except Exception as e:
self._last_error = str(e)
return False
def get_data(self) -> OBD2Data:
"""Get current OBD2 data."""
cached = self._get_cached("data")
if cached is not None:
return cached
self.refresh()
return self._data
# Convenience methods
def get_rpm(self) -> Optional[float]:
return self.get_data().rpm
def get_speed(self) -> Optional[float]:
return self.get_data().speed
def get_coolant_temp(self) -> Optional[float]:
return self.get_data().coolant_temp
def get_fuel_level(self) -> Optional[float]:
return self.get_data().fuel_level
def get_throttle(self) -> Optional[float]:
return self.get_data().throttle
def is_connected(self) -> bool:
return self.get_data().ecu_connected
def get_success_rate(self) -> float:
return self.get_data().success_rate

View File

@@ -1,18 +1,17 @@
"""
Модуль обработчиков CAN сообщений.
OBD2 Data Handlers Module.
Предоставляет плагинную архитектуру для обработки CAN фреймов.
Provides plugin architecture for processing OBD2 readings.
"""
from .base import BaseHandler
from .storage_handler import StorageHandler
from .postgresql_handler import PostgreSQLHandler
from .realtime_handler import RealtimeHandler
from .flipper_handler import FlipperHandler
__all__ = [
'BaseHandler',
'StorageHandler',
'PostgreSQLHandler',
'RealtimeHandler',
'FlipperHandler',
]

View File

@@ -1,480 +1,212 @@
"""
Flipper Zero Dynamic UI Handler.
Flipper Zero Handler for OBD2 Data.
Provides multi-page interface with bidirectional communication via UART.
Supports pluggable pages for CAN stats, UPS status, system info, and actions.
Protocol:
RPi -> Flipper:
PAGE:<idx>/<total>|<type>|<title>|<lines>|<actions>|<selected>
ACK:<device>,ip=<ip>
RESULT:<status>|<message>
Flipper -> RPi:
INIT:<device>
STOP:<device>
CMD:NAV:<next|prev>
CMD:SELECT:<index>
CMD:CONFIRM / CMD:CANCEL
CMD:REFRESH
Sends OBD2 vehicle data to Flipper Zero via UART.
"""
import socket
import threading
import time
from typing import Dict, Any, List, Optional
from typing import Dict, Any, Optional, Callable
from handlers.base import BaseHandler
from can_frame import CANFrame
from config import config
from logger import get_logger
from flipper.protocol import Protocol, Command, CommandType
from config import config
from obd2.pids import OBD2Reading
from flipper.protocol import FlipperProtocol
from flipper.page_manager import PageManager
from flipper.pages import CANStatsPage, UPSStatusPage, SystemInfoPage, ActionsPage, AppStatusPage
from flipper.pages import OBD2StatsPage, OBD2CommPage, UPSStatusPage, SystemInfoPage, AppStatusPage
from flipper.providers.obd2_provider import OBD2Provider
from .base import BaseHandler
logger = get_logger(__name__)
def get_ip_address() -> str:
"""
Get the primary IP address of this device.
Returns:
IP address string or "0.0.0.0" if unable to determine
"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.1)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
pass
try:
hostname = socket.gethostname()
ip = socket.gethostbyname(hostname)
if ip and not ip.startswith("127."):
return ip
except Exception:
pass
return "0.0.0.0"
class FlipperHandler(BaseHandler):
"""
Handler that communicates with Flipper Zero via UART.
Handler that sends OBD2 data to Flipper Zero.
Provides dynamic multi-page interface:
- CAN Statistics
- UPS Status (if available)
- System Information
- Actions Menu
Implements handshake protocol for connection management
and bidirectional command processing.
Uses UART connection to display vehicle data
on Flipper Zero screen.
"""
def __init__(self, enabled: Optional[bool] = None):
def __init__(
self,
enabled: bool = True,
device: str = "/dev/ttyAMA0",
baudrate: int = 115200,
update_interval: float = 0.5,
):
"""
Initialize Flipper handler.
Args:
enabled: Whether handler is enabled. If None, reads from config.
enabled: Whether handler is active
device: UART device path
baudrate: UART baudrate
update_interval: Screen update interval in seconds
"""
if enabled is None:
enabled = getattr(config, "flipper", None) is not None
if enabled:
enabled = getattr(config.flipper, "enabled", False)
super().__init__(name="flipper", enabled=enabled)
self._device = device
self._baudrate = baudrate
self._update_interval = update_interval
super().__init__(name="flipper_handler", enabled=enabled)
self._protocol: Optional[FlipperProtocol] = None
self._page_manager: Optional[PageManager] = None
self._obd2_provider: Optional[OBD2Provider] = None
# Serial configuration
self.serial_port: Optional[Any] = None
self.device = "/dev/ttyAMA0"
self.baudrate = 115200
self.send_interval = 1.0
if hasattr(config, "flipper"):
flipper_cfg = config.flipper
self.device = getattr(flipper_cfg, "device", self.device)
self.baudrate = getattr(flipper_cfg, "baudrate", self.baudrate)
self.send_interval = getattr(flipper_cfg, "send_interval", self.send_interval)
# Connection state
self._connected = False
self._update_thread: Optional[threading.Thread] = None
self._running = False
# Statistics
self._stats_lock = threading.Lock()
self._total_frames = 0
self._pending_frames = 0
self._processed_frames = 0
self._sent_count = 0
self._error_count = 0
# Callbacks for data access
self._state_callback: Optional[Callable[[], Any]] = None
self._stats_callback: Optional[Callable[[], Dict[str, Any]]] = None
# Threads
self._rx_thread: Optional[threading.Thread] = None
self._tx_thread: Optional[threading.Thread] = None
def set_state_callback(self, callback: Callable[[], Any]) -> None:
"""Set callback to get VehicleState."""
self._state_callback = callback
if self._obd2_provider:
self._obd2_provider.set_state_callback(callback)
# IP address
self._ip_address = "0.0.0.0"
# Page manager
self._page_manager = PageManager()
self._setup_pages()
def _setup_pages(self) -> None:
"""Setup default pages."""
# CAN Statistics (always available)
can_page = CANStatsPage()
self._page_manager.register_page(can_page)
# Keep reference to CAN provider for stats updates
self._can_provider = can_page.get_provider()
# Application Status (SQLite, PostgreSQL, Queue, etc.)
app_status_page = AppStatusPage()
self._page_manager.register_page(app_status_page)
# Keep reference to app status provider for updates
self._app_status_provider = app_status_page.get_provider()
# UPS Status (if available)
ups_page = UPSStatusPage()
self._page_manager.register_page(ups_page)
# System Information
system_page = SystemInfoPage()
self._page_manager.register_page(system_page)
# Actions Menu
actions_page = ActionsPage(on_result=self._on_action_result)
self._page_manager.register_page(actions_page)
def _on_action_result(self, result: str) -> None:
"""Handle action result from actions page."""
self.logger.info(f"Action result: {result}")
# Send result to Flipper
if self._connected:
msg = Protocol.encode_result(True, result)
self._send_raw(msg)
def set_stats_callback(self, callback: Callable[[], Dict[str, Any]]) -> None:
"""Set callback to get client stats."""
self._stats_callback = callback
if self._obd2_provider:
self._obd2_provider.set_stats_callback(callback)
def initialize(self) -> bool:
"""
Initialize UART connection.
"""Initialize the handler."""
if not self._enabled:
logger.info("Flipper handler disabled")
return False
Returns:
True if initialization successful
"""
try:
import serial
self.serial_port = serial.Serial(
port=self.device,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.1,
# Initialize UART protocol
self._protocol = FlipperProtocol(
device=self._device,
baudrate=self._baudrate,
)
self._ip_address = get_ip_address()
if not self._protocol.connect():
logger.warning(f"Failed to connect to Flipper on {self._device}")
self._available = False
return False
# Initialize page manager with OBD2 pages
self._page_manager = PageManager(self._protocol)
# Create and register pages
obd2_page = OBD2StatsPage()
comm_page = OBD2CommPage()
ups_page = UPSStatusPage()
sys_page = SystemInfoPage()
app_page = AppStatusPage()
self._page_manager.register_page(obd2_page)
self._page_manager.register_page(comm_page)
self._page_manager.register_page(ups_page)
self._page_manager.register_page(sys_page)
self._page_manager.register_page(app_page)
# Get OBD2 provider from page
self._obd2_provider = obd2_page.get_provider()
# Set callbacks if already provided
if self._state_callback:
self._obd2_provider.set_state_callback(self._state_callback)
if self._stats_callback:
self._obd2_provider.set_stats_callback(self._stats_callback)
# Start update thread
self._running = True
self._update_thread = threading.Thread(
target=self._update_loop,
name="Flipper-Update",
daemon=True,
)
self._update_thread.start()
self._initialized = True
self.logger.info(
f"Flipper handler initialized on {self.device} @ {self.baudrate} baud, "
f"IP: {self._ip_address}"
logger.info(
"Flipper handler initialized",
extra={
"device": self._device,
"update_interval": self._update_interval,
}
)
return True
except ImportError:
self.logger.error("pyserial not installed. Run: pip install pyserial")
return False
except Exception as e:
self.logger.error(f"Failed to initialize Flipper UART: {e}")
logger.error(f"Failed to initialize Flipper handler: {e}")
return False
def start(self) -> None:
"""Start the RX listener and TX sender threads."""
if self._running:
return
self._running = True
self._connected = False
# Start RX thread (listens for commands)
self._rx_thread = threading.Thread(
target=self._rx_loop, name="FlipperRX", daemon=True
)
self._rx_thread.start()
# Start TX thread (sends page content when connected)
self._tx_thread = threading.Thread(
target=self._tx_loop, name="FlipperTX", daemon=True
)
self._tx_thread.start()
self.logger.info(
f"Flipper handler started with {self._page_manager.get_page_count()} pages"
)
def _rx_loop(self) -> None:
"""Receive loop - listens for commands from Flipper."""
buffer = ""
while self._running:
try:
if not self.serial_port or not self.serial_port.is_open:
time.sleep(0.1)
continue
# Read available data
if self.serial_port.in_waiting > 0:
data = self.serial_port.read(self.serial_port.in_waiting)
buffer += data.decode("utf-8", errors="ignore")
# Process complete lines
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if line:
self._process_command(line)
else:
time.sleep(0.05)
except Exception as e:
self.logger.debug(f"RX error: {e}")
time.sleep(0.1)
def _process_command(self, raw_command: str) -> None:
def handle(self, reading: OBD2Reading) -> bool:
"""
Process received command from Flipper.
Handle incoming OBD2 reading.
Data is displayed via the update loop, not directly from readings.
This method just ensures the handler is active.
Args:
raw_command: Raw command string
"""
self.logger.debug(f"RX: {raw_command}")
# Handle handshake commands directly
if raw_command.startswith("INIT:"):
client_id = raw_command[5:].strip()
self.logger.info(f"Handshake request from: {client_id}")
# Update IP and send ACK
self._ip_address = get_ip_address()
ack_msg = Protocol.encode_ack("rpi5", self._ip_address)
self._send_raw(ack_msg)
self._connected = True
self.logger.info(f"Connected to Flipper, IP: {self._ip_address}")
# Send initial page content
self._send_page_content()
return
if raw_command.startswith("STOP:"):
client_id = raw_command[5:].strip()
self.logger.info(f"Disconnect request from: {client_id}")
self._connected = False
return
# Parse and process other commands
command = Protocol.decode_command(raw_command)
if command is None:
self.logger.debug(f"Unknown command: {raw_command}")
return
# Process command via page manager
result = self._page_manager.process_command(command)
# Send result if available
if result:
msg = Protocol.encode_result(True, result)
self._send_raw(msg)
# Always send updated page content after command
self._send_page_content()
def _tx_loop(self) -> None:
"""Transmit loop - sends page content periodically when connected."""
while self._running:
try:
if self._connected:
self._send_page_content()
time.sleep(self.send_interval)
except Exception as e:
self.logger.debug(f"TX error: {e}")
with self._stats_lock:
self._error_count += 1
def _send_raw(self, message: str) -> bool:
"""
Send raw message via UART.
Args:
message: Message to send
reading: OBD2Reading (not directly used)
Returns:
True if sent successfully
True if handler is active
"""
if not self.serial_port or not self.serial_port.is_open:
return False
return self._initialized and self._enabled
try:
self.serial_port.write(message.encode("utf-8"))
self.serial_port.flush()
self.logger.debug(f"TX: {message.strip()}")
return True
except Exception as e:
self.logger.debug(f"Send error: {e}")
return False
def _send_page_content(self) -> None:
"""Send current page content to Flipper Zero."""
if not self._connected:
return
content = self._page_manager.get_current_content()
if content:
if self._send_raw(content):
with self._stats_lock:
self._sent_count += 1
def handle(self, frame: CANFrame) -> bool:
"""
Handle a single CAN frame.
Args:
frame: CANFrame to handle
Returns:
True (always succeeds)
"""
with self._stats_lock:
self._total_frames += 1
self._pending_frames += 1
# Update CAN provider
self._can_provider.update_stats(
total=self._total_frames,
pending=self._pending_frames,
processed=self._processed_frames
)
return True
def handle_batch(self, frames: List[CANFrame]) -> int:
"""
Handle a batch of CAN frames.
Args:
frames: List of CANFrame objects
Returns:
Number of frames processed
"""
count = len(frames)
with self._stats_lock:
self._total_frames += count
self._processed_frames += count
self._pending_frames = max(0, self._pending_frames - count)
# Update CAN provider
self._can_provider.update_stats(
total=self._total_frames,
pending=self._pending_frames,
processed=self._processed_frames
)
return count
def update_pending(self, pending_count: int) -> None:
"""
Update pending frame count.
Args:
pending_count: Current number of pending frames
"""
with self._stats_lock:
self._pending_frames = pending_count
self._can_provider.update_stats(
total=self._total_frames,
pending=self._pending_frames,
processed=self._processed_frames
)
def handle_batch(self, readings: list) -> int:
"""Handle batch of readings."""
if not self._initialized or not self._enabled:
return 0
return len(readings)
def flush(self) -> None:
"""Flush - send immediate page content if connected."""
if self._connected:
try:
self._send_page_content()
except Exception as e:
self.logger.debug(f"Flush error: {e}")
"""Flush is handled by update loop."""
pass
def shutdown(self) -> None:
"""Shutdown the handler."""
self.logger.info("Shutting down Flipper handler...")
self._running = False
self._connected = False
# Wait for threads
if self._rx_thread and self._rx_thread.is_alive():
self._rx_thread.join(timeout=2.0)
if self._update_thread and self._update_thread.is_alive():
self._update_thread.join(timeout=2.0)
if self._tx_thread and self._tx_thread.is_alive():
self._tx_thread.join(timeout=2.0)
# Close serial port
if self.serial_port and self.serial_port.is_open:
try:
self.serial_port.close()
except Exception as e:
self.logger.debug(f"Error closing serial port: {e}")
# Shutdown page manager
self._page_manager.shutdown()
if self._protocol:
self._protocol.disconnect()
logger.info("Flipper handler shutdown")
self._initialized = False
self.logger.info("Flipper handler stopped")
def get_stats(self) -> Dict[str, Any]:
"""
Get handler statistics.
"""Get handler statistics."""
stats = {
"name": self.name,
"enabled": self._enabled,
"initialized": self._initialized,
"device": self._device,
"connected": self._protocol.is_connected() if self._protocol else False,
}
Returns:
Dictionary with handler stats
"""
with self._stats_lock:
stats = {
"total_frames": self._total_frames,
"pending_frames": self._pending_frames,
"processed_frames": self._processed_frames,
"sent_count": self._sent_count,
"error_count": self._error_count,
"device": self.device,
"baudrate": self.baudrate,
"connected": self._connected,
"ip_address": self._ip_address,
}
# Add page manager stats
stats.update(self._page_manager.get_stats())
if self._page_manager:
stats["current_page"] = self._page_manager.get_current_page_name()
return stats
def is_connected(self) -> bool:
"""Check if Flipper is connected."""
return self._connected
def _update_loop(self) -> None:
"""Update loop for sending data to Flipper."""
logger.debug("Flipper update loop started")
def get_page_manager(self) -> PageManager:
"""Get page manager for external page registration."""
return self._page_manager
while self._running:
try:
if self._page_manager and self._protocol and self._protocol.is_connected():
# Update current page
self._page_manager.update()
# Handle input from Flipper
self._page_manager.handle_input()
except Exception as e:
logger.error(f"Flipper update error: {e}")
time.sleep(self._update_interval)
logger.debug("Flipper update loop stopped")

View File

@@ -1,151 +0,0 @@
"""
Обработчик для отправки CAN сообщений в PostgreSQL.
"""
from typing import List, Dict, Any, Optional
from can_frame import CANFrame
from .base import BaseHandler
from postgresql_handler import get_postgresql_client
from postgresql_handler.postgresql_client import ConnectionStatus
from config import config
class PostgreSQLHandler(BaseHandler):
"""Обработчик для отправки в PostgreSQL."""
def __init__(self, enabled: Optional[bool] = None):
"""
Инициализация обработчика PostgreSQL.
Args:
enabled: Включен ли обработчик. Если None, берется из config.postgresql.enabled
"""
super().__init__(
name="postgresql",
enabled=enabled if enabled is not None else config.postgresql.enabled
)
self.postgresql_client = None
def initialize(self) -> bool:
"""Инициализация PostgreSQL клиента."""
if not self.enabled:
return False
try:
self.postgresql_client = get_postgresql_client()
self._initialized = True
self.logger.info("PostgreSQL handler initialized")
return True
except Exception as e:
self.logger.error(f"Failed to initialize PostgreSQL: {e}", exc_info=True)
self.postgresql_client = None
return False
def handle(self, frame: CANFrame) -> bool:
"""Обработка одного CAN фрейма."""
if not self.enabled or not self._initialized or not self.postgresql_client:
return False
try:
return self.postgresql_client.write_message(
interface=frame.bus,
can_id=frame.can_id,
dlc=frame.dlc,
data=frame.data,
timestamp=frame.timestamp
)
except Exception as e:
self.logger.error(
f"Failed to send frame to PostgreSQL: {e}",
exc_info=True,
extra={"can_id": frame.can_id_hex}
)
return False
def handle_batch(self, frames: List[CANFrame]) -> int:
"""
Обработка батча CAN фреймов.
Неблокирующий метод - при ошибках или переполнении очереди PostgreSQL
просто пропускает батч, не останавливая обработку других handlers.
"""
if not self.enabled or not self._initialized or not self.postgresql_client or not frames:
return 0
try:
# Проверяем состояние соединения перед обработкой
if hasattr(self.postgresql_client, 'connection_status'):
if self.postgresql_client.connection_status != ConnectionStatus.CONNECTED:
# Соединение недоступно - пропускаем батч без ошибки
return 0
# Конвертируем CANFrame в формат для PostgreSQL
postgresql_messages = []
for frame in frames:
postgresql_messages.append({
"interface": frame.bus,
"can_id": frame.can_id,
"can_id_hex": frame.can_id_hex,
"dlc": frame.dlc,
"data": frame.data,
"data_hex": frame.data_hex,
"timestamp": frame.timestamp, # float timestamp в секундах
"is_extended": frame.is_extended
})
if postgresql_messages:
# Пытаемся добавить в очередь PostgreSQL (неблокирующий режим)
# Если очередь переполнена, пропускаем батч
return self.postgresql_client.write_messages_batch(postgresql_messages)
return 0
except Exception as e:
# Ошибка не должна останавливать обработку других handlers
# Логируем, но не пробрасываем исключение
self.logger.error(
f"Failed to send frames batch to PostgreSQL: {e}",
exc_info=True,
extra={"batch_size": len(frames)}
)
return 0
def flush(self) -> None:
"""Принудительная отправка накопленных данных."""
# PostgreSQL forwarder сам управляет flush через свой цикл
# Но можно вызвать явный flush если нужно
pass
def shutdown(self) -> None:
"""Корректное завершение работы обработчика."""
if self.postgresql_client:
try:
self.postgresql_client.close()
self.logger.info("PostgreSQL handler closed")
except Exception as e:
self.logger.error(f"Error closing PostgreSQL: {e}", exc_info=True)
self._initialized = False
def get_stats(self) -> Dict[str, Any]:
"""Получение статистики обработчика."""
if self.postgresql_client:
try:
stats = self.postgresql_client.get_stats()
stats["handler"] = self.name
stats["enabled"] = self.enabled
stats["initialized"] = self._initialized
return stats
except Exception:
pass
return {
"handler": self.name,
"enabled": self.enabled,
"initialized": self._initialized
}
def start(self) -> None:
"""Запуск PostgreSQL forwarder (если используется)."""
if self.postgresql_client:
try:
self.postgresql_client.start()
except Exception as e:
self.logger.error(f"Failed to start PostgreSQL forwarder: {e}", exc_info=True)

View File

@@ -0,0 +1,114 @@
"""
Realtime Handler for OBD2 Data.
Updates VehicleState in memory from OBD2 readings.
"""
from typing import Dict, Any, Optional
from logger import get_logger
from vehicle.state_manager import VehicleStateManager
from obd2.pids import OBD2Reading
from .base import BaseHandler
logger = get_logger(__name__)
class RealtimeHandler(BaseHandler):
"""
Handler that updates VehicleState from OBD2 readings.
Maintains the in-memory vehicle state for real-time access.
"""
def __init__(self, enabled: bool = True):
super().__init__(name="realtime", enabled=enabled)
self._state_manager: Optional[VehicleStateManager] = None
self._updates_count = 0
def initialize(self) -> bool:
"""Initialize the handler."""
try:
self._state_manager = VehicleStateManager()
self._initialized = True
logger.info("Realtime handler initialized")
return True
except Exception as e:
logger.error(f"Failed to initialize realtime handler: {e}")
return False
def handle(self, reading: OBD2Reading) -> bool:
"""
Update vehicle state from a single reading.
Args:
reading: OBD2Reading to process
Returns:
True if state was updated
"""
if not self._initialized or not self._enabled:
return False
if not reading.is_valid:
return False
try:
updated = self._state_manager.update_from_reading(reading)
if updated:
self._updates_count += 1
return updated
except Exception as e:
logger.error(f"Error updating vehicle state: {e}")
return False
def handle_batch(self, readings: list) -> int:
"""
Update vehicle state from multiple readings.
Args:
readings: List of OBD2Reading objects
Returns:
Number of successful updates
"""
if not self._initialized or not self._enabled:
return 0
count = 0
for reading in readings:
if self.handle(reading):
count += 1
return count
def flush(self) -> None:
"""Flush is not needed for realtime handler."""
pass
def shutdown(self) -> None:
"""Shutdown the handler."""
logger.info(
"Realtime handler shutdown",
extra={"total_updates": self._updates_count}
)
self._initialized = False
def get_stats(self) -> Dict[str, Any]:
"""Get handler statistics."""
state_stats = {}
if self._state_manager:
state_stats = self._state_manager.get_stats()
return {
"name": self.name,
"enabled": self._enabled,
"initialized": self._initialized,
"updates_count": self._updates_count,
**state_stats
}
@property
def state_manager(self) -> Optional[VehicleStateManager]:
"""Get the VehicleStateManager instance."""
return self._state_manager

View File

@@ -1,119 +1,187 @@
"""
Обработчик для сохранения CAN сообщений в SQLite.
Storage Handler for OBD2 Data.
Saves OBD2 readings to SQLite database with batching.
"""
from typing import List, Dict, Any
from can_frame import CANFrame
import threading
import time
from typing import Dict, Any, List, Optional
from logger import get_logger
from storage.storage import get_storage, Storage
from obd2.pids import OBD2Reading
from .base import BaseHandler
from storage import get_storage
logger = get_logger(__name__)
class StorageHandler(BaseHandler):
"""Обработчик для сохранения в SQLite."""
def __init__(self, enabled: bool = True):
"""Инициализация обработчика storage."""
"""
Handler that saves OBD2 readings to SQLite.
Supports batching for efficient database writes.
"""
def __init__(
self,
enabled: bool = True,
batch_size: int = 50,
flush_interval: float = 1.0
):
"""
Initialize storage handler.
Args:
enabled: Whether handler is active
batch_size: Number of readings to batch before write
flush_interval: Maximum time before flushing batch (seconds)
"""
super().__init__(name="storage", enabled=enabled)
self.storage = None
self._storage: Optional[Storage] = None
self._batch_size = batch_size
self._flush_interval = flush_interval
self._batch: List[OBD2Reading] = []
self._batch_lock = threading.Lock()
self._last_flush_time = time.time()
self._saved_count = 0
self._batch_count = 0
def initialize(self) -> bool:
"""Инициализация storage."""
if not self.enabled:
return False
"""Initialize the handler."""
try:
self.storage = get_storage()
self._storage = get_storage()
self._initialized = True
self.logger.info("Storage handler initialized")
logger.info(
"Storage handler initialized",
extra={
"batch_size": self._batch_size,
"flush_interval": self._flush_interval
}
)
return True
except Exception as e:
self.logger.error(f"Failed to initialize storage: {e}", exc_info=True)
self.storage = None
logger.error(f"Failed to initialize storage handler: {e}")
return False
def handle(self, frame: CANFrame) -> bool:
"""Обработка одного CAN фрейма."""
if not self.enabled or not self._initialized or not self.storage:
return False
try:
message_id = self.storage.save_message(
interface=frame.bus,
can_id=frame.can_id,
dlc=frame.dlc,
data=frame.data,
timestamp=frame.timestamp
)
return message_id is not None
except Exception as e:
self.logger.error(
f"Failed to save frame: {e}",
exc_info=True,
extra={"can_id": frame.can_id_hex}
)
return False
def handle_batch(self, frames: List[CANFrame]) -> int:
"""Обработка батча CAN фреймов."""
if not self.enabled or not self._initialized or not self.storage or not frames:
return 0
try:
# Конвертируем CANFrame в формат для storage
messages = []
for frame in frames:
messages.append((
frame.timestamp, # float timestamp в секундах
frame.bus,
frame.can_id,
frame.dlc,
frame.data
))
saved_count = self.storage.save_messages_batch(messages)
if saved_count != len(frames):
self.logger.warning(
f"Not all frames saved: {saved_count}/{len(frames)}",
extra={"batch_size": len(frames)}
)
return saved_count
except Exception as e:
self.logger.error(
f"Failed to save frames batch: {e}",
exc_info=True,
extra={"batch_size": len(frames)}
)
return 0
def flush(self) -> None:
"""Принудительная отправка накопленных данных."""
# SQLite не требует явного flush, данные сохраняются сразу
pass
def shutdown(self) -> None:
"""Корректное завершение работы обработчика.
Примечание: НЕ закрываем Storage singleton здесь, так как он может
использоваться другими компонентами (например, для синхронизации с PostgreSQL).
Storage закрывается отдельно при полном завершении приложения.
def handle(self, reading: OBD2Reading) -> bool:
"""
Add reading to batch.
Args:
reading: OBD2Reading to save
Returns:
True if reading was accepted
"""
if not self._initialized or not self._enabled:
return False
with self._batch_lock:
self._batch.append(reading)
# Check if we should flush
should_flush = (
len(self._batch) >= self._batch_size or
(time.time() - self._last_flush_time) >= self._flush_interval
)
if should_flush:
self.flush()
return True
def handle_batch(self, readings: list) -> int:
"""
Add multiple readings.
Args:
readings: List of OBD2Reading objects
Returns:
Number of readings accepted
"""
if not self._initialized or not self._enabled:
return 0
with self._batch_lock:
self._batch.extend(readings)
should_flush = (
len(self._batch) >= self._batch_size or
(time.time() - self._last_flush_time) >= self._flush_interval
)
if should_flush:
self.flush()
return len(readings)
def flush(self) -> None:
"""Flush pending readings to database."""
if not self._storage:
return
with self._batch_lock:
if not self._batch:
return
batch_to_save = self._batch
self._batch = []
self._last_flush_time = time.time()
try:
saved = self._storage.save_readings_batch(batch_to_save)
self._saved_count += saved
self._batch_count += 1
logger.debug(
f"Flushed {saved} readings to storage",
extra={"batch_number": self._batch_count}
)
except Exception as e:
logger.error(f"Failed to flush readings: {e}")
# Put readings back in batch for retry
with self._batch_lock:
self._batch = batch_to_save + self._batch
def shutdown(self) -> None:
"""Shutdown the handler."""
# Final flush
self.flush()
logger.info(
"Storage handler shutdown",
extra={
"total_saved": self._saved_count,
"total_batches": self._batch_count
}
)
self._initialized = False
self.logger.info("Storage handler shutdown complete")
def get_stats(self) -> Dict[str, Any]:
"""Получение статистики обработчика."""
if self.storage:
try:
stats = self.storage.get_stats()
stats["handler"] = self.name
stats["enabled"] = self.enabled
stats["initialized"] = self._initialized
return stats
except Exception:
pass
"""Get handler statistics."""
storage_stats = {}
if self._storage:
storage_stats = self._storage.get_stats()
with self._batch_lock:
pending = len(self._batch)
return {
"handler": self.name,
"enabled": self.enabled,
"initialized": self._initialized
"name": self.name,
"enabled": self._enabled,
"initialized": self._initialized,
"saved_count": self._saved_count,
"batch_count": self._batch_count,
"pending_in_batch": pending,
"batch_size": self._batch_size,
**storage_stats
}
@property
def storage(self) -> Optional[Storage]:
"""Get the Storage instance."""
return self._storage

View File

@@ -1,84 +1,377 @@
#!/usr/bin/env python3
"""
Главный модуль CAN Sniffer приложения.
OBD2 Client - Onboard Computer for Vehicle Diagnostics.
Только код запуска приложения. Вся логика обработки сообщений
автоматически применяется в модуле socket_can.
Main entry point for the OBD2 client application.
Polls vehicle ECU for diagnostic data via CAN bus.
"""
import signal
import subprocess
import sys
import time
from config import config
from logger import get_logger
from socket_can import CANSniffer
import argparse
from typing import Optional, List
from config import get_config, Config
from logger import get_logger
from obd2.transceiver import CANTransceiver
from obd2.response_matcher import ResponseMatcher
from obd2.poller import OBD2Poller, PollingGroup
from obd2.pids import OBD2Reading
from handlers.realtime_handler import RealtimeHandler
from handlers.storage_handler import StorageHandler
from handlers.flipper_handler import FlipperHandler
from storage.storage import get_storage
from vehicle.state_manager import VehicleStateManager
# Инициализация логгера
logger = get_logger(__name__)
# Глобальная переменная для graceful shutdown
sniffer: CANSniffer = None
class OBD2Client:
"""
Main OBD2 Client Application.
Orchestrates all components for OBD2 communication:
- CAN transceiver for TX/RX
- Response matcher for request correlation
- Poller for periodic PID requests
- Handlers for data processing
"""
def __init__(self, config: Optional[Config] = None):
"""
Initialize OBD2 client.
Args:
config: Configuration object (uses global if not provided)
"""
self.config = config or get_config()
# Core components
self._transceiver: Optional[CANTransceiver] = None
self._matcher: Optional[ResponseMatcher] = None
self._poller: Optional[OBD2Poller] = None
# Handlers
self._realtime_handler: Optional[RealtimeHandler] = None
self._storage_handler: Optional[StorageHandler] = None
self._flipper_handler: Optional[FlipperHandler] = None
self._handlers: List = []
# State
self._running = False
self._state_manager: Optional[VehicleStateManager] = None
def start(self) -> bool:
"""
Start the OBD2 client.
Returns:
True if started successfully
"""
logger.info(
"Starting OBD2 Client",
extra={
"interface": self.config.can.interface,
"bitrate": self.config.can.bitrate,
}
)
try:
# Initialize storage
storage = get_storage()
session_id = storage.start_session()
logger.info(f"Started session {session_id}")
# Initialize transceiver
self._transceiver = CANTransceiver(
interface=self.config.can.interface,
bitrate=self.config.can.bitrate,
)
if not self._transceiver.start():
logger.error("Failed to start CAN transceiver")
return False
# Initialize response matcher
self._matcher = ResponseMatcher(
timeout_ms=self.config.obd2.request_timeout_ms,
max_retries=self.config.obd2.retry_count,
)
# Initialize handlers
self._init_handlers()
# Initialize poller
self._poller = OBD2Poller(
transceiver=self._transceiver,
matcher=self._matcher,
reading_callback=self._on_reading,
auto_discover=self.config.obd2.auto_discover,
)
# Add polling groups from config
for group_config in self.config.obd2.polling_groups:
if group_config.enabled:
self._poller.add_group(PollingGroup(
name=group_config.name,
interval_ms=group_config.interval_ms,
pids=group_config.pids,
enabled=group_config.enabled,
))
# Start poller
if not self._poller.start():
logger.error("Failed to start OBD2 poller")
return False
self._running = True
logger.info("OBD2 Client started successfully")
return True
except Exception as e:
logger.error(f"Failed to start OBD2 client: {e}", exc_info=True)
self.stop()
return False
def _init_handlers(self) -> None:
"""Initialize data handlers."""
# Realtime handler (updates VehicleState)
self._realtime_handler = RealtimeHandler(enabled=True)
if self._realtime_handler.initialize():
self._handlers.append(self._realtime_handler)
self._state_manager = self._realtime_handler.state_manager
# Storage handler (saves to SQLite)
self._storage_handler = StorageHandler(
enabled=True,
batch_size=50,
flush_interval=1.0,
)
if self._storage_handler.initialize():
self._handlers.append(self._storage_handler)
# Flipper handler (display on Flipper Zero)
if self.config.flipper.enabled:
self._flipper_handler = FlipperHandler(
enabled=True,
device=self.config.flipper.device,
baudrate=self.config.flipper.baudrate,
update_interval=self.config.flipper.update_interval,
)
if self._flipper_handler.initialize():
# Set callbacks for data access
self._flipper_handler.set_state_callback(lambda: self.vehicle_state)
self._flipper_handler.set_stats_callback(self.get_stats)
self._handlers.append(self._flipper_handler)
logger.info(f"Initialized {len(self._handlers)} handlers")
def _on_reading(self, reading: OBD2Reading) -> None:
"""
Handle incoming OBD2 reading.
Args:
reading: Decoded OBD2 reading
"""
# Forward to all handlers
for handler in self._handlers:
try:
handler.handle(reading)
except Exception as e:
logger.error(f"Handler {handler.name} error: {e}")
def stop(self) -> None:
"""Stop the OBD2 client."""
if not self._running:
return
logger.info("Stopping OBD2 Client")
self._running = False
# Stop poller first
if self._poller:
self._poller.stop()
# Shutdown handlers
for handler in self._handlers:
try:
handler.shutdown()
except Exception as e:
logger.error(f"Error shutting down handler {handler.name}: {e}")
# Stop transceiver
if self._transceiver:
self._transceiver.stop()
# End session
try:
storage = get_storage()
storage.end_session()
storage.close()
except Exception as e:
logger.error(f"Error closing storage: {e}")
logger.info("OBD2 Client stopped")
def get_stats(self) -> dict:
"""Get client statistics."""
stats = {
"running": self._running,
}
if self._transceiver:
stats["transceiver"] = self._transceiver.get_stats()
if self._poller:
stats["poller"] = self._poller.get_stats()
if self._matcher:
stats["matcher"] = self._matcher.get_stats()
for handler in self._handlers:
stats[f"handler_{handler.name}"] = handler.get_stats()
return stats
@property
def vehicle_state(self):
"""Get current vehicle state."""
if self._state_manager:
return self._state_manager.state
return None
@property
def is_running(self) -> bool:
"""Check if client is running."""
return self._running
# Global client instance for signal handler
_client: Optional[OBD2Client] = None
def signal_handler(sig, frame):
"""Обработчик сигналов для graceful shutdown."""
logger.info("Received shutdown signal, stopping gracefully...")
if sniffer:
try:
sniffer.stop()
except Exception as e:
logger.error(f"Error during shutdown: {e}", exc_info=True)
# Даем время на завершение потоков перед выходом
import time
time.sleep(0.5)
"""Handle shutdown signals."""
logger.info(f"Received signal {sig}, shutting down...")
if _client:
_client.stop()
sys.exit(0)
def print_vehicle_state(client: OBD2Client) -> None:
"""Print current vehicle state to console."""
state = client.vehicle_state
if state is None:
print("No vehicle state available")
return
print("\n" + "=" * 50)
print("VEHICLE STATE")
print("=" * 50)
if state.ecu_connected:
print(f"ECU: Connected")
else:
print(f"ECU: Disconnected")
if state.rpm is not None:
print(f"RPM: {state.rpm:.0f}")
if state.speed is not None:
print(f"Speed: {state.speed:.0f} km/h")
if state.coolant_temp is not None:
print(f"Coolant: {state.coolant_temp:.0f} °C")
if state.throttle_pos is not None:
print(f"Throttle: {state.throttle_pos:.1f} %")
if state.engine_load is not None:
print(f"Load: {state.engine_load:.1f} %")
if state.fuel_level is not None:
print(f"Fuel: {state.fuel_level:.1f} %")
print("=" * 50)
def main():
"""Главная функция приложения - только запуск."""
global sniffer
# Регистрируем обработчики сигналов для graceful shutdown
"""Main entry point."""
global _client
parser = argparse.ArgumentParser(
description="OBD2 Client - Vehicle Diagnostics via CAN"
)
parser.add_argument(
"-i", "--interface",
default=None,
help="CAN interface (e.g., can0, vcan0)"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Enable verbose logging"
)
parser.add_argument(
"--stats-interval",
type=int,
default=10,
help="Statistics print interval in seconds (0 to disable)"
)
args = parser.parse_args()
# Override config if interface specified
config = get_config()
if args.interface:
config.can.interface = args.interface
# Setup signal handlers
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
logger.info("CAN Sniffer application starting", extra={
"interfaces": config.can.interfaces,
"bitrate": config.can.bitrate,
"listen_only": config.can.listen_only
})
logger.info("Configuration loaded", extra={
"postgresql_enabled": config.postgresql.enabled,
"postgresql_host": config.postgresql.host if config.postgresql.enabled else None,
"storage_path": config.storage.database_path
})
# Create and start client
_client = OBD2Client(config)
if not _client.start():
logger.error("Failed to start OBD2 client")
sys.exit(1)
logger.info(
"OBD2 Client running",
extra={
"interface": config.can.interface,
"polling_groups": len(config.obd2.polling_groups),
}
)
# Main loop
try:
# Создаем и запускаем CAN Sniffer
# MessageProcessor автоматически инициализируется и используется внутри CANSniffer
sniffer = CANSniffer()
sniffer.start()
logger.info("Application initialized successfully. Reading CAN messages...")
logger.info("Press Ctrl+C to stop")
# Основной цикл - периодически выводим статистику
while True:
time.sleep(10) # Выводим статистику каждые 10 секунд
stats = sniffer.get_stats()
logger.info("Statistics", extra=stats)
last_stats_time = time.time()
while _client.is_running:
time.sleep(1)
# Print stats periodically
if args.stats_interval > 0:
if time.time() - last_stats_time >= args.stats_interval:
last_stats_time = time.time()
print_vehicle_state(_client)
stats = _client.get_stats()
poller_stats = stats.get("poller", {})
logger.info(
"Statistics",
extra={
"requests": poller_stats.get("total_requests", 0),
"responses": poller_stats.get("successful_responses", 0),
"success_rate": poller_stats.get("success_rate", 0),
"avg_latency_ms": poller_stats.get("avg_latency_ms", 0),
}
)
except KeyboardInterrupt:
logger.info("Keyboard interrupt received")
except Exception as e:
logger.error(f"Unexpected error: {e}", exc_info=True)
logger.info("Interrupted by user")
finally:
if sniffer:
sniffer.stop()
logger.info("Application stopped")
if _client:
_client.stop()
if __name__ == '__main__':
main()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,29 @@
"""
OBD2 Protocol Implementation Module.
This module provides ISO 15765-4 (CAN) OBD2 protocol support including:
- Request/Response encoding and decoding
- PID registry with decoding formulas
- Polling engine with configurable groups
- Request/Response correlation
"""
from .protocol import OBD2Request, OBD2Response, OBD2Mode
from .pids import PIDDefinition, PIDRegistry, OBD2Reading
from .transceiver import CANTransceiver
from .response_matcher import ResponseMatcher, PendingRequest
from .poller import OBD2Poller, PollingGroup
__all__ = [
"OBD2Request",
"OBD2Response",
"OBD2Mode",
"PIDDefinition",
"PIDRegistry",
"OBD2Reading",
"CANTransceiver",
"ResponseMatcher",
"PendingRequest",
"OBD2Poller",
"PollingGroup",
]

View File

@@ -0,0 +1,619 @@
"""
OBD2 PID Registry and Decoding.
Contains definitions and decoding formulas for all supported PIDs.
Based on SAE J1979 / ISO 15031-5 standard.
"""
from dataclasses import dataclass, field
from typing import Callable, Optional, Dict, Any, List
from enum import Enum
import time
class PIDCategory(Enum):
"""Categories of OBD2 PIDs."""
ENGINE = "engine"
FUEL = "fuel"
TEMPERATURE = "temperature"
SPEED = "speed"
DIAGNOSTICS = "diagnostics"
VEHICLE_INFO = "vehicle_info"
OXYGEN = "oxygen"
OTHER = "other"
@dataclass
class PIDDefinition:
"""
Definition of an OBD2 PID with decoding formula.
Attributes:
pid: Parameter ID (0x00-0xFF)
name: Human-readable name
short_name: Short identifier for display
unit: Unit of measurement
min_value: Minimum valid value
max_value: Maximum valid value
bytes_count: Number of data bytes expected
decoder: Function to decode raw bytes to value
category: PID category for grouping
description: Detailed description
"""
pid: int
name: str
short_name: str
unit: str
min_value: float
max_value: float
bytes_count: int
decoder: Callable[[bytes], float]
category: PIDCategory = PIDCategory.OTHER
description: str = ""
def decode(self, data: bytes) -> Optional[float]:
"""
Decode raw bytes to value.
Args:
data: Raw data bytes from OBD2 response
Returns:
Decoded value or None if insufficient data
"""
if len(data) < self.bytes_count:
return None
try:
value = self.decoder(data)
# Clamp to valid range
return max(self.min_value, min(self.max_value, value))
except (IndexError, ValueError, ZeroDivisionError):
return None
@property
def pid_hex(self) -> str:
"""PID as hex string."""
return f"0x{self.pid:02X}"
@dataclass
class OBD2Reading:
"""
A decoded OBD2 reading with metadata.
Attributes:
timestamp_ns: Reading timestamp in nanoseconds
pid: Parameter ID
pid_name: Human-readable PID name
raw_data: Original raw bytes
value: Decoded value
unit: Unit of measurement
is_valid: Whether decoding was successful
ecu_id: ECU that provided the response
"""
timestamp_ns: int
pid: int
pid_name: str
raw_data: bytes
value: Optional[float]
unit: str
is_valid: bool
ecu_id: int = 0
@property
def timestamp(self) -> float:
"""Timestamp in seconds."""
return self.timestamp_ns / 1_000_000_000
@property
def pid_hex(self) -> str:
"""PID as hex string."""
return f"0x{self.pid:02X}"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
"timestamp": self.timestamp,
"timestamp_ns": self.timestamp_ns,
"pid": self.pid,
"pid_hex": self.pid_hex,
"pid_name": self.pid_name,
"raw_data": self.raw_data.hex(),
"value": self.value,
"unit": self.unit,
"is_valid": self.is_valid,
"ecu_id": self.ecu_id,
}
# ============================================================================
# PID Decoding Formulas (SAE J1979)
# ============================================================================
def _decode_supported_pids(data: bytes) -> float:
"""Decode supported PIDs bitmap (returns raw 32-bit value)."""
if len(data) >= 4:
return (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]
return 0.0
def _decode_percent(data: bytes) -> float:
"""Decode percentage value: A * 100 / 255."""
return data[0] * 100.0 / 255.0
def _decode_temp(data: bytes) -> float:
"""Decode temperature: A - 40 (°C)."""
return data[0] - 40.0
def _decode_rpm(data: bytes) -> float:
"""Decode engine RPM: (A * 256 + B) / 4."""
return (data[0] * 256 + data[1]) / 4.0
def _decode_speed(data: bytes) -> float:
"""Decode vehicle speed: A (km/h)."""
return float(data[0])
def _decode_timing_advance(data: bytes) -> float:
"""Decode timing advance: A / 2 - 64 (degrees)."""
return data[0] / 2.0 - 64.0
def _decode_maf(data: bytes) -> float:
"""Decode MAF air flow rate: (A * 256 + B) / 100 (g/s)."""
return (data[0] * 256 + data[1]) / 100.0
def _decode_fuel_pressure(data: bytes) -> float:
"""Decode fuel pressure: A * 3 (kPa)."""
return data[0] * 3.0
def _decode_intake_pressure(data: bytes) -> float:
"""Decode intake manifold pressure: A (kPa)."""
return float(data[0])
def _decode_fuel_trim(data: bytes) -> float:
"""Decode fuel trim: (A - 128) * 100 / 128 (%)."""
return (data[0] - 128) * 100.0 / 128.0
def _decode_fuel_system_status(data: bytes) -> float:
"""Decode fuel system status (bitmap)."""
return float(data[0])
def _decode_o2_voltage(data: bytes) -> float:
"""Decode O2 sensor voltage: A / 200 (V)."""
return data[0] / 200.0
def _decode_o2_trim(data: bytes) -> float:
"""Decode O2 sensor trim: (B - 128) * 100 / 128 (%) if B != 0xFF."""
if len(data) >= 2 and data[1] != 0xFF:
return (data[1] - 128) * 100.0 / 128.0
return 0.0
def _decode_runtime(data: bytes) -> float:
"""Decode engine run time: A * 256 + B (seconds)."""
return data[0] * 256 + data[1]
def _decode_distance_mil(data: bytes) -> float:
"""Decode distance traveled with MIL on: A * 256 + B (km)."""
return data[0] * 256 + data[1]
def _decode_fuel_rail_pressure(data: bytes) -> float:
"""Decode fuel rail pressure: (A * 256 + B) * 0.079 (kPa)."""
return (data[0] * 256 + data[1]) * 0.079
def _decode_fuel_rail_pressure_diesel(data: bytes) -> float:
"""Decode fuel rail pressure (diesel): (A * 256 + B) * 10 (kPa)."""
return (data[0] * 256 + data[1]) * 10.0
def _decode_egr_commanded(data: bytes) -> float:
"""Decode commanded EGR: A * 100 / 255 (%)."""
return data[0] * 100.0 / 255.0
def _decode_egr_error(data: bytes) -> float:
"""Decode EGR error: (A - 128) * 100 / 128 (%)."""
return (data[0] - 128) * 100.0 / 128.0
def _decode_evap_purge(data: bytes) -> float:
"""Decode commanded evaporative purge: A * 100 / 255 (%)."""
return data[0] * 100.0 / 255.0
def _decode_fuel_level(data: bytes) -> float:
"""Decode fuel tank level: A * 100 / 255 (%)."""
return data[0] * 100.0 / 255.0
def _decode_warmups(data: bytes) -> float:
"""Decode warm-ups since codes cleared: A (count)."""
return float(data[0])
def _decode_distance_cleared(data: bytes) -> float:
"""Decode distance traveled since codes cleared: A * 256 + B (km)."""
return data[0] * 256 + data[1]
def _decode_barometric_pressure(data: bytes) -> float:
"""Decode barometric pressure: A (kPa)."""
return float(data[0])
def _decode_catalyst_temp(data: bytes) -> float:
"""Decode catalyst temperature: (A * 256 + B) / 10 - 40 (°C)."""
return (data[0] * 256 + data[1]) / 10.0 - 40.0
def _decode_control_module_voltage(data: bytes) -> float:
"""Decode control module voltage: (A * 256 + B) / 1000 (V)."""
return (data[0] * 256 + data[1]) / 1000.0
def _decode_absolute_load(data: bytes) -> float:
"""Decode absolute load value: (A * 256 + B) * 100 / 255 (%)."""
return (data[0] * 256 + data[1]) * 100.0 / 255.0
def _decode_equivalence_ratio(data: bytes) -> float:
"""Decode commanded equivalence ratio: (A * 256 + B) / 32768."""
return (data[0] * 256 + data[1]) / 32768.0
def _decode_relative_throttle(data: bytes) -> float:
"""Decode relative throttle position: A * 100 / 255 (%)."""
return data[0] * 100.0 / 255.0
def _decode_absolute_throttle_b(data: bytes) -> float:
"""Decode absolute throttle position B: A * 100 / 255 (%)."""
return data[0] * 100.0 / 255.0
def _decode_accelerator_pedal_d(data: bytes) -> float:
"""Decode accelerator pedal position D: A * 100 / 255 (%)."""
return data[0] * 100.0 / 255.0
def _decode_accelerator_pedal_e(data: bytes) -> float:
"""Decode accelerator pedal position E: A * 100 / 255 (%)."""
return data[0] * 100.0 / 255.0
def _decode_commanded_throttle(data: bytes) -> float:
"""Decode commanded throttle actuator: A * 100 / 255 (%)."""
return data[0] * 100.0 / 255.0
def _decode_time_mil(data: bytes) -> float:
"""Decode time run with MIL on: A * 256 + B (minutes)."""
return data[0] * 256 + data[1]
def _decode_time_cleared(data: bytes) -> float:
"""Decode time since codes cleared: A * 256 + B (minutes)."""
return data[0] * 256 + data[1]
def _decode_fuel_rate(data: bytes) -> float:
"""Decode engine fuel rate: (A * 256 + B) / 20 (L/h)."""
return (data[0] * 256 + data[1]) / 20.0
def _decode_ethanol_percent(data: bytes) -> float:
"""Decode ethanol fuel percentage: A * 100 / 255 (%)."""
return data[0] * 100.0 / 255.0
def _decode_odometer(data: bytes) -> float:
"""Decode odometer: A*2^24 + B*2^16 + C*2^8 + D / 10 (km)."""
if len(data) >= 4:
value = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3]
return value / 10.0
return 0.0
# ============================================================================
# PID Registry
# ============================================================================
class PIDRegistry:
"""
Registry of all supported OBD2 PIDs.
Provides lookup, decoding, and filtering capabilities for PIDs.
"""
def __init__(self):
self._pids: Dict[int, PIDDefinition] = {}
self._register_standard_pids()
def _register_standard_pids(self) -> None:
"""Register all standard Mode 01 PIDs."""
pids = [
# Supported PIDs
PIDDefinition(0x00, "Supported PIDs 01-20", "PIDs_A", "", 0, 0xFFFFFFFF, 4,
_decode_supported_pids, PIDCategory.DIAGNOSTICS, "Bitmap of supported PIDs 01-20"),
PIDDefinition(0x20, "Supported PIDs 21-40", "PIDs_B", "", 0, 0xFFFFFFFF, 4,
_decode_supported_pids, PIDCategory.DIAGNOSTICS, "Bitmap of supported PIDs 21-40"),
PIDDefinition(0x40, "Supported PIDs 41-60", "PIDs_C", "", 0, 0xFFFFFFFF, 4,
_decode_supported_pids, PIDCategory.DIAGNOSTICS, "Bitmap of supported PIDs 41-60"),
PIDDefinition(0x60, "Supported PIDs 61-80", "PIDs_D", "", 0, 0xFFFFFFFF, 4,
_decode_supported_pids, PIDCategory.DIAGNOSTICS, "Bitmap of supported PIDs 61-80"),
PIDDefinition(0x80, "Supported PIDs 81-A0", "PIDs_E", "", 0, 0xFFFFFFFF, 4,
_decode_supported_pids, PIDCategory.DIAGNOSTICS, "Bitmap of supported PIDs 81-A0"),
PIDDefinition(0xA0, "Supported PIDs A1-C0", "PIDs_F", "", 0, 0xFFFFFFFF, 4,
_decode_supported_pids, PIDCategory.DIAGNOSTICS, "Bitmap of supported PIDs A1-C0"),
PIDDefinition(0xC0, "Supported PIDs C1-E0", "PIDs_G", "", 0, 0xFFFFFFFF, 4,
_decode_supported_pids, PIDCategory.DIAGNOSTICS, "Bitmap of supported PIDs C1-E0"),
# Fuel System
PIDDefinition(0x03, "Fuel System Status", "FuelSys", "", 0, 255, 2,
_decode_fuel_system_status, PIDCategory.FUEL, "Fuel system status"),
PIDDefinition(0x04, "Calculated Engine Load", "Load", "%", 0, 100, 1,
_decode_percent, PIDCategory.ENGINE, "Calculated engine load value"),
# Temperature
PIDDefinition(0x05, "Engine Coolant Temperature", "Coolant", "°C", -40, 215, 1,
_decode_temp, PIDCategory.TEMPERATURE, "Engine coolant temperature"),
PIDDefinition(0x0F, "Intake Air Temperature", "IntakeTemp", "°C", -40, 215, 1,
_decode_temp, PIDCategory.TEMPERATURE, "Intake air temperature"),
PIDDefinition(0x46, "Ambient Air Temperature", "Ambient", "°C", -40, 215, 1,
_decode_temp, PIDCategory.TEMPERATURE, "Ambient air temperature"),
PIDDefinition(0x5C, "Engine Oil Temperature", "OilTemp", "°C", -40, 210, 1,
_decode_temp, PIDCategory.TEMPERATURE, "Engine oil temperature"),
# Fuel Trim
PIDDefinition(0x06, "Short Term Fuel Trim Bank 1", "STFT1", "%", -100, 99.2, 1,
_decode_fuel_trim, PIDCategory.FUEL, "Short term fuel trim - Bank 1"),
PIDDefinition(0x07, "Long Term Fuel Trim Bank 1", "LTFT1", "%", -100, 99.2, 1,
_decode_fuel_trim, PIDCategory.FUEL, "Long term fuel trim - Bank 1"),
PIDDefinition(0x08, "Short Term Fuel Trim Bank 2", "STFT2", "%", -100, 99.2, 1,
_decode_fuel_trim, PIDCategory.FUEL, "Short term fuel trim - Bank 2"),
PIDDefinition(0x09, "Long Term Fuel Trim Bank 2", "LTFT2", "%", -100, 99.2, 1,
_decode_fuel_trim, PIDCategory.FUEL, "Long term fuel trim - Bank 2"),
# Pressure
PIDDefinition(0x0A, "Fuel Pressure", "FuelPres", "kPa", 0, 765, 1,
_decode_fuel_pressure, PIDCategory.FUEL, "Fuel pressure (gauge)"),
PIDDefinition(0x0B, "Intake Manifold Pressure", "MAP", "kPa", 0, 255, 1,
_decode_intake_pressure, PIDCategory.ENGINE, "Intake manifold absolute pressure"),
PIDDefinition(0x33, "Barometric Pressure", "Baro", "kPa", 0, 255, 1,
_decode_barometric_pressure, PIDCategory.OTHER, "Absolute barometric pressure"),
# Engine
PIDDefinition(0x0C, "Engine RPM", "RPM", "rpm", 0, 16383.75, 2,
_decode_rpm, PIDCategory.ENGINE, "Engine speed"),
PIDDefinition(0x0E, "Timing Advance", "Timing", "°", -64, 63.5, 1,
_decode_timing_advance, PIDCategory.ENGINE, "Timing advance relative to #1 cylinder"),
PIDDefinition(0x10, "Mass Air Flow Rate", "MAF", "g/s", 0, 655.35, 2,
_decode_maf, PIDCategory.ENGINE, "Mass air flow sensor rate"),
# Speed & Movement
PIDDefinition(0x0D, "Vehicle Speed", "Speed", "km/h", 0, 255, 1,
_decode_speed, PIDCategory.SPEED, "Vehicle speed"),
PIDDefinition(0x11, "Throttle Position", "Throttle", "%", 0, 100, 1,
_decode_percent, PIDCategory.ENGINE, "Throttle position"),
# O2 Sensors (Bank 1)
PIDDefinition(0x14, "O2 Sensor B1S1 Voltage", "O2_B1S1", "V", 0, 1.275, 2,
_decode_o2_voltage, PIDCategory.OXYGEN, "Oxygen sensor 1, Bank 1 - voltage"),
PIDDefinition(0x15, "O2 Sensor B1S2 Voltage", "O2_B1S2", "V", 0, 1.275, 2,
_decode_o2_voltage, PIDCategory.OXYGEN, "Oxygen sensor 2, Bank 1 - voltage"),
PIDDefinition(0x16, "O2 Sensor B1S3 Voltage", "O2_B1S3", "V", 0, 1.275, 2,
_decode_o2_voltage, PIDCategory.OXYGEN, "Oxygen sensor 3, Bank 1 - voltage"),
PIDDefinition(0x17, "O2 Sensor B1S4 Voltage", "O2_B1S4", "V", 0, 1.275, 2,
_decode_o2_voltage, PIDCategory.OXYGEN, "Oxygen sensor 4, Bank 1 - voltage"),
# O2 Sensors (Bank 2)
PIDDefinition(0x18, "O2 Sensor B2S1 Voltage", "O2_B2S1", "V", 0, 1.275, 2,
_decode_o2_voltage, PIDCategory.OXYGEN, "Oxygen sensor 1, Bank 2 - voltage"),
PIDDefinition(0x19, "O2 Sensor B2S2 Voltage", "O2_B2S2", "V", 0, 1.275, 2,
_decode_o2_voltage, PIDCategory.OXYGEN, "Oxygen sensor 2, Bank 2 - voltage"),
PIDDefinition(0x1A, "O2 Sensor B2S3 Voltage", "O2_B2S3", "V", 0, 1.275, 2,
_decode_o2_voltage, PIDCategory.OXYGEN, "Oxygen sensor 3, Bank 2 - voltage"),
PIDDefinition(0x1B, "O2 Sensor B2S4 Voltage", "O2_B2S4", "V", 0, 1.275, 2,
_decode_o2_voltage, PIDCategory.OXYGEN, "Oxygen sensor 4, Bank 2 - voltage"),
# Runtime & Distance
PIDDefinition(0x1F, "Run Time Since Engine Start", "Runtime", "s", 0, 65535, 2,
_decode_runtime, PIDCategory.ENGINE, "Time since engine start"),
PIDDefinition(0x21, "Distance with MIL On", "DistMIL", "km", 0, 65535, 2,
_decode_distance_mil, PIDCategory.DIAGNOSTICS, "Distance traveled with MIL on"),
PIDDefinition(0x31, "Distance Since Codes Cleared", "DistClr", "km", 0, 65535, 2,
_decode_distance_cleared, PIDCategory.DIAGNOSTICS, "Distance since codes cleared"),
PIDDefinition(0xA6, "Odometer", "Odometer", "km", 0, 429496729.5, 4,
_decode_odometer, PIDCategory.SPEED, "Odometer value"),
# Fuel System Advanced
PIDDefinition(0x22, "Fuel Rail Pressure", "FRP", "kPa", 0, 5177.27, 2,
_decode_fuel_rail_pressure, PIDCategory.FUEL, "Fuel rail pressure (manifold vacuum)"),
PIDDefinition(0x23, "Fuel Rail Pressure (Diesel)", "FRP_D", "kPa", 0, 655350, 2,
_decode_fuel_rail_pressure_diesel, PIDCategory.FUEL, "Fuel rail pressure (diesel/direct inject)"),
PIDDefinition(0x2C, "Commanded EGR", "EGR", "%", 0, 100, 1,
_decode_egr_commanded, PIDCategory.ENGINE, "Commanded EGR"),
PIDDefinition(0x2D, "EGR Error", "EGR_Err", "%", -100, 99.2, 1,
_decode_egr_error, PIDCategory.ENGINE, "EGR error"),
PIDDefinition(0x2E, "Commanded Evaporative Purge", "Evap", "%", 0, 100, 1,
_decode_evap_purge, PIDCategory.FUEL, "Commanded evaporative purge"),
PIDDefinition(0x2F, "Fuel Tank Level", "FuelLvl", "%", 0, 100, 1,
_decode_fuel_level, PIDCategory.FUEL, "Fuel tank level input"),
# Warmups & Codes
PIDDefinition(0x30, "Warm-ups Since Codes Cleared", "Warmups", "", 0, 255, 1,
_decode_warmups, PIDCategory.DIAGNOSTICS, "Warm-ups since codes cleared"),
PIDDefinition(0x4D, "Time with MIL On", "TimeMIL", "min", 0, 65535, 2,
_decode_time_mil, PIDCategory.DIAGNOSTICS, "Time run with MIL on"),
PIDDefinition(0x4E, "Time Since Codes Cleared", "TimeClr", "min", 0, 65535, 2,
_decode_time_cleared, PIDCategory.DIAGNOSTICS, "Time since codes cleared"),
# Catalyst Temperature
PIDDefinition(0x3C, "Catalyst Temp B1S1", "Cat_B1S1", "°C", -40, 6513.5, 2,
_decode_catalyst_temp, PIDCategory.TEMPERATURE, "Catalyst temperature Bank 1, Sensor 1"),
PIDDefinition(0x3D, "Catalyst Temp B2S1", "Cat_B2S1", "°C", -40, 6513.5, 2,
_decode_catalyst_temp, PIDCategory.TEMPERATURE, "Catalyst temperature Bank 2, Sensor 1"),
PIDDefinition(0x3E, "Catalyst Temp B1S2", "Cat_B1S2", "°C", -40, 6513.5, 2,
_decode_catalyst_temp, PIDCategory.TEMPERATURE, "Catalyst temperature Bank 1, Sensor 2"),
PIDDefinition(0x3F, "Catalyst Temp B2S2", "Cat_B2S2", "°C", -40, 6513.5, 2,
_decode_catalyst_temp, PIDCategory.TEMPERATURE, "Catalyst temperature Bank 2, Sensor 2"),
# Voltage & Load
PIDDefinition(0x42, "Control Module Voltage", "ECU_V", "V", 0, 65.535, 2,
_decode_control_module_voltage, PIDCategory.OTHER, "Control module voltage"),
PIDDefinition(0x43, "Absolute Load Value", "AbsLoad", "%", 0, 25700, 2,
_decode_absolute_load, PIDCategory.ENGINE, "Absolute load value"),
PIDDefinition(0x44, "Commanded Equivalence Ratio", "Lambda", "", 0, 2, 2,
_decode_equivalence_ratio, PIDCategory.FUEL, "Commanded air-fuel equivalence ratio"),
# Throttle & Pedal
PIDDefinition(0x45, "Relative Throttle Position", "RelThrot", "%", 0, 100, 1,
_decode_relative_throttle, PIDCategory.ENGINE, "Relative throttle position"),
PIDDefinition(0x47, "Absolute Throttle Position B", "ThrotB", "%", 0, 100, 1,
_decode_absolute_throttle_b, PIDCategory.ENGINE, "Absolute throttle position B"),
PIDDefinition(0x48, "Absolute Throttle Position C", "ThrotC", "%", 0, 100, 1,
_decode_absolute_throttle_b, PIDCategory.ENGINE, "Absolute throttle position C"),
PIDDefinition(0x49, "Accelerator Pedal Position D", "PedalD", "%", 0, 100, 1,
_decode_accelerator_pedal_d, PIDCategory.ENGINE, "Accelerator pedal position D"),
PIDDefinition(0x4A, "Accelerator Pedal Position E", "PedalE", "%", 0, 100, 1,
_decode_accelerator_pedal_e, PIDCategory.ENGINE, "Accelerator pedal position E"),
PIDDefinition(0x4B, "Accelerator Pedal Position F", "PedalF", "%", 0, 100, 1,
_decode_accelerator_pedal_e, PIDCategory.ENGINE, "Accelerator pedal position F"),
PIDDefinition(0x4C, "Commanded Throttle Actuator", "ThrotAct", "%", 0, 100, 1,
_decode_commanded_throttle, PIDCategory.ENGINE, "Commanded throttle actuator"),
# Fuel Rate & Ethanol
PIDDefinition(0x5E, "Engine Fuel Rate", "FuelRate", "L/h", 0, 3276.75, 2,
_decode_fuel_rate, PIDCategory.FUEL, "Engine fuel rate"),
PIDDefinition(0x52, "Ethanol Fuel Percentage", "Ethanol", "%", 0, 100, 1,
_decode_ethanol_percent, PIDCategory.FUEL, "Ethanol fuel percentage"),
]
for pid_def in pids:
self._pids[pid_def.pid] = pid_def
def get(self, pid: int) -> Optional[PIDDefinition]:
"""Get PID definition by ID."""
return self._pids.get(pid)
def decode(self, pid: int, data: bytes) -> Optional[float]:
"""
Decode raw data using PID definition.
Args:
pid: Parameter ID
data: Raw data bytes
Returns:
Decoded value or None if PID unknown or decode failed
"""
pid_def = self.get(pid)
if pid_def is None:
return None
return pid_def.decode(data)
def create_reading(
self,
pid: int,
data: bytes,
timestamp_ns: int,
ecu_id: int = 0
) -> OBD2Reading:
"""
Create an OBD2Reading from raw response data.
Args:
pid: Parameter ID
data: Raw data bytes
timestamp_ns: Reading timestamp
ecu_id: ECU identifier
Returns:
OBD2Reading with decoded value
"""
pid_def = self.get(pid)
if pid_def is None:
return OBD2Reading(
timestamp_ns=timestamp_ns,
pid=pid,
pid_name=f"Unknown_0x{pid:02X}",
raw_data=data,
value=None,
unit="",
is_valid=False,
ecu_id=ecu_id
)
value = pid_def.decode(data)
return OBD2Reading(
timestamp_ns=timestamp_ns,
pid=pid,
pid_name=pid_def.name,
raw_data=data,
value=value,
unit=pid_def.unit,
is_valid=value is not None,
ecu_id=ecu_id
)
def get_by_category(self, category: PIDCategory) -> List[PIDDefinition]:
"""Get all PIDs in a category."""
return [p for p in self._pids.values() if p.category == category]
def get_supported_pids(self, bitmap: int, base_pid: int = 0x00) -> List[int]:
"""
Parse supported PIDs bitmap.
Args:
bitmap: 32-bit bitmap from PID 0x00/0x20/0x40/etc response
base_pid: Base PID (0x00, 0x20, 0x40, etc.)
Returns:
List of supported PID numbers
"""
supported = []
for i in range(32):
if bitmap & (1 << (31 - i)):
supported.append(base_pid + i + 1)
return supported
def all_pids(self) -> List[PIDDefinition]:
"""Get all registered PIDs."""
return list(self._pids.values())
def __contains__(self, pid: int) -> bool:
return pid in self._pids
def __len__(self) -> int:
return len(self._pids)
# Global registry instance
_registry: Optional[PIDRegistry] = None
def get_pid_registry() -> PIDRegistry:
"""Get global PID registry instance."""
global _registry
if _registry is None:
_registry = PIDRegistry()
return _registry

View File

@@ -0,0 +1,447 @@
"""
OBD2 Polling Engine.
Manages periodic polling of OBD2 PIDs with configurable groups
and intervals. Coordinates with transceiver and response matcher.
"""
import threading
import time
from dataclasses import dataclass, field
from typing import Optional, Callable, Dict, Any, List, Set
from enum import Enum
from logger import get_logger
from .protocol import OBD2Request, OBD2Response, OBD2Mode
from .pids import OBD2Reading, get_pid_registry
from .transceiver import CANTransceiver
from .response_matcher import ResponseMatcher
logger = get_logger(__name__)
class PollerState(Enum):
"""OBD2 Poller states."""
STOPPED = "stopped"
DISCOVERING = "discovering"
RUNNING = "running"
PAUSED = "paused"
ERROR = "error"
@dataclass
class PollingGroup:
"""
A group of PIDs polled at the same interval.
Attributes:
name: Group identifier
interval_ms: Polling interval in milliseconds
pids: List of PIDs to poll (hex strings like "0C", "0D")
enabled: Whether group is active
priority: Lower = higher priority
"""
name: str
interval_ms: int
pids: List[str]
enabled: bool = True
priority: int = 0
def get_pid_ints(self) -> List[int]:
"""Convert hex PID strings to integers."""
result = []
for pid_str in self.pids:
try:
result.append(int(pid_str, 16))
except ValueError:
logger.warning(f"Invalid PID format: {pid_str}")
return result
@dataclass
class GroupState:
"""Runtime state for a polling group."""
group: PollingGroup
last_poll_time: float = 0.0
polls_completed: int = 0
current_pid_index: int = 0
@property
def pids(self) -> List[int]:
"""Get PIDs as integers."""
return self.group.get_pid_ints()
@property
def next_poll_time(self) -> float:
"""Calculate when next poll should occur."""
return self.last_poll_time + (self.group.interval_ms / 1000.0)
@property
def is_due(self) -> bool:
"""Check if group is due for polling."""
return time.time() >= self.next_poll_time
def get_next_pid(self) -> Optional[int]:
"""Get next PID to poll (round-robin)."""
pids = self.pids
if not pids:
return None
pid = pids[self.current_pid_index]
self.current_pid_index = (self.current_pid_index + 1) % len(pids)
return pid
@dataclass
class PollerStats:
"""Statistics for the OBD2 poller."""
total_requests: int = 0
successful_responses: int = 0
timeouts: int = 0
ecu_connected: bool = False
supported_pids: Set[int] = field(default_factory=set)
last_response_time: float = 0.0
polling_active: bool = False
class OBD2Poller:
"""
OBD2 Polling Engine.
Manages periodic polling of OBD2 PIDs with configurable groups.
Handles ECU discovery, supported PID detection, and adaptive polling.
Typical usage:
poller = OBD2Poller(transceiver, matcher)
poller.add_group(PollingGroup("fast", 100, ["0C", "0D"]))
poller.add_group(PollingGroup("slow", 1000, ["05", "2F"]))
poller.start()
"""
def __init__(
self,
transceiver: CANTransceiver,
matcher: ResponseMatcher,
reading_callback: Optional[Callable[[OBD2Reading], None]] = None,
auto_discover: bool = True,
):
"""
Initialize OBD2 poller.
Args:
transceiver: CAN transceiver for sending/receiving
matcher: Response matcher for request correlation
reading_callback: Callback for decoded readings
auto_discover: Auto-discover supported PIDs on start
"""
self._transceiver = transceiver
self._matcher = matcher
self._reading_callback = reading_callback
self._auto_discover = auto_discover
self._groups: Dict[str, GroupState] = {}
self._state = PollerState.STOPPED
self._stats = PollerStats()
self._stats_lock = threading.Lock()
self._running = False
self._poll_thread: Optional[threading.Thread] = None
self._pid_registry = get_pid_registry()
# Connect matcher callbacks
self._matcher.set_reading_callback(self._on_reading)
self._matcher.set_retry_callback(self._on_retry)
@property
def state(self) -> PollerState:
"""Get current poller state."""
return self._state
@property
def is_running(self) -> bool:
"""Check if poller is actively polling."""
return self._running and self._state == PollerState.RUNNING
def add_group(self, group: PollingGroup) -> None:
"""
Add a polling group.
Args:
group: Polling group to add
"""
self._groups[group.name] = GroupState(group=group)
logger.info(
f"Added polling group: {group.name}",
extra={
"interval_ms": group.interval_ms,
"pids": group.pids,
}
)
def remove_group(self, name: str) -> None:
"""Remove a polling group by name."""
if name in self._groups:
del self._groups[name]
logger.info(f"Removed polling group: {name}")
def enable_group(self, name: str, enabled: bool = True) -> None:
"""Enable or disable a polling group."""
if name in self._groups:
self._groups[name].group.enabled = enabled
logger.info(f"Polling group {name} {'enabled' if enabled else 'disabled'}")
def start(self) -> bool:
"""
Start the polling engine.
Returns:
True if started successfully
"""
if self._running:
logger.warning("Poller already running")
return True
if not self._transceiver.is_running:
logger.error("Cannot start poller - transceiver not running")
return False
self._running = True
# Start matcher
self._matcher.start()
# Connect transceiver response callback
self._transceiver.set_response_callback(self._on_response)
# Start polling thread
self._poll_thread = threading.Thread(
target=self._poll_loop,
name="OBD2-Poller",
daemon=True
)
self._poll_thread.start()
logger.info("OBD2 Poller started")
return True
def stop(self) -> None:
"""Stop the polling engine."""
if not self._running:
return
logger.info("Stopping OBD2 poller")
self._running = False
self._state = PollerState.STOPPED
if self._poll_thread and self._poll_thread.is_alive():
self._poll_thread.join(timeout=2.0)
self._matcher.stop()
with self._stats_lock:
self._stats.polling_active = False
logger.info("OBD2 Poller stopped")
def pause(self) -> None:
"""Pause polling (can be resumed)."""
if self._state == PollerState.RUNNING:
self._state = PollerState.PAUSED
with self._stats_lock:
self._stats.polling_active = False
logger.info("OBD2 Poller paused")
def resume(self) -> None:
"""Resume paused polling."""
if self._state == PollerState.PAUSED:
self._state = PollerState.RUNNING
with self._stats_lock:
self._stats.polling_active = True
logger.info("OBD2 Poller resumed")
def request_pid(
self,
pid: int,
callback: Optional[Callable[[OBD2Reading], None]] = None,
) -> bool:
"""
Request a single PID (one-shot).
Args:
pid: PID to request
callback: Optional callback for this specific request
Returns:
True if request was sent
"""
request = OBD2Request(mode=OBD2Mode.CURRENT_DATA, pid=pid)
if self._transceiver.send_request(request):
self._matcher.register_request(request, callback)
with self._stats_lock:
self._stats.total_requests += 1
return True
return False
def discover_supported_pids(self) -> None:
"""
Discover supported PIDs from ECU.
Queries PID 0x00, 0x20, 0x40, etc. to get supported PID bitmaps.
"""
logger.info("Starting PID discovery")
self._state = PollerState.DISCOVERING
discovery_pids = [0x00, 0x20, 0x40, 0x60, 0x80, 0xA0, 0xC0]
for pid in discovery_pids:
self.request_pid(pid, self._on_discovery_response)
time.sleep(0.05) # Small delay between requests
def get_stats(self) -> Dict[str, Any]:
"""Get poller statistics."""
with self._stats_lock:
matcher_stats = self._matcher.get_stats()
return {
"state": self._state.value,
"total_requests": self._stats.total_requests,
"successful_responses": self._stats.successful_responses,
"timeouts": matcher_stats["timeouts"],
"success_rate": matcher_stats["success_rate"],
"avg_latency_ms": matcher_stats["avg_latency_ms"],
"ecu_connected": self._stats.ecu_connected,
"supported_pids_count": len(self._stats.supported_pids),
"last_response_time": self._stats.last_response_time,
"polling_active": self._stats.polling_active,
"groups": {
name: {
"enabled": gs.group.enabled,
"interval_ms": gs.group.interval_ms,
"polls_completed": gs.polls_completed,
}
for name, gs in self._groups.items()
},
}
def get_supported_pids(self) -> Set[int]:
"""Get set of discovered supported PIDs."""
with self._stats_lock:
return self._stats.supported_pids.copy()
def _poll_loop(self) -> None:
"""Main polling loop."""
logger.debug("Poll loop started")
# Auto-discover if enabled
if self._auto_discover:
self.discover_supported_pids()
time.sleep(0.5) # Wait for discovery responses
self._state = PollerState.RUNNING
with self._stats_lock:
self._stats.polling_active = True
while self._running:
try:
if self._state == PollerState.PAUSED:
time.sleep(0.1)
continue
# Poll each group that's due
for name, group_state in self._groups.items():
if not group_state.group.enabled:
continue
if group_state.is_due:
self._poll_group(group_state)
# Small sleep to prevent busy loop
time.sleep(0.001)
except Exception as e:
logger.error(f"Poll loop error: {e}")
self._state = PollerState.ERROR
logger.debug("Poll loop stopped")
def _poll_group(self, group_state: GroupState) -> None:
"""Poll the next PID in a group."""
pid = group_state.get_next_pid()
if pid is None:
return
# Skip if PID not in supported set (if we have discovery data)
with self._stats_lock:
if self._stats.supported_pids and pid not in self._stats.supported_pids:
# Skip unsupported PIDs silently
return
request = OBD2Request(mode=OBD2Mode.CURRENT_DATA, pid=pid)
if self._transceiver.send_request(request):
self._matcher.register_request(request)
group_state.last_poll_time = time.time()
with self._stats_lock:
self._stats.total_requests += 1
# If we've polled all PIDs, increment polls_completed
if group_state.current_pid_index == 0:
group_state.polls_completed += 1
def _on_response(self, response: OBD2Response) -> None:
"""Handle response from transceiver."""
# Forward to matcher for correlation
self._matcher.match_response(response)
def _on_reading(self, reading: OBD2Reading) -> None:
"""Handle decoded reading from matcher."""
with self._stats_lock:
self._stats.successful_responses += 1
self._stats.last_response_time = time.time()
self._stats.ecu_connected = True
# Forward to user callback
if self._reading_callback:
try:
self._reading_callback(reading)
except Exception as e:
logger.error(f"Reading callback error: {e}")
def _on_retry(self, request: OBD2Request) -> None:
"""Handle retry request from matcher."""
self._transceiver.send_request(request)
self._matcher.register_request(request)
def _on_discovery_response(self, reading: OBD2Reading) -> None:
"""Handle PID discovery response."""
if reading.value is None:
return
# Parse supported PIDs bitmap
bitmap = int(reading.value)
base_pid = reading.pid
supported = self._pid_registry.get_supported_pids(bitmap, base_pid)
with self._stats_lock:
self._stats.supported_pids.update(supported)
logger.info(
f"Discovered PIDs from 0x{base_pid:02X}",
extra={"count": len(supported), "pids": [f"0x{p:02X}" for p in supported[:10]]}
)
# Also forward to regular callback if set
if self._reading_callback:
self._reading_callback(reading)
def set_reading_callback(
self,
callback: Callable[[OBD2Reading], None]
) -> None:
"""Set callback for all decoded readings."""
self._reading_callback = callback

View File

@@ -0,0 +1,316 @@
"""
OBD2 Protocol Encoding/Decoding.
Implements ISO 15765-4 (CAN) protocol for OBD2 communication.
Supports Single Frame and Multi Frame messages.
"""
from dataclasses import dataclass, field
from enum import IntEnum
from typing import Optional, List
import time
from can_frame import CANFrame
class OBD2Mode(IntEnum):
"""OBD2 Service Modes (ISO 15031-5)."""
CURRENT_DATA = 0x01 # Mode 01 - Current powertrain diagnostic data
FREEZE_FRAME = 0x02 # Mode 02 - Freeze frame data
STORED_DTCS = 0x03 # Mode 03 - Stored Diagnostic Trouble Codes
CLEAR_DTCS = 0x04 # Mode 04 - Clear DTCs and stored values
O2_MONITORING = 0x05 # Mode 05 - Oxygen sensor monitoring
ONBOARD_MONITORING = 0x06 # Mode 06 - On-board monitoring test results
PENDING_DTCS = 0x07 # Mode 07 - Pending Diagnostic Trouble Codes
CONTROL_OPERATION = 0x08 # Mode 08 - Control operation of on-board system
VEHICLE_INFO = 0x09 # Mode 09 - Request vehicle information
PERMANENT_DTCS = 0x0A # Mode 0A - Permanent DTCs
class FrameType(IntEnum):
"""ISO-TP Frame Types for Multi Frame support."""
SINGLE = 0x00 # Single Frame (SF) - complete message in one frame
FIRST = 0x10 # First Frame (FF) - first of multi-frame sequence
CONSECUTIVE = 0x20 # Consecutive Frame (CF) - continuation frames
FLOW_CONTROL = 0x30 # Flow Control (FC) - flow control frame
# Standard OBD2 CAN IDs
OBD2_REQUEST_BROADCAST = 0x7DF # Broadcast request (all ECUs)
OBD2_REQUEST_ECU_BASE = 0x7E0 # Physical addressing base (ECU 0)
OBD2_RESPONSE_ECU_BASE = 0x7E8 # Response base (ECU 0 responds on 0x7E8)
OBD2_RESPONSE_ECU_END = 0x7EF # Response end (ECU 7 responds on 0x7EF)
@dataclass(frozen=True)
class OBD2Request:
"""
Represents an OBD2 request message.
Encapsulates the mode and PID for an OBD2 query, with methods
to convert to CAN frames for transmission.
Attributes:
mode: OBD2 service mode (e.g., 0x01 for current data)
pid: Parameter ID to request (0x00-0xFF)
target_id: CAN ID to send request to (default: broadcast 0x7DF)
timestamp_ns: Request creation timestamp in nanoseconds
"""
mode: int
pid: int
target_id: int = OBD2_REQUEST_BROADCAST
timestamp_ns: int = field(default_factory=lambda: time.time_ns())
def __post_init__(self):
"""Validate request parameters."""
if not 0x01 <= self.mode <= 0x0A:
raise ValueError(f"Invalid OBD2 mode: 0x{self.mode:02X}")
if not 0x00 <= self.pid <= 0xFF:
raise ValueError(f"Invalid PID: 0x{self.pid:02X}")
def to_can_frame(self) -> CANFrame:
"""
Convert OBD2 request to CAN frame.
Single Frame format (ISO 15765-2):
Byte 0: PCI (length)
Byte 1: Mode (Service ID)
Byte 2: PID
Bytes 3-7: Padding (0x00 or 0x55/0xAA)
Returns:
CANFrame ready for transmission
"""
# Single Frame: length = 2 bytes (mode + pid)
data = bytes([
0x02, # PCI: Single Frame, length = 2
self.mode, # Service ID (Mode)
self.pid, # PID
0x00, 0x00, 0x00, 0x00, 0x00 # Padding
])
return CANFrame(
ts_ns=self.timestamp_ns,
bus="obd2",
can_id=self.target_id,
is_extended=False,
dlc=8,
data=data
)
@property
def request_id(self) -> str:
"""Unique identifier for request correlation."""
return f"{self.mode:02X}_{self.pid:02X}"
def __repr__(self) -> str:
return f"OBD2Request(mode=0x{self.mode:02X}, pid=0x{self.pid:02X}, target=0x{self.target_id:03X})"
@dataclass
class OBD2Response:
"""
Represents an OBD2 response message.
Parses CAN frames containing OBD2 responses and extracts
the mode, PID, and data bytes.
Attributes:
mode: Response mode (request mode + 0x40)
pid: Parameter ID
data: Raw data bytes from response
source_id: CAN ID of responding ECU
timestamp_ns: Response reception timestamp
is_multiframe: Whether this is part of a multi-frame sequence
"""
mode: int
pid: int
data: bytes
source_id: int
timestamp_ns: int
is_multiframe: bool = False
@classmethod
def from_can_frame(cls, frame: CANFrame) -> Optional["OBD2Response"]:
"""
Parse OBD2 response from CAN frame.
Args:
frame: Received CAN frame
Returns:
OBD2Response if valid OBD2 response, None otherwise
"""
# Validate source ID is in OBD2 response range
if not OBD2_RESPONSE_ECU_BASE <= frame.can_id <= OBD2_RESPONSE_ECU_END:
return None
if len(frame.data) < 3:
return None
pci = frame.data[0]
frame_type = pci & 0xF0
if frame_type == FrameType.SINGLE:
# Single Frame response
length = pci & 0x0F
if length < 2:
return None
mode = frame.data[1]
pid = frame.data[2]
# Verify it's a positive response (mode + 0x40)
if not (mode & 0x40):
return None
# Extract data bytes (skip PCI, mode, PID)
data_length = length - 2 # Subtract mode and PID
data = frame.data[3:3 + data_length]
return cls(
mode=mode & 0x3F, # Remove response flag to get original mode
pid=pid,
data=bytes(data),
source_id=frame.can_id,
timestamp_ns=frame.ts_ns,
is_multiframe=False
)
elif frame_type == FrameType.FIRST:
# First Frame of multi-frame response
# Total length is in PCI byte 0 (lower nibble) + byte 1
total_length = ((pci & 0x0F) << 8) | frame.data[1]
mode = frame.data[2]
pid = frame.data[3]
if not (mode & 0x40):
return None
# First 4 data bytes are in this frame
data = frame.data[4:8]
return cls(
mode=mode & 0x3F,
pid=pid,
data=bytes(data),
source_id=frame.can_id,
timestamp_ns=frame.ts_ns,
is_multiframe=True
)
return None
@property
def response_id(self) -> str:
"""Unique identifier matching request_id."""
return f"{self.mode:02X}_{self.pid:02X}"
@property
def ecu_index(self) -> int:
"""ECU index (0-7) based on response CAN ID."""
return self.source_id - OBD2_RESPONSE_ECU_BASE
def __repr__(self) -> str:
return (
f"OBD2Response(mode=0x{self.mode:02X}, pid=0x{self.pid:02X}, "
f"data={self.data.hex()}, ecu={self.ecu_index})"
)
@dataclass
class MultiFrameBuffer:
"""
Buffer for assembling multi-frame OBD2 responses.
Collects First Frame and Consecutive Frames until
the complete message is assembled.
"""
source_id: int
mode: int
pid: int
total_length: int
data: bytearray
expected_sequence: int = 1
timestamp_ns: int = field(default_factory=lambda: time.time_ns())
def add_consecutive_frame(self, frame: CANFrame) -> bool:
"""
Add a Consecutive Frame to the buffer.
Args:
frame: Consecutive Frame to add
Returns:
True if frame was valid and added
"""
if frame.can_id != self.source_id:
return False
pci = frame.data[0]
if (pci & 0xF0) != FrameType.CONSECUTIVE:
return False
sequence = pci & 0x0F
if sequence != (self.expected_sequence & 0x0F):
return False
# Add data bytes (up to 7 per CF)
remaining = self.total_length - len(self.data)
bytes_to_add = min(7, remaining)
self.data.extend(frame.data[1:1 + bytes_to_add])
self.expected_sequence += 1
return True
@property
def is_complete(self) -> bool:
"""Check if all data has been received."""
return len(self.data) >= self.total_length
def to_response(self) -> OBD2Response:
"""Convert completed buffer to OBD2Response."""
return OBD2Response(
mode=self.mode,
pid=self.pid,
data=bytes(self.data[:self.total_length]),
source_id=self.source_id,
timestamp_ns=self.timestamp_ns,
is_multiframe=True
)
def create_flow_control_frame(target_id: int) -> CANFrame:
"""
Create Flow Control frame for multi-frame reception.
Sends CTS (Clear To Send) to allow ECU to continue
sending Consecutive Frames.
Args:
target_id: ECU request ID (0x7E0-0x7E7)
Returns:
Flow Control CAN frame
"""
# FC frame: CTS, Block Size = 0 (no limit), ST_min = 0 (no delay)
data = bytes([
0x30, # FC, CTS
0x00, # Block Size = unlimited
0x00, # ST_min = 0ms
0x00, 0x00, 0x00, 0x00, 0x00
])
return CANFrame(
ts_ns=time.time_ns(),
bus="obd2",
can_id=target_id,
is_extended=False,
dlc=8,
data=data
)

View File

@@ -0,0 +1,342 @@
"""
OBD2 Request/Response Matcher.
Correlates OBD2 requests with their responses, handling timeouts
and providing metrics for latency and success rate.
"""
import threading
import time
from dataclasses import dataclass, field
from typing import Optional, Callable, Dict, Any, List
from collections import OrderedDict
from enum import Enum
from logger import get_logger
from .protocol import OBD2Request, OBD2Response
from .pids import OBD2Reading, get_pid_registry
logger = get_logger(__name__)
class RequestState(Enum):
"""State of a pending request."""
PENDING = "pending"
COMPLETED = "completed"
TIMEOUT = "timeout"
ERROR = "error"
@dataclass
class PendingRequest:
"""
Represents a pending OBD2 request awaiting response.
Attributes:
request: Original OBD2 request
sent_time: Time when request was sent
timeout_ms: Timeout in milliseconds
callback: Optional callback when response received
state: Current request state
response: Received response (if any)
retry_count: Number of retries attempted
"""
request: OBD2Request
sent_time: float = field(default_factory=time.time)
timeout_ms: int = 100
callback: Optional[Callable[[OBD2Reading], None]] = None
state: RequestState = RequestState.PENDING
response: Optional[OBD2Response] = None
retry_count: int = 0
max_retries: int = 2
@property
def is_expired(self) -> bool:
"""Check if request has timed out."""
elapsed_ms = (time.time() - self.sent_time) * 1000
return elapsed_ms > self.timeout_ms
@property
def latency_ms(self) -> Optional[float]:
"""Get response latency in milliseconds."""
if self.response is None:
return None
return (self.response.timestamp_ns / 1_000_000) - (self.sent_time * 1000)
@property
def can_retry(self) -> bool:
"""Check if request can be retried."""
return self.retry_count < self.max_retries
@dataclass
class MatcherStats:
"""Statistics for the response matcher."""
requests_sent: int = 0
responses_matched: int = 0
timeouts: int = 0
retries: int = 0
errors: int = 0
total_latency_ms: float = 0.0
@property
def success_rate(self) -> float:
"""Calculate success rate percentage."""
if self.requests_sent == 0:
return 0.0
return (self.responses_matched / self.requests_sent) * 100.0
@property
def avg_latency_ms(self) -> float:
"""Calculate average latency."""
if self.responses_matched == 0:
return 0.0
return self.total_latency_ms / self.responses_matched
class ResponseMatcher:
"""
Matches OBD2 requests with responses.
Tracks pending requests, handles timeouts and retries,
provides callback notification and metrics.
Thread-safe for concurrent request/response handling.
"""
def __init__(
self,
timeout_ms: int = 100,
max_retries: int = 2,
reading_callback: Optional[Callable[[OBD2Reading], None]] = None,
retry_callback: Optional[Callable[[OBD2Request], None]] = None,
):
"""
Initialize response matcher.
Args:
timeout_ms: Default request timeout in milliseconds
max_retries: Maximum retry attempts per request
reading_callback: Callback for decoded readings
retry_callback: Callback to retry failed requests
"""
self.timeout_ms = timeout_ms
self.max_retries = max_retries
self._reading_callback = reading_callback
self._retry_callback = retry_callback
# Pending requests by request_id (mode_pid)
self._pending: OrderedDict[str, PendingRequest] = OrderedDict()
self._lock = threading.Lock()
self._stats = MatcherStats()
self._stats_lock = threading.Lock()
self._pid_registry = get_pid_registry()
# Cleanup thread
self._running = False
self._cleanup_thread: Optional[threading.Thread] = None
def start(self) -> None:
"""Start the cleanup thread."""
if self._running:
return
self._running = True
self._cleanup_thread = threading.Thread(
target=self._cleanup_loop,
name="Matcher-Cleanup",
daemon=True
)
self._cleanup_thread.start()
logger.debug("Response matcher started")
def stop(self) -> None:
"""Stop the cleanup thread."""
self._running = False
if self._cleanup_thread and self._cleanup_thread.is_alive():
self._cleanup_thread.join(timeout=2.0)
logger.debug("Response matcher stopped")
def register_request(
self,
request: OBD2Request,
callback: Optional[Callable[[OBD2Reading], None]] = None,
) -> None:
"""
Register a sent request for response matching.
Args:
request: The OBD2 request that was sent
callback: Optional per-request callback
"""
pending = PendingRequest(
request=request,
sent_time=time.time(),
timeout_ms=self.timeout_ms,
callback=callback,
max_retries=self.max_retries,
)
with self._lock:
# Replace if already exists (retry case)
self._pending[request.request_id] = pending
with self._stats_lock:
self._stats.requests_sent += 1
def match_response(self, response: OBD2Response) -> Optional[OBD2Reading]:
"""
Match a response to a pending request.
Args:
response: Received OBD2 response
Returns:
OBD2Reading if matched, None otherwise
"""
with self._lock:
pending = self._pending.pop(response.response_id, None)
if pending is None:
# Response without pending request (unsolicited or late)
logger.debug(
f"Unmatched response: {response.response_id}",
extra={"source_id": hex(response.source_id)}
)
return None
# Mark as completed
pending.state = RequestState.COMPLETED
pending.response = response
# Update stats
latency = pending.latency_ms
with self._stats_lock:
self._stats.responses_matched += 1
if latency:
self._stats.total_latency_ms += latency
# Create reading
reading = self._pid_registry.create_reading(
pid=response.pid,
data=response.data,
timestamp_ns=response.timestamp_ns,
ecu_id=response.ecu_index,
)
# Notify callbacks
if pending.callback:
try:
pending.callback(reading)
except Exception as e:
logger.error(f"Request callback error: {e}")
if self._reading_callback:
try:
self._reading_callback(reading)
except Exception as e:
logger.error(f"Reading callback error: {e}")
return reading
def get_pending_count(self) -> int:
"""Get number of pending requests."""
with self._lock:
return len(self._pending)
def get_stats(self) -> Dict[str, Any]:
"""Get matcher statistics."""
with self._stats_lock:
return {
"requests_sent": self._stats.requests_sent,
"responses_matched": self._stats.responses_matched,
"timeouts": self._stats.timeouts,
"retries": self._stats.retries,
"errors": self._stats.errors,
"success_rate": round(self._stats.success_rate, 2),
"avg_latency_ms": round(self._stats.avg_latency_ms, 2),
"pending_count": self.get_pending_count(),
}
def clear_pending(self) -> None:
"""Clear all pending requests."""
with self._lock:
self._pending.clear()
def _cleanup_loop(self) -> None:
"""Cleanup loop for handling timeouts."""
logger.debug("Cleanup loop started")
while self._running:
try:
self._check_timeouts()
time.sleep(0.01) # 10ms check interval
except Exception as e:
logger.error(f"Cleanup loop error: {e}")
logger.debug("Cleanup loop stopped")
def _check_timeouts(self) -> None:
"""Check and handle timed out requests."""
expired: List[PendingRequest] = []
with self._lock:
# Find expired requests
for request_id, pending in list(self._pending.items()):
if pending.is_expired:
expired.append(pending)
del self._pending[request_id]
# Handle expired requests outside lock
for pending in expired:
self._handle_timeout(pending)
def _handle_timeout(self, pending: PendingRequest) -> None:
"""Handle a timed out request."""
pending.state = RequestState.TIMEOUT
with self._stats_lock:
self._stats.timeouts += 1
# Check if we should retry
if pending.can_retry and self._retry_callback:
pending.retry_count += 1
with self._stats_lock:
self._stats.retries += 1
logger.debug(
f"Retrying request {pending.request.request_id} "
f"(attempt {pending.retry_count}/{pending.max_retries})"
)
try:
self._retry_callback(pending.request)
except Exception as e:
logger.error(f"Retry callback error: {e}")
with self._stats_lock:
self._stats.errors += 1
else:
logger.debug(
f"Request timeout: {pending.request.request_id}",
extra={"retries": pending.retry_count}
)
def set_reading_callback(
self,
callback: Callable[[OBD2Reading], None]
) -> None:
"""Set callback for all decoded readings."""
self._reading_callback = callback
def set_retry_callback(
self,
callback: Callable[[OBD2Request], None]
) -> None:
"""Set callback for retry requests."""
self._retry_callback = callback

View File

@@ -0,0 +1,370 @@
"""
CAN Transceiver for OBD2 Communication.
Provides unified TX/RX interface for sending OBD2 requests
and receiving responses via SocketCAN.
"""
import threading
import time
from dataclasses import dataclass, field
from typing import Optional, Callable, Dict, Any
from queue import Queue, Empty
from enum import Enum
try:
import can
except ImportError:
can = None # type: ignore
from can_frame import CANFrame
from logger import get_logger
from .protocol import (
OBD2Request,
OBD2Response,
OBD2_RESPONSE_ECU_BASE,
OBD2_RESPONSE_ECU_END,
)
logger = get_logger(__name__)
class TransceiverState(Enum):
"""CAN Transceiver states."""
STOPPED = "stopped"
STARTING = "starting"
RUNNING = "running"
ERROR = "error"
@dataclass
class TransceiverStats:
"""Statistics for the CAN transceiver."""
tx_count: int = 0
rx_count: int = 0
tx_errors: int = 0
rx_errors: int = 0
last_tx_time: float = 0.0
last_rx_time: float = 0.0
bus_errors: int = 0
class CANTransceiver:
"""
Unified CAN TX/RX interface for OBD2 communication.
Handles sending OBD2 requests and receiving responses via SocketCAN.
Provides callback-based response notification and statistics.
Attributes:
interface: CAN interface name (e.g., "can0", "vcan0")
bitrate: CAN bus bitrate (default: 500000 for OBD2)
state: Current transceiver state
"""
def __init__(
self,
interface: str = "can0",
bitrate: int = 500000,
response_callback: Optional[Callable[[OBD2Response], None]] = None,
):
"""
Initialize CAN transceiver.
Args:
interface: CAN interface name
bitrate: CAN bus bitrate
response_callback: Callback for received OBD2 responses
"""
self.interface = interface
self.bitrate = bitrate
self._response_callback = response_callback
self._bus: Optional[can.Bus] = None
self._rx_thread: Optional[threading.Thread] = None
self._running = False
self._state = TransceiverState.STOPPED
self._stats = TransceiverStats()
self._stats_lock = threading.Lock()
# TX queue for thread-safe sending
self._tx_queue: Queue[CANFrame] = Queue(maxsize=100)
self._tx_thread: Optional[threading.Thread] = None
@property
def state(self) -> TransceiverState:
"""Get current transceiver state."""
return self._state
@property
def is_running(self) -> bool:
"""Check if transceiver is running."""
return self._running and self._state == TransceiverState.RUNNING
def start(self) -> bool:
"""
Start the CAN transceiver.
Initializes SocketCAN bus, starts RX/TX threads.
Returns:
True if started successfully
"""
if self._running:
logger.warning("Transceiver already running")
return True
self._state = TransceiverState.STARTING
try:
# Initialize CAN bus
self._bus = can.Bus(
channel=self.interface,
bustype="socketcan",
bitrate=self.bitrate,
receive_own_messages=False,
)
# Set up filters for OBD2 responses only
self._bus.set_filters([
{
"can_id": OBD2_RESPONSE_ECU_BASE,
"can_mask": 0x7F8, # Match 0x7E8-0x7EF
"extended": False
}
])
self._running = True
# Start RX thread
self._rx_thread = threading.Thread(
target=self._rx_loop,
name="CAN-RX",
daemon=True
)
self._rx_thread.start()
# Start TX thread
self._tx_thread = threading.Thread(
target=self._tx_loop,
name="CAN-TX",
daemon=True
)
self._tx_thread.start()
self._state = TransceiverState.RUNNING
logger.info(
"CAN Transceiver started",
extra={
"interface": self.interface,
"bitrate": self.bitrate,
}
)
return True
except Exception as e:
self._state = TransceiverState.ERROR
logger.error(
"Failed to start CAN transceiver",
extra={"error": str(e), "interface": self.interface}
)
return False
def stop(self) -> None:
"""Stop the CAN transceiver."""
if not self._running:
return
logger.info("Stopping CAN transceiver")
self._running = False
# Wait for threads to finish
if self._rx_thread and self._rx_thread.is_alive():
self._rx_thread.join(timeout=2.0)
if self._tx_thread and self._tx_thread.is_alive():
self._tx_thread.join(timeout=2.0)
# Close CAN bus
if self._bus:
try:
self._bus.shutdown()
except Exception as e:
logger.warning(f"Error closing CAN bus: {e}")
self._bus = None
self._state = TransceiverState.STOPPED
logger.info("CAN Transceiver stopped")
def send_request(self, request: OBD2Request) -> bool:
"""
Send an OBD2 request.
Args:
request: OBD2Request to send
Returns:
True if request was queued successfully
"""
if not self.is_running:
logger.warning("Cannot send - transceiver not running")
return False
frame = request.to_can_frame()
try:
self._tx_queue.put_nowait(frame)
return True
except Exception as e:
logger.warning(f"TX queue full, dropping request: {e}")
with self._stats_lock:
self._stats.tx_errors += 1
return False
def send_frame(self, frame: CANFrame) -> bool:
"""
Send a raw CAN frame.
Args:
frame: CANFrame to send
Returns:
True if frame was queued successfully
"""
if not self.is_running:
return False
try:
self._tx_queue.put_nowait(frame)
return True
except Exception:
with self._stats_lock:
self._stats.tx_errors += 1
return False
def set_response_callback(
self,
callback: Callable[[OBD2Response], None]
) -> None:
"""Set callback for received OBD2 responses."""
self._response_callback = callback
def get_stats(self) -> Dict[str, Any]:
"""Get transceiver statistics."""
with self._stats_lock:
return {
"state": self._state.value,
"interface": self.interface,
"tx_count": self._stats.tx_count,
"rx_count": self._stats.rx_count,
"tx_errors": self._stats.tx_errors,
"rx_errors": self._stats.rx_errors,
"bus_errors": self._stats.bus_errors,
"last_tx_time": self._stats.last_tx_time,
"last_rx_time": self._stats.last_rx_time,
"tx_queue_size": self._tx_queue.qsize(),
}
def _rx_loop(self) -> None:
"""Receive loop - runs in dedicated thread."""
logger.debug("RX loop started")
while self._running:
try:
# Receive with timeout for graceful shutdown
msg = self._bus.recv(timeout=0.1)
if msg is None:
continue
# Convert to CANFrame
frame = CANFrame(
ts_ns=int(msg.timestamp * 1_000_000_000) if msg.timestamp else time.time_ns(),
bus=self.interface,
can_id=msg.arbitration_id,
is_extended=msg.is_extended_id,
dlc=msg.dlc,
data=bytes(msg.data)
)
# Parse as OBD2 response
response = OBD2Response.from_can_frame(frame)
if response:
with self._stats_lock:
self._stats.rx_count += 1
self._stats.last_rx_time = time.time()
# Notify callback
if self._response_callback:
try:
self._response_callback(response)
except Exception as e:
logger.error(f"Response callback error: {e}")
except can.CanError as e:
with self._stats_lock:
self._stats.bus_errors += 1
logger.warning(f"CAN bus error: {e}")
except Exception as e:
with self._stats_lock:
self._stats.rx_errors += 1
logger.error(f"RX loop error: {e}")
logger.debug("RX loop stopped")
def _tx_loop(self) -> None:
"""Transmit loop - runs in dedicated thread."""
logger.debug("TX loop started")
while self._running:
try:
# Get frame from queue with timeout
frame = self._tx_queue.get(timeout=0.1)
if self._bus is None:
continue
# Convert CANFrame to python-can Message
msg = can.Message(
arbitration_id=frame.can_id,
data=frame.data,
is_extended_id=frame.is_extended,
dlc=frame.dlc
)
# Send
self._bus.send(msg)
with self._stats_lock:
self._stats.tx_count += 1
self._stats.last_tx_time = time.time()
except Empty:
continue
except can.CanError as e:
with self._stats_lock:
self._stats.tx_errors += 1
self._stats.bus_errors += 1
logger.warning(f"CAN TX error: {e}")
except Exception as e:
with self._stats_lock:
self._stats.tx_errors += 1
logger.error(f"TX loop error: {e}")
logger.debug("TX loop stopped")
def __enter__(self):
"""Context manager entry."""
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.stop()
return False

View File

@@ -1,12 +0,0 @@
"""
Модуль для работы с PostgreSQL.
Предоставляет singleton класс для отправки CAN сообщений в PostgreSQL
с поддержкой пакетной отправки, connection pooling, retry с backoff.
"""
from typing import Optional
from .postgresql_client import PostgreSQLClient, get_postgresql_client
__all__ = ['PostgreSQLClient', 'get_postgresql_client']

View File

@@ -1,759 +0,0 @@
"""
Модуль для работы с PostgreSQL.
Предоставляет singleton класс для отправки CAN сообщений в PostgreSQL
с поддержкой пакетной отправки, connection pooling, retry с backoff.
"""
import queue
import threading
import time
from datetime import datetime, timezone
from queue import Queue, Empty
from typing import Optional, List, Dict, Any, Tuple
from enum import Enum
from config import config
from logger import get_logger
logger = get_logger(__name__)
# Импортируем PostgreSQL клиент
try:
import psycopg2
from psycopg2 import pool, sql
from psycopg2.extras import execute_batch
POSTGRESQL_AVAILABLE = True
except ImportError:
POSTGRESQL_AVAILABLE = False
psycopg2 = None
pool = None
sql = None
execute_batch = None
logger.warning("psycopg2 not installed. Install with: pip install psycopg2-binary")
class ConnectionStatus(Enum):
"""Статус соединения с PostgreSQL."""
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
ERROR = "error"
class PostgreSQLClient:
"""Singleton класс для работы с PostgreSQL."""
_instance: Optional['PostgreSQLClient'] = None
_lock = threading.Lock()
def __new__(cls):
"""Singleton паттерн для единого экземпляра клиента."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
"""Инициализация клиента PostgreSQL."""
# Защита от race condition при инициализации singleton
with self._lock:
# Проверяем, что инициализация выполняется только один раз
if hasattr(self, '_initialized') and self._initialized:
return
self.config = config.postgresql
self.logger = logger
# Инициализируем атрибуты по умолчанию
self.connection_pool: Optional[pool.ThreadedConnectionPool] = None
self.message_queue: Queue[Dict[str, Any]] = Queue(maxsize=config.general.buffer_size)
self.running = False
self.forwarder_thread: Optional[threading.Thread] = None
self.connection_status = ConnectionStatus.DISCONNECTED
# Статистика с блокировкой для потокобезопасности
self._stats_lock = threading.Lock()
self._sent_count = 0
self._failed_count = 0
self._retry_count = 0
self._reconnect_count = 0
self._synced_count = 0 # Количество синхронизированных из SQLite
# Флаг для запуска синхронизации после восстановления соединения
self._needs_sync = True
self._last_sync_time = 0.0
self._sync_interval = self.config.sync_interval
if not POSTGRESQL_AVAILABLE:
self.logger.error("PostgreSQL client library not available")
self._initialized = True # Отмечаем как инициализированный, чтобы не повторять
return
# Инициализируем клиент
self._init_client()
self._initialized = True
# Потокобезопасные свойства для статистики
@property
def sent_count(self) -> int:
with self._stats_lock:
return self._sent_count
@property
def failed_count(self) -> int:
with self._stats_lock:
return self._failed_count
@property
def retry_count(self) -> int:
with self._stats_lock:
return self._retry_count
@property
def reconnect_count(self) -> int:
with self._stats_lock:
return self._reconnect_count
@property
def synced_count(self) -> int:
with self._stats_lock:
return self._synced_count
def _increment_sent(self, count: int = 1) -> None:
with self._stats_lock:
self._sent_count += count
def _increment_failed(self, count: int = 1) -> None:
with self._stats_lock:
self._failed_count += count
def _increment_retry(self) -> None:
with self._stats_lock:
self._retry_count += 1
def _increment_reconnect(self) -> None:
with self._stats_lock:
self._reconnect_count += 1
def _increment_synced(self, count: int = 1) -> None:
with self._stats_lock:
self._synced_count += count
def _init_client(self) -> None:
"""Инициализация пула соединений PostgreSQL."""
if not POSTGRESQL_AVAILABLE:
self.connection_status = ConnectionStatus.ERROR
return
try:
# Создаем пул соединений
self.connection_pool = pool.ThreadedConnectionPool(
minconn=1,
maxconn=self.config.connection_pool_size,
host=self.config.host,
port=self.config.port,
database=self.config.database,
user=self.config.user,
password=self.config.password,
connect_timeout=self.config.connection_timeout
)
# Проверяем соединение
conn = self.connection_pool.getconn()
if conn:
# Создаем таблицу если её нет
self._create_table(conn)
self.connection_pool.putconn(conn)
self.connection_status = ConnectionStatus.CONNECTED
self.logger.info(
f"PostgreSQL connected: {self.config.host}:{self.config.port}/{self.config.database}"
)
else:
self.connection_pool = None
self.connection_status = ConnectionStatus.ERROR
except Exception as e:
self.connection_pool = None
self.connection_status = ConnectionStatus.ERROR
# Используем warning вместо error - это нормальная ситуация при старте без сети
self.logger.warning(f"PostgreSQL not available: {e}")
def _create_table(self, conn) -> None:
"""Создание таблицы для CAN сообщений если её нет."""
try:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS can_messages (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMP NOT NULL,
interface VARCHAR(50) NOT NULL,
can_id INTEGER NOT NULL,
can_id_hex VARCHAR(10) NOT NULL,
is_extended BOOLEAN NOT NULL,
dlc INTEGER NOT NULL,
data BYTEA NOT NULL,
data_hex VARCHAR(32) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_can_messages_timestamp ON can_messages(timestamp);
CREATE INDEX IF NOT EXISTS idx_can_messages_can_id ON can_messages(can_id);
CREATE INDEX IF NOT EXISTS idx_can_messages_interface ON can_messages(interface);
""")
conn.commit()
cursor.close()
self.logger.info("PostgreSQL table 'can_messages' created or verified")
except Exception as e:
conn.rollback()
self.logger.error(f"Failed to create table: {e}", exc_info=True)
raise
def write_message(self, interface: str, can_id: int, dlc: int, data: bytes, timestamp: float, block: bool = False) -> bool:
"""
Добавление CAN сообщения в очередь для отправки.
Args:
interface: Имя интерфейса (например, 'can0')
can_id: CAN ID сообщения
dlc: Data Length Code
data: Данные сообщения (bytes)
timestamp: Временная метка сообщения (float в секундах)
block: Блокировать ли при переполнении очереди
Returns:
True если сообщение добавлено в очередь
"""
if not self.connection_pool:
return False
try:
# Добавляем сообщение в очередь для пакетной отправки
if block:
self.message_queue.put({
"interface": interface,
"can_id": can_id,
"can_id_hex": hex(can_id),
"dlc": dlc,
"data": data,
"data_hex": data.hex().upper(),
"timestamp": timestamp,
"is_extended": can_id > 0x7FF
})
else:
try:
self.message_queue.put_nowait({
"interface": interface,
"can_id": can_id,
"can_id_hex": hex(can_id),
"dlc": dlc,
"data": data,
"data_hex": data.hex().upper(),
"timestamp": timestamp,
"is_extended": can_id > 0x7FF
})
except queue.Full:
# Очередь переполнена - пропускаем сообщение
self._increment_failed()
return False
return True
except Exception as e:
self.logger.error(
f"Failed to queue message for PostgreSQL: {e}",
exc_info=True
)
self._increment_failed()
return False
def write_messages_batch(self, messages: List[Dict[str, Any]], block: bool = False) -> int:
"""
Пакетная отправка сообщений в PostgreSQL.
Добавляет сообщения в очередь для асинхронной отправки через forwarder loop.
Args:
messages: Список словарей с данными сообщений
block: Блокировать ли при переполнении очереди
Returns:
Количество успешно добавленных в очередь сообщений
"""
if not self.connection_pool or not messages:
return 0
# Проверяем соединение перед добавлением в очередь
if self.connection_status != ConnectionStatus.CONNECTED:
if not self._health_check():
# Соединение недоступно - пропускаем батч без ошибки
self._increment_failed(len(messages))
return 0
else:
self.connection_status = ConnectionStatus.CONNECTED
# Проверяем заполненность очереди перед добавлением
queue_usage = self.message_queue.qsize() / self.message_queue.maxsize if self.message_queue.maxsize > 0 else 0
if queue_usage > 0.9 and not block:
# Очередь почти переполнена - пропускаем батч
self._increment_failed(len(messages))
return 0
# Добавляем сообщения в очередь для асинхронной отправки
added_count = 0
for msg in messages:
try:
if block:
self.message_queue.put(msg)
else:
try:
self.message_queue.put_nowait(msg)
except queue.Full:
# Очередь переполнена - пропускаем оставшиеся сообщения
break
added_count += 1
except Exception as e:
self.logger.debug(f"Failed to queue message: {e}")
break
if added_count < len(messages):
self._increment_failed(len(messages) - added_count)
return added_count
def _send_messages_batch(self, messages: List[Dict[str, Any]]) -> int:
"""
Непосредственная отправка батча сообщений в PostgreSQL.
Этот метод вызывается из forwarder loop для реальной отправки данных.
Args:
messages: Список словарей с данными сообщений
Returns:
Количество успешно отправленных сообщений
"""
if not self.connection_pool or not messages:
return 0
# Проверяем соединение перед отправкой
if self.connection_status != ConnectionStatus.CONNECTED:
if not self._health_check():
self.logger.warning("PostgreSQL connection not available, skipping batch")
self._increment_failed(len(messages))
return 0
else:
self.connection_status = ConnectionStatus.CONNECTED
conn = None
try:
# Получаем соединение из пула
conn = self.connection_pool.getconn()
if not conn:
self._increment_failed(len(messages))
return 0
cursor = conn.cursor()
# Подготавливаем данные для batch insert
insert_query = """
INSERT INTO can_messages (timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
"""
values = []
for msg in messages:
# Используем UTC для согласованности времени
ts = datetime.fromtimestamp(msg["timestamp"], tz=timezone.utc)
values.append((
ts,
msg["interface"],
msg["can_id"],
msg.get("can_id_hex", hex(msg["can_id"])),
msg.get("is_extended", msg["can_id"] > 0x7FF),
msg["dlc"],
msg["data"],
msg.get("data_hex", msg["data"].hex().upper() if isinstance(msg["data"], bytes) else "")
))
# Выполняем batch insert
execute_batch(cursor, insert_query, values)
conn.commit()
cursor.close()
sent = len(messages)
self._increment_sent(sent)
self.logger.debug(
f"Sent {sent} messages to PostgreSQL",
extra={"batch_size": sent}
)
return sent
except Exception as e:
if conn:
conn.rollback()
self.logger.error(
f"Failed to send messages batch to PostgreSQL: {e}",
exc_info=True,
extra={"batch_size": len(messages)}
)
# Не увеличиваем failed_count здесь - это делает _send_messages_batch_with_retry
raise # Пробрасываем исключение для retry механизма
finally:
if conn:
self.connection_pool.putconn(conn)
def _send_messages_batch_with_retry(self, messages: List[Dict[str, Any]]) -> int:
"""
Отправка батча сообщений с retry и exponential backoff.
Args:
messages: Список словарей с данными сообщений
Returns:
Количество успешно отправленных сообщений
"""
if not messages:
return 0
max_retries = self.config.max_retries
base_backoff = self.config.retry_backoff
for attempt in range(max_retries):
try:
return self._send_messages_batch(messages)
except Exception as e:
self._increment_retry()
if attempt < max_retries - 1:
# Exponential backoff: 1s, 2s, 4s...
delay = base_backoff * (2 ** attempt)
self.logger.warning(
f"PostgreSQL send failed (attempt {attempt + 1}/{max_retries}), "
f"retrying in {delay}s: {e}"
)
time.sleep(delay)
# Проверяем соединение перед повторной попыткой
if not self._health_check():
self.logger.warning("PostgreSQL connection lost, attempting reconnect")
self._reconnect()
else:
# Все попытки исчерпаны
self.logger.error(
f"All {max_retries} retries failed for batch of {len(messages)} messages"
)
self._increment_failed(len(messages))
return 0
return 0
def _health_check(self) -> bool:
"""Проверка здоровья соединения с PostgreSQL."""
if not self.connection_pool:
return False
try:
conn = self.connection_pool.getconn()
if conn:
cursor = conn.cursor()
cursor.execute("SELECT 1")
cursor.fetchone()
cursor.close()
self.connection_pool.putconn(conn)
self.connection_status = ConnectionStatus.CONNECTED
self.last_health_check = time.time()
return True
except Exception as e:
self.logger.debug(f"PostgreSQL health check failed: {e}")
return False
def _sync_from_sqlite(self) -> int:
"""
Синхронизация необработанных записей из SQLite в PostgreSQL.
Читает записи с processed=0 из SQLite и отправляет их в PostgreSQL.
После успешной отправки помечает записи как обработанные.
Returns:
Количество синхронизированных сообщений
"""
if self.connection_status != ConnectionStatus.CONNECTED:
return 0
try:
# Импортируем storage здесь, чтобы избежать циклических импортов
from storage import get_storage
storage = get_storage()
# Получаем необработанные сообщения из SQLite
unprocessed = storage.get_unprocessed_messages(limit=config.general.batch_size)
if not unprocessed:
return 0
self.logger.info(
f"Syncing {len(unprocessed)} unprocessed messages from SQLite to PostgreSQL"
)
# Конвертируем записи SQLite в формат для PostgreSQL
# Формат из SQLite: (id, timestamp, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex)
messages = []
sqlite_ids = []
for row in unprocessed:
sqlite_id, ts, interface, can_id, can_id_hex, is_extended, dlc, data, data_hex = row
sqlite_ids.append(sqlite_id)
messages.append({
"interface": interface,
"can_id": can_id,
"can_id_hex": can_id_hex or hex(can_id),
"dlc": dlc,
"data": data,
"data_hex": data_hex or (data.hex().upper() if isinstance(data, bytes) else ""),
"timestamp": ts,
"is_extended": bool(is_extended) if is_extended is not None else (can_id > 0x7FF)
})
# Отправляем в PostgreSQL с retry механизмом
sent_count = self._send_messages_batch_with_retry(messages)
if sent_count > 0:
# Помечаем успешно отправленные как обработанные
# Помечаем все, так как _send_messages_batch либо отправляет всё, либо ничего
marked = storage.mark_as_processed(sqlite_ids)
self._increment_synced(marked)
self.logger.info(
f"Synced {sent_count} messages from SQLite, marked {marked} as processed"
)
return sent_count
else:
self.logger.warning(
f"Failed to sync {len(messages)} messages from SQLite to PostgreSQL"
)
return 0
except Exception as e:
self.logger.error(
f"Error syncing from SQLite: {e}",
exc_info=True
)
return 0
def _reconnect(self) -> bool:
"""
Переподключение к PostgreSQL.
Returns:
True если подключение успешно
"""
if self.connection_status == ConnectionStatus.CONNECTING:
return False
self.connection_status = ConnectionStatus.CONNECTING
self._increment_reconnect()
try:
# Закрываем старый пул если есть
if self.connection_pool:
try:
self.connection_pool.closeall()
except Exception:
pass
self.connection_pool = None
# Создаем новый пул
self._init_client()
if self.connection_status == ConnectionStatus.CONNECTED:
self.logger.info("Successfully connected to PostgreSQL")
return True
else:
return False
except Exception as e:
self.connection_status = ConnectionStatus.ERROR
self.logger.warning(f"Failed to connect to PostgreSQL: {e}")
return False
def _forwarder_loop(self) -> None:
"""Основной цикл для отправки сообщений в PostgreSQL."""
self.logger.info("PostgreSQL forwarder loop started")
batch = []
last_flush_time = time.time()
last_reconnect_attempt = 0.0
reconnect_interval = 10.0 # Интервал между попытками подключения
was_connected = self.connection_status == ConnectionStatus.CONNECTED
while self.running or not self.message_queue.empty():
try:
current_time = time.time()
# Если нет соединения - пытаемся подключиться периодически
if not self.connection_pool or self.connection_status != ConnectionStatus.CONNECTED:
if current_time - last_reconnect_attempt >= reconnect_interval:
last_reconnect_attempt = current_time
self.logger.info("Attempting to connect to PostgreSQL...")
self._reconnect()
# Проверяем восстановление соединения и запускаем синхронизацию
is_connected = self.connection_status == ConnectionStatus.CONNECTED
if is_connected:
# Синхронизация при восстановлении соединения или по интервалу
should_sync = (
(not was_connected and is_connected) or # Соединение восстановлено
(self._needs_sync) or # Первая синхронизация
(current_time - self._last_sync_time >= self._sync_interval) # По интервалу
)
if should_sync:
synced = self._sync_from_sqlite()
self._last_sync_time = current_time
self._needs_sync = False
if synced > 0:
self.logger.debug(f"Synced {synced} messages from SQLite")
was_connected = is_connected
# Собираем сообщения в батч
try:
message = self.message_queue.get(timeout=0.1)
batch.append(message)
except Empty:
pass
# Отправляем батч если он заполнен или прошло достаточно времени
should_flush = (
len(batch) >= self.config.batch_size or
(batch and (current_time - last_flush_time) >= self.config.flush_interval)
)
if should_flush:
if batch:
# Отправляем батч с retry механизмом
self._send_messages_batch_with_retry(batch)
batch = []
last_flush_time = current_time
except Exception as e:
self.logger.error(
f"Error in forwarder loop: {e}",
exc_info=True
)
time.sleep(0.1)
# Отправляем оставшиеся сообщения с retry
if batch:
self._send_messages_batch_with_retry(batch)
# Финальная синхронизация перед остановкой
self._sync_from_sqlite()
self.logger.info(
"PostgreSQL forwarder loop stopped",
extra={
"sent_count": self.sent_count,
"failed_count": self.failed_count,
"synced_count": self.synced_count
}
)
def start(self) -> None:
"""Запуск forwarder потока для отправки сообщений."""
if not self.config.enabled:
self.logger.info("PostgreSQL is disabled in config")
return
if hasattr(self, 'running') and self.running:
self.logger.warning("PostgreSQL forwarder is already running")
return
self.running = True
# Запускаем forwarder поток даже если соединения нет
# Он будет периодически пытаться подключиться
self.forwarder_thread = threading.Thread(
target=self._forwarder_loop,
name="PostgreSQL-Forwarder",
daemon=False
)
self.forwarder_thread.start()
if self.connection_pool:
self.logger.info("PostgreSQL forwarder started (connected)")
else:
self.logger.info("PostgreSQL forwarder started (will retry connection)")
def stop(self) -> None:
"""Остановка forwarder потока."""
if not hasattr(self, 'running') or not self.running:
return
self.logger.info("Stopping PostgreSQL forwarder...")
self.running = False
# Даем время на обработку оставшихся сообщений в очереди
max_wait_time = 5.0
wait_start = time.time()
while not self.message_queue.empty() and (time.time() - wait_start) < max_wait_time:
time.sleep(0.1)
if not self.message_queue.empty():
remaining = self.message_queue.qsize()
self.logger.warning(
f"PostgreSQL queue not empty after shutdown, {remaining} messages remaining"
)
# Ждем завершения потока
if self.forwarder_thread and self.forwarder_thread.is_alive():
self.forwarder_thread.join(timeout=10.0)
if self.forwarder_thread.is_alive():
self.logger.warning("Forwarder thread did not stop gracefully")
# Закрываем пул соединений
if self.connection_pool:
try:
self.connection_pool.closeall()
self.connection_pool = None
except Exception as e:
self.logger.error(f"Error closing PostgreSQL connection pool: {e}", exc_info=True)
self.connection_status = ConnectionStatus.DISCONNECTED
self.logger.info("PostgreSQL forwarder stopped")
def get_stats(self) -> Dict[str, Any]:
"""Получение статистики клиента."""
return {
"enabled": self.config.enabled,
"initialized": self._initialized and self.connection_pool is not None,
"running": getattr(self, 'running', False),
"connection_status": self.connection_status.value,
"sent_count": self.sent_count,
"failed_count": self.failed_count,
"retry_count": self.retry_count,
"reconnect_count": self.reconnect_count,
"synced_count": self.synced_count,
"queue_size": self.message_queue.qsize(),
"host": self.config.host if self.config.enabled else None,
"database": self.config.database if self.config.enabled else None
}
def close(self) -> None:
"""Закрытие соединения с PostgreSQL."""
self.stop()
# Глобальный экземпляр клиента
_postgresql_instance: Optional[PostgreSQLClient] = None
def get_postgresql_client() -> PostgreSQLClient:
"""
Получение глобального экземпляра PostgreSQL клиента (singleton).
Returns:
Экземпляр PostgreSQLClient
"""
global _postgresql_instance
if _postgresql_instance is None:
_postgresql_instance = PostgreSQLClient()
return _postgresql_instance

View File

@@ -1,7 +0,0 @@
"""Модуль для работы с SocketCAN интерфейсами."""
from .src import CANSniffer, CANBusHandler
from .message_processor import MessageProcessor
__all__ = ['CANSniffer', 'CANBusHandler', 'MessageProcessor']

View File

@@ -1,434 +0,0 @@
"""
Модуль для обработки CAN сообщений.
Обрабатывает входящие CAN сообщения через pipeline обработчиков.
Использует очередь для асинхронной обработки, чтобы не блокировать чтение CAN сообщений.
"""
import queue
import threading
import time
from queue import Queue, Empty
from typing import Optional, Dict, Any, List
from logger import get_logger
from config import config
from can_frame import CANFrame
from handlers import BaseHandler, StorageHandler, PostgreSQLHandler, FlipperHandler
logger = get_logger(__name__)
class MessageProcessor:
"""
Класс для обработки и сохранения CAN сообщений с асинхронной обработкой.
Использует плагинную архитектуру обработчиков (pipeline).
Каждый обработчик реализует интерфейс BaseHandler.
"""
def __init__(self, handlers: Optional[List[BaseHandler]] = None, queue_size: Optional[int] = None):
"""
Инициализация процессора сообщений.
Args:
handlers: Список обработчиков для pipeline. Если None, создаются по умолчанию.
queue_size: Максимальный размер очереди сообщений. Если None, берется из config.general.buffer_size
"""
self.logger = logger
# Используем размер очереди из конфига, если не указан явно
if queue_size is None:
queue_size = config.general.buffer_size
# Очередь для асинхронной обработки сообщений
# Храним CANFrame объекты (неизменяемые, легковесные)
self.message_queue: Queue[CANFrame] = Queue(maxsize=queue_size)
self.running = False
self.processing_thread: Optional[threading.Thread] = None
# Статистика с блокировкой для потокобезопасности
self._stats_lock = threading.Lock()
self._processed_count = 0
self._dropped_count = 0
self._queue_full_warnings = 0
# Инициализируем обработчики
if handlers is None:
handlers = self._create_default_handlers()
self.handlers: List[BaseHandler] = []
self._init_handlers(handlers)
@property
def processed_count(self) -> int:
with self._stats_lock:
return self._processed_count
@property
def dropped_count(self) -> int:
with self._stats_lock:
return self._dropped_count
@property
def queue_full_warnings(self) -> int:
with self._stats_lock:
return self._queue_full_warnings
def _increment_processed(self, count: int = 1) -> None:
with self._stats_lock:
self._processed_count += count
def _increment_dropped(self, count: int = 1) -> None:
with self._stats_lock:
self._dropped_count += count
self._queue_full_warnings += count
def _create_default_handlers(self) -> List[BaseHandler]:
"""
Создание обработчиков по умолчанию из конфигурации.
Returns:
Список обработчиков
"""
handlers = []
# Storage handler всегда включен
handlers.append(StorageHandler(enabled=True))
# PostgreSQL handler зависит от конфигурации
handlers.append(PostgreSQLHandler(enabled=None)) # None = из config
# Flipper Zero handler зависит от конфигурации
handlers.append(FlipperHandler(enabled=None)) # None = из config
return handlers
def _init_handlers(self, handlers: List[BaseHandler]) -> None:
"""
Инициализация обработчиков.
Args:
handlers: Список обработчиков для инициализации
"""
for handler in handlers:
if handler.is_enabled():
try:
if handler.initialize():
self.handlers.append(handler)
self.logger.info(
f"Handler '{handler.name}' initialized successfully"
)
else:
self.logger.warning(
f"Handler '{handler.name}' initialization failed"
)
except Exception as e:
self.logger.error(
f"Error initializing handler '{handler.name}': {e}",
exc_info=True
)
else:
self.logger.debug(f"Handler '{handler.name}' is disabled")
self.logger.info(
f"Initialized {len(self.handlers)}/{len(handlers)} handlers",
extra={"handlers": [h.name for h in self.handlers]}
)
def enqueue(self, frame: CANFrame, block: bool = False, timeout: Optional[float] = None) -> bool:
"""
Добавление CAN фрейма в очередь для асинхронной обработки.
Этот метод вызывается из callback CAN чтения и должен быть быстрым.
Args:
frame: CANFrame объект
block: Блокировать ли при переполнении очереди (для backpressure)
timeout: Таймаут для блокирующего режима (секунды)
Returns:
True если сообщение добавлено, False если очередь переполнена
"""
try:
if block:
# Блокирующий режим - используется для backpressure
self.message_queue.put(frame, timeout=timeout)
return True
else:
# Неблокирующий режим - быстрое добавление
self.message_queue.put_nowait(frame)
return True
except queue.Full:
# Очередь переполнена - пропускаем сообщение
self._increment_dropped()
# Логируем предупреждение периодически (не каждое сообщение)
if self.queue_full_warnings % 1000 == 0:
queue_usage = (self.message_queue.qsize() / self.message_queue.maxsize) * 100
self.logger.warning(
f"Message queue full, dropped {self.dropped_count} messages",
extra={
"dropped_count": self.dropped_count,
"queue_size": self.message_queue.qsize(),
"queue_maxsize": self.message_queue.maxsize,
"queue_usage_percent": round(queue_usage, 1)
}
)
return False
except Exception as e:
# Неожиданная ошибка
self.logger.debug(f"Unexpected error in enqueue: {e}")
self._increment_dropped()
return False
def get_queue_usage(self) -> float:
"""
Получение процента заполнения очереди.
Returns:
Процент заполнения (0.0 - 1.0)
"""
if self.message_queue.maxsize == 0:
return 0.0
return self.message_queue.qsize() / self.message_queue.maxsize
def process(self, frame: CANFrame) -> None:
"""
Публичный метод для обработки CAN фрейма.
Используется как callback для CANSniffer.
Быстро добавляет фрейм в очередь без блокировки.
Args:
frame: CANFrame объект
"""
self.enqueue(frame)
def _processing_loop(self) -> None:
"""Основной цикл обработки сообщений из очереди."""
self.logger.info("Message processing loop started")
# Батч для групповой обработки
batch: List[CANFrame] = []
batch_size = config.general.batch_size
batch_interval = config.general.batch_interval
last_batch_time = time.time()
last_flush_time = time.time()
flush_interval = 5.0 # Периодический flush обработчиков
# Обрабатываем сообщения пока очередь не пуста или пока running=True
while self.running or not self.message_queue.empty():
try:
# Получаем сообщение из очереди с таймаутом
# Используем меньший таймаут при shutdown для быстрого завершения
timeout = batch_interval if self.running else 0.1
try:
frame = self.message_queue.get(timeout=timeout)
batch.append(frame)
except Empty:
# Если очередь пуста, обрабатываем накопленный батч
if batch:
self._process_batch(batch)
batch = []
last_batch_time = time.time()
# Если shutdown и очередь пуста - выходим
if not self.running and self.message_queue.empty():
break
continue
# Обрабатываем батч если он заполнен или прошло достаточно времени
current_time = time.time()
should_flush = (
len(batch) >= batch_size or
(batch and (current_time - last_batch_time) >= batch_interval)
)
if should_flush:
self._process_batch(batch)
batch = []
last_batch_time = current_time
# Периодический flush обработчиков
if (current_time - last_flush_time) >= flush_interval:
self._flush_handlers()
last_flush_time = current_time
except Exception as e:
self.logger.error(
f"Error in processing loop: {e}",
exc_info=True
)
# Обрабатываем оставшиеся сообщения в батче
if batch:
self._process_batch(batch)
# Финальный flush всех обработчиков
self._flush_handlers()
self.logger.info(
"Message processing loop stopped",
extra={
"processed_count": self.processed_count,
"dropped_count": self.dropped_count
}
)
def _process_batch(self, batch: List[CANFrame]) -> None:
"""
Обработка батча CAN фреймов через pipeline обработчиков.
Args:
batch: Список CANFrame объектов
"""
if not batch:
return
try:
# Логируем батч на уровне DEBUG (если уровень DEBUG включен)
if batch:
first_frame = batch[0]
self.logger.debug(
"CAN message batch processed",
extra={
"batch_size": len(batch),
"first_message": {
"bus": first_frame.bus,
"can_id": first_frame.can_id,
"can_id_hex": first_frame.can_id_hex,
"dlc": first_frame.dlc,
"data_hex": first_frame.data_hex,
"ts_ns": first_frame.ts_ns,
"timestamp": first_frame.timestamp,
"is_extended": first_frame.is_extended
}
}
)
# Обрабатываем батч через все обработчики (pipeline)
for handler in self.handlers:
if not handler.is_enabled() or not handler.is_initialized():
continue
try:
handler.handle_batch(batch)
except Exception as e:
self.logger.error(
f"Error in handler '{handler.name}': {e}",
exc_info=True,
extra={"batch_size": len(batch)}
)
# Обновляем счетчик обработанных сообщений (атомарно)
self._increment_processed(len(batch))
except Exception as e:
self.logger.error(
f"Error processing batch: {e}",
exc_info=True,
extra={"batch_size": len(batch)}
)
def _flush_handlers(self) -> None:
"""Принудительный flush всех обработчиков."""
for handler in self.handlers:
if handler.is_enabled() and handler.is_initialized():
try:
handler.flush()
except Exception as e:
self.logger.error(
f"Error flushing handler '{handler.name}': {e}",
exc_info=True
)
def start(self) -> None:
"""Запуск обработки сообщений в отдельном потоке."""
if self.running:
self.logger.warning("Message processor is already running")
return
self.running = True
# Запускаем специальные обработчики (например, PostgreSQL forwarder, Flipper sender)
for handler in self.handlers:
if isinstance(handler, (PostgreSQLHandler, FlipperHandler)) and handler.is_initialized():
try:
handler.start()
except Exception as e:
self.logger.error(
f"Failed to start handler '{handler.name}': {e}",
exc_info=True
)
# Запускаем поток обработки сообщений
# НЕ используем daemon=True, чтобы поток мог корректно завершиться
self.processing_thread = threading.Thread(
target=self._processing_loop,
name="MessageProcessor",
daemon=False
)
self.processing_thread.start()
self.logger.info("Message processor started")
def shutdown(self) -> None:
"""Корректное завершение работы процессора."""
self.logger.info("Shutting down message processor...")
self.running = False
# Даем время на обработку оставшихся сообщений
# Ждем пока очередь не опустеет или не пройдет таймаут
max_wait_time = 10.0
wait_start = time.time()
while not self.message_queue.empty() and (time.time() - wait_start) < max_wait_time:
time.sleep(0.1)
if not self.message_queue.empty():
remaining = self.message_queue.qsize()
self.logger.warning(
f"Queue not empty after shutdown signal, {remaining} messages remaining"
)
# Ждем завершения потока обработки
if self.processing_thread and self.processing_thread.is_alive():
self.processing_thread.join(timeout=5.0)
if self.processing_thread.is_alive():
self.logger.warning("Processing thread did not stop gracefully")
# Закрываем все обработчики
for handler in self.handlers:
try:
handler.shutdown()
except Exception as e:
self.logger.error(
f"Error shutting down handler '{handler.name}': {e}",
exc_info=True
)
self.logger.info(
"Message processor stopped",
extra={
"processed_count": self.processed_count,
"dropped_count": self.dropped_count
}
)
def get_stats(self) -> dict:
"""Получение статистики процессора."""
stats = {
"processed_count": self.processed_count,
"dropped_count": self.dropped_count,
"queue_size": self.message_queue.qsize(),
"running": self.running,
"handlers_count": len(self.handlers)
}
# Добавляем статистику всех обработчиков
for handler in self.handlers:
try:
handler_stats = handler.get_stats()
stats[handler.name] = handler_stats
except Exception as e:
self.logger.debug(f"Failed to get stats from handler '{handler.name}': {e}")
return stats

View File

@@ -1,411 +0,0 @@
"""
Модуль для работы с SocketCAN интерфейсами.
Предоставляет параллельное чтение CAN сообщений с нескольких интерфейсов
с поддержкой обработки ошибок, логирования и graceful shutdown.
"""
import can
import threading
import time
from typing import Callable, Dict, List, Optional
from queue import Queue, Empty
from config import config
from logger import get_logger
from can_frame import CANFrame
from .message_processor import MessageProcessor
class CANBusHandler:
"""Обработчик для одной CAN шины."""
def __init__(
self,
interface: str,
bus: can.Bus,
message_callback: Callable[[CANFrame], None],
logger,
filters: Optional[List[dict]] = None
):
"""
Инициализация обработчика CAN шины.
Args:
interface: Имя интерфейса (например, 'can0')
bus: Экземпляр can.Bus
message_callback: Функция для обработки CAN сообщений (принимает CANFrame)
logger: Логгер для данного интерфейса
filters: Список фильтров SocketCAN
"""
self.interface = interface
self.bus = bus
self.message_callback = message_callback
self.logger = logger
self.filters = filters or []
self.running = False
self.thread: Optional[threading.Thread] = None
self.message_count = 0
self.error_count = 0
self.last_message_time: Optional[float] = None
# Кэшируем ссылки для быстрого доступа (избегаем рефлексии в hot path)
self._processor = None
self._has_backpressure = False
self._enqueue_method = None
self._get_queue_usage_method = None
if hasattr(message_callback, '__self__'):
processor = getattr(message_callback, '__self__', None)
if processor and hasattr(processor, 'get_queue_usage') and hasattr(processor, 'enqueue'):
self._processor = processor
self._has_backpressure = True
self._enqueue_method = processor.enqueue
self._get_queue_usage_method = processor.get_queue_usage
# Применяем фильтры, если они есть
if self.filters:
self._apply_filters()
def _apply_filters(self) -> None:
"""Применение фильтров SocketCAN к шине."""
try:
# SocketCAN фильтры применяются через set_filters
# Формат: [{"can_id": 0x123, "can_mask": 0x7FF}, ...]
self.bus.set_filters(self.filters)
self.logger.info(
f"Applied {len(self.filters)} filters to {self.interface}",
extra={"filters": self.filters}
)
except Exception as e:
self.logger.warning(
f"Failed to apply filters to {self.interface}: {e}",
exc_info=True
)
def _read_loop(self) -> None:
"""Основной цикл чтения сообщений с шины."""
self.logger.info(f"Starting read loop for {self.interface}")
# Переменные для backpressure механизма
consecutive_drops = 0
backpressure_delay = 0.0
max_backpressure_delay = 0.5 # Максимальная задержка 500ms
while self.running:
try:
# Читаем сообщение с таймаутом для возможности проверки running
# Увеличиваем таймаут при backpressure
recv_timeout = 0.1 + backpressure_delay
message = self.bus.recv(timeout=recv_timeout)
if message is not None:
self.message_count += 1
self.last_message_time = time.time()
# Конвертируем can.Message в CANFrame
try:
frame = CANFrame.from_can_message(message, self.interface)
except Exception as e:
self.logger.error(
f"Failed to convert message to CANFrame for {self.interface}: {e}",
exc_info=True,
extra={"can_id": hex(message.arbitration_id) if message else None}
)
self.error_count += 1
continue
# Вызываем callback для обработки сообщения
# Используем backpressure: если очередь заполнена, замедляем чтение
try:
# Используем закэшированные ссылки для избежания рефлексии в hot path
if self._has_backpressure:
queue_usage = self._get_queue_usage_method()
# Если очередь заполнена более чем на 80%, используем блокирующий режим
if queue_usage > 0.8:
# Блокируем добавление с небольшим таймаутом для backpressure
success = self._enqueue_method(frame, block=True, timeout=0.01)
if not success:
consecutive_drops += 1
# Увеличиваем задержку при последовательных потерях
backpressure_delay = min(
max_backpressure_delay,
0.001 * consecutive_drops
)
continue
else:
# Очередь не заполнена - быстрое добавление
success = self._enqueue_method(frame, block=False)
if not success:
consecutive_drops += 1
backpressure_delay = min(
max_backpressure_delay,
0.001 * consecutive_drops
)
continue
# Сбрасываем счетчик при успешной отправке
if queue_usage < 0.5:
consecutive_drops = 0
backpressure_delay = 0.0
else:
# Обычный callback без backpressure
self.message_callback(frame)
except Exception as e:
self.logger.error(
f"Error in message callback for {self.interface}: {e}",
exc_info=True,
extra={"can_id": frame.can_id_hex}
)
self.error_count += 1
except can.CanError as e:
self.logger.error(
f"CAN error on {self.interface}: {e}",
exc_info=True
)
self.error_count += 1
# Небольшая задержка перед повторной попыткой
time.sleep(0.1)
except Exception as e:
self.logger.error(
f"Unexpected error on {self.interface}: {e}",
exc_info=True
)
self.error_count += 1
time.sleep(0.1)
self.logger.info(
f"Read loop stopped for {self.interface}",
extra={
"total_messages": self.message_count,
"total_errors": self.error_count
}
)
def start(self) -> None:
"""Запуск чтения сообщений в отдельном потоке."""
if self.running:
self.logger.warning(f"{self.interface} is already running")
return
self.running = True
# НЕ используем daemon=True для корректного завершения
self.thread = threading.Thread(
target=self._read_loop,
name=f"CAN-{self.interface}",
daemon=False
)
self.thread.start()
self.logger.info(f"Started reading from {self.interface}")
def stop(self) -> None:
"""Остановка чтения сообщений."""
if not self.running:
return
self.logger.info(f"Stopping {self.interface}...")
self.running = False
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2.0)
if self.thread.is_alive():
self.logger.warning(f"Thread for {self.interface} did not stop gracefully")
# Закрываем шину
try:
self.bus.shutdown()
self.logger.info(f"Bus {self.interface} closed")
except Exception as e:
self.logger.error(f"Error closing bus {self.interface}: {e}", exc_info=True)
def get_stats(self) -> Dict:
"""Получение статистики по обработке сообщений."""
return {
"interface": self.interface,
"message_count": self.message_count,
"error_count": self.error_count,
"last_message_time": self.last_message_time,
"running": self.running
}
class CANSniffer:
"""Класс для параллельного чтения CAN сообщений с нескольких интерфейсов."""
def __init__(self, message_callback: Optional[Callable[[CANFrame], None]] = None):
"""
Инициализация CAN Sniffer.
Args:
message_callback: Функция для обработки CAN сообщений.
Должна принимать CANFrame объект
"""
self.config = config.can
self.logger = get_logger(__name__)
# Инициализируем MessageProcessor для автоматической обработки сообщений
# Используем настройку из конфигурации для логирования сообщений
self.message_processor = MessageProcessor()
# Используем переданный callback или процессор по умолчанию
if message_callback:
self.message_callback = message_callback
else:
# Автоматически используем MessageProcessor
# Метод enqueue быстрый и не блокирует чтение CAN
self.message_callback = self.message_processor.enqueue
self.bus_handlers: Dict[str, CANBusHandler] = {}
self.running = False
self._init_buses()
def _init_buses(self) -> None:
"""
Инициализация CAN шин из конфигурации.
Примечание: Битрейт должен быть установлен на уровне системы через:
`ip link set canX type can bitrate X`
Значение в конфиге используется только для логирования и должно соответствовать реальному битрейту интерфейса.
"""
self.logger.info(
"Initializing CAN buses",
extra={
"interfaces": self.config.interfaces,
"listen_only": self.config.listen_only,
"bitrate": self.config.bitrate,
"note": "Bitrate must match system interface settings (ip link set)"
}
)
for interface in self.config.interfaces:
try:
bus = self._create_bus(interface)
handler = CANBusHandler(
interface=interface,
bus=bus,
message_callback=self.message_callback,
logger=self.logger.getChild(f"bus.{interface}"),
filters=self.config.filters
)
self.bus_handlers[interface] = handler
self.logger.info(f"Initialized bus: {interface}")
except Exception as e:
self.logger.error(
f"Failed to initialize bus {interface}: {e}",
exc_info=True,
extra={"interface": interface}
)
def _create_bus(self, interface: str) -> can.Bus:
"""
Создание CAN шины для интерфейса.
Args:
interface: Имя интерфейса (например, 'can0')
Returns:
Экземпляр can.Bus
"""
bus_kwargs = {
"channel": interface,
"bustype": "socketcan",
"receive_own_messages": False,
}
# Добавляем listen-only режим, если указан в конфигурации
if self.config.listen_only:
# Для SocketCAN listen-only режим устанавливается через параметр
# В некоторых версиях python-can это может быть через receive_own_messages=False
# и отдельным параметром, но для SocketCAN обычно достаточно receive_own_messages=False
pass
try:
bus = can.interface.Bus(**bus_kwargs)
self.logger.debug(
f"Created bus for {interface}",
extra={"kwargs": bus_kwargs}
)
return bus
except can.CanError as e:
self.logger.error(
f"CAN error creating bus for {interface}: {e}",
exc_info=True
)
raise
except Exception as e:
self.logger.error(
f"Unexpected error creating bus for {interface}: {e}",
exc_info=True
)
raise
def start(self) -> None:
"""Запуск чтения со всех шин."""
if self.running:
self.logger.warning("CANSniffer is already running")
return
self.logger.info(
f"Starting CANSniffer with {len(self.bus_handlers)} buses",
extra={"interfaces": list(self.bus_handlers.keys())}
)
# Запускаем процессор сообщений первым
self.message_processor.start()
self.running = True
# Запускаем все обработчики параллельно
for handler in self.bus_handlers.values():
handler.start()
self.logger.info("CANSniffer started successfully")
def stop(self) -> None:
"""Остановка чтения со всех шин."""
if not self.running:
return
self.logger.info("Stopping CANSniffer...")
self.running = False
# Останавливаем все обработчики
for handler in self.bus_handlers.values():
handler.stop()
# Останавливаем процессор сообщений
self.message_processor.shutdown()
self.logger.info("CANSniffer stopped")
def get_stats(self) -> Dict:
"""Получение статистики по всем шинам и обработке сообщений."""
stats = {
"running": self.running,
"buses": {
interface: handler.get_stats()
for interface, handler in self.bus_handlers.items()
}
}
# Добавляем статистику процессора сообщений
processor_stats = self.message_processor.get_stats()
stats["message_processor"] = processor_stats
return stats
def __enter__(self):
"""Поддержка context manager."""
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Поддержка context manager."""
self.stop()
return False

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
"""
Vehicle State Management Module.
Provides in-memory vehicle state tracking with pub/sub notifications.
"""
from .state import VehicleState
from .state_manager import VehicleStateManager, StateChangeEvent
__all__ = [
"VehicleState",
"VehicleStateManager",
"StateChangeEvent",
]

View File

@@ -0,0 +1,283 @@
"""
Vehicle State Data Model.
Represents the current state of vehicle parameters from OBD2.
"""
from dataclasses import dataclass, field, fields
from typing import Optional, Dict, Any
import time
@dataclass
class VehicleState:
"""
Current vehicle state from OBD2 readings.
All fields are optional and updated as responses arrive.
Timestamps track when each field was last updated.
Attributes:
timestamp: Last update timestamp
# Engine
rpm: Engine speed (rpm)
engine_load: Calculated engine load (%)
coolant_temp: Engine coolant temperature (°C)
oil_temp: Engine oil temperature (°C)
intake_temp: Intake air temperature (°C)
timing_advance: Timing advance (°)
maf: Mass air flow rate (g/s)
# Speed & Movement
speed: Vehicle speed (km/h)
throttle_pos: Throttle position (%)
accelerator_pos: Accelerator pedal position (%)
# Fuel
fuel_level: Fuel tank level (%)
fuel_rate: Engine fuel rate (L/h)
fuel_pressure: Fuel pressure (kPa)
# Temperatures
ambient_temp: Ambient air temperature (°C)
catalyst_temp: Catalyst temperature (°C)
# Diagnostics
runtime: Time since engine start (s)
distance_mil: Distance with MIL on (km)
dtc_count: Number of DTCs
# Vehicle Info
vin: Vehicle Identification Number
odometer: Odometer reading (km)
# Connection status
ecu_connected: Whether ECU is responding
last_response_time: Last successful response time
"""
# Metadata
timestamp: float = field(default_factory=time.time)
# Engine
rpm: Optional[float] = None
engine_load: Optional[float] = None
coolant_temp: Optional[float] = None
oil_temp: Optional[float] = None
intake_temp: Optional[float] = None
timing_advance: Optional[float] = None
maf: Optional[float] = None
map_pressure: Optional[float] = None # Intake manifold pressure
# Speed & Movement
speed: Optional[float] = None
throttle_pos: Optional[float] = None
accelerator_pos: Optional[float] = None
relative_throttle: Optional[float] = None
# Fuel
fuel_level: Optional[float] = None
fuel_rate: Optional[float] = None
fuel_pressure: Optional[float] = None
short_term_fuel_trim_1: Optional[float] = None
long_term_fuel_trim_1: Optional[float] = None
short_term_fuel_trim_2: Optional[float] = None
long_term_fuel_trim_2: Optional[float] = None
ethanol_percent: Optional[float] = None
# Temperatures
ambient_temp: Optional[float] = None
catalyst_temp_b1s1: Optional[float] = None
catalyst_temp_b1s2: Optional[float] = None
# Voltage
control_module_voltage: Optional[float] = None
# Oxygen Sensors
o2_voltage_b1s1: Optional[float] = None
o2_voltage_b1s2: Optional[float] = None
o2_voltage_b2s1: Optional[float] = None
o2_voltage_b2s2: Optional[float] = None
# Diagnostics
runtime: Optional[float] = None
distance_mil: Optional[float] = None
distance_since_clear: Optional[float] = None
warmups_since_clear: Optional[int] = None
time_since_clear: Optional[float] = None
dtc_count: int = 0
# Vehicle Info
vin: str = ""
odometer: Optional[float] = None
barometric_pressure: Optional[float] = None
# Connection status
ecu_connected: bool = False
last_response_time: float = 0.0
# Field update timestamps
_field_timestamps: Dict[str, float] = field(default_factory=dict)
def update_field(self, name: str, value: Any) -> bool:
"""
Update a field value with timestamp tracking.
Args:
name: Field name
value: New value
Returns:
True if field was updated (value changed)
"""
if not hasattr(self, name):
return False
old_value = getattr(self, name)
if old_value == value:
return False
setattr(self, name, value)
self._field_timestamps[name] = time.time()
self.timestamp = time.time()
return True
def get_field_age(self, name: str) -> Optional[float]:
"""
Get age of a field value in seconds.
Args:
name: Field name
Returns:
Age in seconds or None if never updated
"""
ts = self._field_timestamps.get(name)
if ts is None:
return None
return time.time() - ts
def is_field_stale(self, name: str, max_age_s: float = 5.0) -> bool:
"""
Check if a field value is stale.
Args:
name: Field name
max_age_s: Maximum age in seconds
Returns:
True if field is stale or never updated
"""
age = self.get_field_age(name)
if age is None:
return True
return age > max_age_s
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary (excludes private fields)."""
result = {}
for f in fields(self):
if f.name.startswith("_"):
continue
value = getattr(self, f.name)
if value is not None:
result[f.name] = value
return result
def get_engine_summary(self) -> Dict[str, Any]:
"""Get engine-related parameters."""
return {
"rpm": self.rpm,
"load": self.engine_load,
"coolant_temp": self.coolant_temp,
"oil_temp": self.oil_temp,
"intake_temp": self.intake_temp,
"maf": self.maf,
"throttle": self.throttle_pos,
}
def get_fuel_summary(self) -> Dict[str, Any]:
"""Get fuel-related parameters."""
return {
"level": self.fuel_level,
"rate": self.fuel_rate,
"pressure": self.fuel_pressure,
"stft1": self.short_term_fuel_trim_1,
"ltft1": self.long_term_fuel_trim_1,
}
def get_movement_summary(self) -> Dict[str, Any]:
"""Get movement-related parameters."""
return {
"speed": self.speed,
"throttle": self.throttle_pos,
"accelerator": self.accelerator_pos,
"odometer": self.odometer,
}
@property
def is_engine_running(self) -> bool:
"""Check if engine appears to be running."""
if self.rpm is not None and self.rpm > 0:
return True
return False
@property
def is_moving(self) -> bool:
"""Check if vehicle appears to be moving."""
if self.speed is not None and self.speed > 0:
return True
return False
def __repr__(self) -> str:
parts = []
if self.rpm is not None:
parts.append(f"rpm={self.rpm:.0f}")
if self.speed is not None:
parts.append(f"speed={self.speed:.0f}km/h")
if self.coolant_temp is not None:
parts.append(f"coolant={self.coolant_temp:.0f}°C")
if self.fuel_level is not None:
parts.append(f"fuel={self.fuel_level:.0f}%")
status = "connected" if self.ecu_connected else "disconnected"
return f"VehicleState({', '.join(parts)}, {status})"
# Mapping from PID to VehicleState field name
PID_TO_FIELD: Dict[int, str] = {
0x04: "engine_load",
0x05: "coolant_temp",
0x06: "short_term_fuel_trim_1",
0x07: "long_term_fuel_trim_1",
0x08: "short_term_fuel_trim_2",
0x09: "long_term_fuel_trim_2",
0x0A: "fuel_pressure",
0x0B: "map_pressure",
0x0C: "rpm",
0x0D: "speed",
0x0E: "timing_advance",
0x0F: "intake_temp",
0x10: "maf",
0x11: "throttle_pos",
0x14: "o2_voltage_b1s1",
0x15: "o2_voltage_b1s2",
0x18: "o2_voltage_b2s1",
0x19: "o2_voltage_b2s2",
0x1F: "runtime",
0x21: "distance_mil",
0x2F: "fuel_level",
0x31: "distance_since_clear",
0x33: "barometric_pressure",
0x3C: "catalyst_temp_b1s1",
0x3E: "catalyst_temp_b1s2",
0x42: "control_module_voltage",
0x45: "relative_throttle",
0x46: "ambient_temp",
0x49: "accelerator_pos",
0x52: "ethanol_percent",
0x5C: "oil_temp",
0x5E: "fuel_rate",
0xA6: "odometer",
}

View File

@@ -0,0 +1,324 @@
"""
Vehicle State Manager.
Manages the vehicle state singleton with pub/sub notifications
for state changes.
"""
import threading
import time
from dataclasses import dataclass
from typing import Optional, Callable, Dict, Any, List, Set
from enum import Enum
from logger import get_logger
from .state import VehicleState, PID_TO_FIELD
logger = get_logger(__name__)
@dataclass
class StateChangeEvent:
"""
Event fired when vehicle state changes.
Attributes:
field: Field name that changed
old_value: Previous value
new_value: New value
timestamp: When the change occurred
pid: Source PID (if known)
"""
field: str
old_value: Any
new_value: Any
timestamp: float
pid: Optional[int] = None
class SubscriptionType(Enum):
"""Types of state subscriptions."""
ALL = "all" # All state changes
FIELD = "field" # Specific field changes
CATEGORY = "category" # Category changes (engine, fuel, etc.)
@dataclass
class Subscription:
"""A state change subscription."""
callback: Callable[[StateChangeEvent], None]
sub_type: SubscriptionType
filter_value: Optional[str] = None # Field name or category
class VehicleStateManager:
"""
Manages vehicle state with pub/sub notifications.
Provides:
- Singleton state instance
- Thread-safe updates
- Subscription to state changes
- Session tracking
Example:
manager = VehicleStateManager()
manager.subscribe(on_change) # All changes
manager.subscribe_field("rpm", on_rpm_change)
# Updates from OBD2 readings
manager.update_from_reading(reading)
"""
_instance: Optional["VehicleStateManager"] = None
_lock = threading.Lock()
def __new__(cls) -> "VehicleStateManager":
"""Singleton pattern."""
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._state = VehicleState()
self._state_lock = threading.RLock()
self._subscriptions: List[Subscription] = []
self._sub_lock = threading.Lock()
# Session tracking
self._session_start: Optional[float] = None
self._session_start_odometer: Optional[float] = None
# Stats
self._updates_count = 0
self._last_update_time = 0.0
self._initialized = True
logger.debug("VehicleStateManager initialized")
@property
def state(self) -> VehicleState:
"""Get current vehicle state (read-only snapshot)."""
with self._state_lock:
return self._state
def get_state(self) -> VehicleState:
"""Get current vehicle state."""
return self.state
def update_from_reading(self, reading: "OBD2Reading") -> bool:
"""
Update state from an OBD2 reading.
Args:
reading: Decoded OBD2 reading
Returns:
True if state was updated
"""
from obd2.pids import OBD2Reading # Avoid circular import
if not reading.is_valid or reading.value is None:
return False
field_name = PID_TO_FIELD.get(reading.pid)
if field_name is None:
return False
return self.update_field(field_name, reading.value, reading.pid)
def update_field(
self,
field: str,
value: Any,
pid: Optional[int] = None
) -> bool:
"""
Update a specific state field.
Args:
field: Field name to update
value: New value
pid: Source PID (optional)
Returns:
True if field was updated (value changed)
"""
with self._state_lock:
old_value = getattr(self._state, field, None)
if not self._state.update_field(field, value):
return False
# Update connection status
self._state.ecu_connected = True
self._state.last_response_time = time.time()
self._updates_count += 1
self._last_update_time = time.time()
# Fire event outside lock
event = StateChangeEvent(
field=field,
old_value=old_value,
new_value=value,
timestamp=time.time(),
pid=pid
)
self._notify_subscribers(event)
return True
def subscribe(
self,
callback: Callable[[StateChangeEvent], None]
) -> None:
"""
Subscribe to all state changes.
Args:
callback: Function called with StateChangeEvent
"""
with self._sub_lock:
self._subscriptions.append(Subscription(
callback=callback,
sub_type=SubscriptionType.ALL
))
def subscribe_field(
self,
field: str,
callback: Callable[[StateChangeEvent], None]
) -> None:
"""
Subscribe to changes of a specific field.
Args:
field: Field name to watch
callback: Function called with StateChangeEvent
"""
with self._sub_lock:
self._subscriptions.append(Subscription(
callback=callback,
sub_type=SubscriptionType.FIELD,
filter_value=field
))
def subscribe_fields(
self,
fields: List[str],
callback: Callable[[StateChangeEvent], None]
) -> None:
"""Subscribe to changes of multiple fields."""
for field in fields:
self.subscribe_field(field, callback)
def unsubscribe(
self,
callback: Callable[[StateChangeEvent], None]
) -> None:
"""Remove a subscription by callback."""
with self._sub_lock:
self._subscriptions = [
s for s in self._subscriptions
if s.callback != callback
]
def clear_subscriptions(self) -> None:
"""Remove all subscriptions."""
with self._sub_lock:
self._subscriptions.clear()
def _notify_subscribers(self, event: StateChangeEvent) -> None:
"""Notify relevant subscribers of a state change."""
with self._sub_lock:
subs = list(self._subscriptions)
for sub in subs:
should_notify = False
if sub.sub_type == SubscriptionType.ALL:
should_notify = True
elif sub.sub_type == SubscriptionType.FIELD:
should_notify = (sub.filter_value == event.field)
if should_notify:
try:
sub.callback(event)
except Exception as e:
logger.error(f"Subscription callback error: {e}")
def start_session(self) -> None:
"""Start a new driving session."""
with self._state_lock:
self._session_start = time.time()
self._session_start_odometer = self._state.odometer
logger.info("Session started")
def end_session(self) -> Dict[str, Any]:
"""
End the current session and return summary.
Returns:
Session summary dictionary
"""
with self._state_lock:
if self._session_start is None:
return {}
duration = time.time() - self._session_start
distance = None
if (self._session_start_odometer is not None and
self._state.odometer is not None):
distance = self._state.odometer - self._session_start_odometer
summary = {
"duration_s": duration,
"distance_km": distance,
"updates_count": self._updates_count,
}
self._session_start = None
self._session_start_odometer = None
logger.info("Session ended", extra=summary)
return summary
def get_stats(self) -> Dict[str, Any]:
"""Get state manager statistics."""
with self._state_lock:
return {
"updates_count": self._updates_count,
"last_update_time": self._last_update_time,
"ecu_connected": self._state.ecu_connected,
"session_active": self._session_start is not None,
"subscriptions_count": len(self._subscriptions),
}
def mark_disconnected(self) -> None:
"""Mark ECU as disconnected."""
with self._state_lock:
self._state.ecu_connected = False
def reset(self) -> None:
"""Reset state to defaults."""
with self._state_lock:
self._state = VehicleState()
self._updates_count = 0
logger.info("Vehicle state reset")
# Convenience function for getting the singleton
def get_state_manager() -> VehicleStateManager:
"""Get the global VehicleStateManager instance."""
return VehicleStateManager()