Add UDS protocol support (ISO 14229) for ECU emulator

Implement UDS Service 0x22 (Read Data By Identifier) with support for:
- Engine ECU (0x7E0): boost pressure, fuel rail pressure, lambda, torque,
  wastegate position, ignition timing, knock correction per cylinder
- DSG Transmission (0x7E1): current gear, selector position, oil temp
- Instrument Cluster (0x714): RPM, ambient light sensor

Also adds ECU_EMULATOR_SPEC.md with full protocol documentation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 16:28:55 +03:00
parent 69acad18ca
commit 2ec05d2e9d
11 changed files with 1843 additions and 25 deletions

598
ECU_EMULATOR_SPEC.md Normal file
View File

@@ -0,0 +1,598 @@
# ECU Emulator Specification
Документация для создания эмулятора ECU для тестирования Carpibord.
## Общие сведения
- **CAN Bus Speed:** 500 kbps
- **CAN ID Format:** 11-bit (standard)
- **Протоколы:** OBD2 (ISO 15765-4) + UDS (ISO 14229)
---
## OBD2 Protocol (Mode 0x01)
### Формат запроса
```
CAN ID: 0x7DF (broadcast) или 0x7E0 (ECU direct)
Data: [Length, Mode, PID, 0x00, 0x00, 0x00, 0x00, 0x00]
Пример запроса RPM:
7DF [02 01 0C 00 00 00 00 00]
│ │ │
│ │ └── PID 0x0C (RPM)
│ └───── Mode 0x01 (Current Data)
└──────── Length: 2 bytes
```
### Формат ответа
```
CAN ID: 0x7E8 (ECU response)
Data: [Length, Mode+0x40, PID, DataA, DataB, ...]
Пример ответа RPM = 2000:
7E8 [04 41 0C 1F 40 00 00 00]
│ │ │ │ │
│ │ │ └───┴── Data: (0x1F * 256 + 0x40) / 4 = 2000 RPM
│ │ └──────── PID 0x0C
│ └─────────── Mode 0x41 (response to 0x01)
└────────────── Length: 4 bytes
```
---
## OBD2 PIDs — Полная спецификация
### PID 0x00 — Supported PIDs [01-20]
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 00 00 00 00 00 00]` |
| Response | `7E8 [06 41 00 XX XX XX XX 00]` |
| Formula | Bitmask of supported PIDs |
**Ответ для нашего набора PIDs:**
```
Поддерживаем: 0x04, 0x05, 0x0C, 0x0D, 0x0F, 0x10, 0x11
Bitmask: 0x18 3F 80 00
7E8 [06 41 00 18 3F 80 00 00]
```
### PID 0x04 — Engine Load
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 04 00 00 00 00 00]` |
| Response | `7E8 [03 41 04 XX 00 00 00 00]` |
| Formula | `A * 100 / 255` (%) |
| Min | 0% |
| Max | 100% |
| Bytes | 1 |
**Примеры:**
```
Load = 0%: 7E8 [03 41 04 00 00 00 00 00]
Load = 50%: 7E8 [03 41 04 80 00 00 00 00] (0x80 = 128 → 50.2%)
Load = 100%: 7E8 [03 41 04 FF 00 00 00 00]
```
### PID 0x05 — Coolant Temperature
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 05 00 00 00 00 00]` |
| Response | `7E8 [03 41 05 XX 00 00 00 00]` |
| Formula | `A - 40` (°C) |
| Min | -40°C |
| Max | 215°C |
| Bytes | 1 |
**Примеры:**
```
Temp = -40°C: 7E8 [03 41 05 00 00 00 00 00]
Temp = 0°C: 7E8 [03 41 05 28 00 00 00 00] (0x28 = 40)
Temp = 90°C: 7E8 [03 41 05 82 00 00 00 00] (0x82 = 130)
Temp = 105°C: 7E8 [03 41 05 91 00 00 00 00] (0x91 = 145)
```
### PID 0x0C — Engine RPM
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 0C 00 00 00 00 00]` |
| Response | `7E8 [04 41 0C XX YY 00 00 00]` |
| Formula | `(A * 256 + B) / 4` (RPM) |
| Min | 0 RPM |
| Max | 16383.75 RPM |
| Bytes | 2 |
**Примеры:**
```
RPM = 0: 7E8 [04 41 0C 00 00 00 00 00]
RPM = 800: 7E8 [04 41 0C 0C 80 00 00 00] (0x0C80 = 3200, /4 = 800)
RPM = 2000: 7E8 [04 41 0C 1F 40 00 00 00] (0x1F40 = 8000, /4 = 2000)
RPM = 3500: 7E8 [04 41 0C 36 B0 00 00 00] (0x36B0 = 14000, /4 = 3500)
RPM = 6000: 7E8 [04 41 0C 5D C0 00 00 00] (0x5DC0 = 24000, /4 = 6000)
```
**Формула кодирования:** `raw = RPM * 4`, затем `A = raw >> 8`, `B = raw & 0xFF`
### PID 0x0D — Vehicle Speed
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 0D 00 00 00 00 00]` |
| Response | `7E8 [03 41 0D XX 00 00 00 00]` |
| Formula | `A` (km/h) |
| Min | 0 km/h |
| Max | 255 km/h |
| Bytes | 1 |
**Примеры:**
```
Speed = 0: 7E8 [03 41 0D 00 00 00 00 00]
Speed = 50: 7E8 [03 41 0D 32 00 00 00 00]
Speed = 120: 7E8 [03 41 0D 78 00 00 00 00]
Speed = 200: 7E8 [03 41 0D C8 00 00 00 00]
```
### PID 0x0F — Intake Air Temperature
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 0F 00 00 00 00 00]` |
| Response | `7E8 [03 41 0F XX 00 00 00 00]` |
| Formula | `A - 40` (°C) |
| Min | -40°C |
| Max | 215°C |
| Bytes | 1 |
**Примеры:**
```
Temp = 25°C: 7E8 [03 41 0F 41 00 00 00 00] (0x41 = 65)
Temp = 40°C: 7E8 [03 41 0F 50 00 00 00 00] (0x50 = 80)
```
### PID 0x10 — MAF Air Flow Rate
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 10 00 00 00 00 00]` |
| Response | `7E8 [04 41 10 XX YY 00 00 00]` |
| Formula | `(A * 256 + B) / 100` (g/s) |
| Min | 0 g/s |
| Max | 655.35 g/s |
| Bytes | 2 |
**Примеры:**
```
MAF = 5 g/s: 7E8 [04 41 10 01 F4 00 00 00] (0x01F4 = 500, /100 = 5)
MAF = 25 g/s: 7E8 [04 41 10 09 C4 00 00 00] (0x09C4 = 2500, /100 = 25)
MAF = 100 g/s: 7E8 [04 41 10 27 10 00 00 00] (0x2710 = 10000, /100 = 100)
```
### PID 0x11 — Throttle Position
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 11 00 00 00 00 00]` |
| Response | `7E8 [03 41 11 XX 00 00 00 00]` |
| Formula | `A * 100 / 255` (%) |
| Min | 0% |
| Max | 100% |
| Bytes | 1 |
**Примеры:**
```
Throttle = 0%: 7E8 [03 41 11 00 00 00 00 00]
Throttle = 15%: 7E8 [03 41 11 26 00 00 00 00] (0x26 = 38 → 14.9%)
Throttle = 50%: 7E8 [03 41 11 80 00 00 00 00]
Throttle = 100%: 7E8 [03 41 11 FF 00 00 00 00]
```
### PID 0x20 — Supported PIDs [21-40]
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 20 00 00 00 00 00]` |
| Response | `7E8 [06 41 20 XX XX XX XX 00]` |
**Ответ для поддержки 0x2F:**
```
7E8 [06 41 20 00 00 00 01 00]
```
### PID 0x2F — Fuel Tank Level
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 2F 00 00 00 00 00]` |
| Response | `7E8 [03 41 2F XX 00 00 00 00]` |
| Formula | `A * 100 / 255` (%) |
| Min | 0% |
| Max | 100% |
| Bytes | 1 |
**Примеры:**
```
Fuel = 25%: 7E8 [03 41 2F 40 00 00 00 00]
Fuel = 50%: 7E8 [03 41 2F 80 00 00 00 00]
Fuel = 75%: 7E8 [03 41 2F BF 00 00 00 00]
```
### PID 0x40 — Supported PIDs [41-60]
```
7E8 [06 41 40 00 00 00 01 00] (поддержка 0x5C)
```
### PID 0x5C — Oil Temperature
| Параметр | Значение |
|----------|----------|
| Request | `7DF [02 01 5C 00 00 00 00 00]` |
| Response | `7E8 [03 41 5C XX 00 00 00 00]` |
| Formula | `A - 40` (°C) |
| Min | -40°C |
| Max | 210°C |
| Bytes | 1 |
**Примеры:**
```
Oil Temp = 90°C: 7E8 [03 41 5C 82 00 00 00 00] (0x82 = 130)
Oil Temp = 110°C: 7E8 [03 41 5C 96 00 00 00 00] (0x96 = 150)
```
---
## UDS Protocol (Mode 0x22)
### Формат запроса
```
CAN ID: зависит от ECU (см. таблицу)
Data: [03, 22, PID_H, PID_L, 55, 55, 55, 55]
Пример запроса Boost (PID 0x202A) к Engine ECU:
7E0 [03 22 20 2A 55 55 55 55]
│ │ │ │
│ │ └───┴── PID 0x202A
│ └───────── Service 0x22 (Read Data By ID)
└──────────── Length: 3 bytes
```
### Формат ответа
```
CAN ID: response ID для ECU
Data: [Length, 62, PID_H, PID_L, DataA, DataB, ...]
Пример ответа Boost = 185 kPa:
7E8 [05 62 20 2A 07 3A 00 00]
│ │ │ │ │ │
│ │ └───┴──┴───┴── PID + Data: 0x073A = 1850, *0.1 = 185.0 kPa
│ └────────────── Service 0x62 (positive response)
└───────────────── Length: 5 bytes
```
### Negative Response
Если PID не поддерживается:
```
7E8 [03 7F 22 31 00 00 00 00]
│ │ │ │
│ │ │ └── Error code 0x31 (Request Out of Range)
│ │ └───── Service that failed (0x22)
│ └──────── Negative response indicator (0x7F)
└─────────── Length: 3 bytes
```
---
## UDS PIDs — Engine ECU (0x7E0 → 0x7E8)
### PID 0x202A — Boost Pressure Actual
| Параметр | Значение |
|----------|----------|
| Request | `7E0 [03 22 20 2A 55 55 55 55]` |
| Response | `7E8 [05 62 20 2A XX YY 00 00]` |
| Formula | `(A * 256 + B) * 0.1` (kPa) |
| Min | 0 kPa |
| Max | 400 kPa |
| Bytes | 2 |
**Примеры:**
```
Boost = 100 kPa (атм): 7E8 [05 62 20 2A 03 E8 00 00] (0x03E8 = 1000)
Boost = 150 kPa: 7E8 [05 62 20 2A 05 DC 00 00] (0x05DC = 1500)
Boost = 200 kPa: 7E8 [05 62 20 2A 07 D0 00 00] (0x07D0 = 2000)
Boost = 250 kPa (1.5 bar): 7E8 [05 62 20 2A 09 C4 00 00] (0x09C4 = 2500)
```
### PID 0x2029 — Boost Pressure Target
| Параметр | Значение |
|----------|----------|
| Request | `7E0 [03 22 20 29 55 55 55 55]` |
| Response | `7E8 [05 62 20 29 XX YY 00 00]` |
| Formula | `(A * 256 + B) * 0.1` (kPa) |
| Min | 0 kPa |
| Max | 400 kPa |
| Bytes | 2 |
### PID 0xF423 — Fuel Rail Pressure
| Параметр | Значение |
|----------|----------|
| Request | `7E0 [03 22 F4 23 55 55 55 55]` |
| Response | `7E8 [05 62 F4 23 XX YY 00 00]` |
| Formula | `(A * 256 + B) * 10` (kPa) |
| Min | 0 kPa |
| Max | 25000 kPa (250 bar) |
| Bytes | 2 |
**Примеры:**
```
FRP = 5000 kPa (50 bar): 7E8 [05 62 F4 23 01 F4 00 00] (0x01F4 = 500)
FRP = 15000 kPa (150 bar): 7E8 [05 62 F4 23 05 DC 00 00] (0x05DC = 1500)
FRP = 20000 kPa (200 bar): 7E8 [05 62 F4 23 07 D0 00 00] (0x07D0 = 2000)
```
### PID 0x10C0 — Lambda Actual
| Параметр | Значение |
|----------|----------|
| Request | `7E0 [03 22 10 C0 55 55 55 55]` |
| Response | `7E8 [05 62 10 C0 XX YY 00 00]` |
| Formula | `(A * 256 + B) * 0.000977` (λ) |
| Min | 0.5 λ |
| Max | 2.0 λ |
| Bytes | 2 |
**Примеры:**
```
Lambda = 1.0 (stoich): 7E8 [05 62 10 C0 04 00 00 00] (0x0400 = 1024 → 1.000)
Lambda = 0.8 (rich): 7E8 [05 62 10 C0 03 33 00 00] (0x0333 = 819 → 0.800)
Lambda = 1.1 (lean): 7E8 [05 62 10 C0 04 66 00 00] (0x0466 = 1126 → 1.100)
```
**Формула кодирования:** `raw = lambda / 0.000977`
### PID 0x1456 — Lambda Target
| Параметр | Значение |
|----------|----------|
| Request | `7E0 [03 22 14 56 55 55 55 55]` |
| Response | `7E8 [05 62 14 56 XX YY 00 00]` |
| Formula | `(A * 256 + B) * 0.000977` (λ) |
### PID 0x437C — Torque Actual
| Параметр | Значение |
|----------|----------|
| Request | `7E0 [03 22 43 7C 55 55 55 55]` |
| Response | `7E8 [05 62 43 7C XX YY 00 00]` |
| Formula | `(A * 256 + B) * 0.1` (Nm) |
| Min | 0 Nm |
| Max | 500 Nm |
| Bytes | 2 |
**Примеры:**
```
Torque = 50 Nm: 7E8 [05 62 43 7C 01 F4 00 00] (0x01F4 = 500)
Torque = 200 Nm: 7E8 [05 62 43 7C 07 D0 00 00] (0x07D0 = 2000)
Torque = 350 Nm: 7E8 [05 62 43 7C 0D AC 00 00] (0x0DAC = 3500)
```
### PID 0x39A2 — Wastegate Position Actual
| Параметр | Значение |
|----------|----------|
| Request | `7E0 [03 22 39 A2 55 55 55 55]` |
| Response | `7E8 [05 62 39 A2 XX YY 00 00]` |
| Formula | `(A * 256 + B) * 0.01` (%) |
| Min | 0% |
| Max | 100% |
| Bytes | 2 |
**Примеры:**
```
WG = 0% (closed): 7E8 [05 62 39 A2 00 00 00 00]
WG = 50%: 7E8 [05 62 39 A2 13 88 00 00] (0x1388 = 5000)
WG = 100% (open): 7E8 [05 62 39 A2 27 10 00 00] (0x2710 = 10000)
```
### PID 0x39A3 — Wastegate Position Target
Аналогично 0x39A2, Request: `7E0 [03 22 39 A3 55 55 55 55]`
### PID 0x2004 — Ignition Timing
| Параметр | Значение |
|----------|----------|
| Request | `7E0 [03 22 20 04 55 55 55 55]` |
| Response | `7E8 [05 62 20 04 XX YY 00 00]` |
| Formula | `signed(A * 256 + B) * 0.01` (°) |
| Min | -10° |
| Max | 50° |
| Bytes | 2 (signed) |
**Примеры:**
```
Timing = 0°: 7E8 [05 62 20 04 00 00 00 00]
Timing = 10°: 7E8 [05 62 20 04 03 E8 00 00] (0x03E8 = 1000)
Timing = 25°: 7E8 [05 62 20 04 09 C4 00 00] (0x09C4 = 2500)
Timing = -5°: 7E8 [05 62 20 04 FE 0C 00 00] (0xFE0C = -500 signed)
```
**Формула кодирования (signed):**
```python
raw = int(timing * 100)
if raw < 0:
raw = 0x10000 + raw # Two's complement
A = (raw >> 8) & 0xFF
B = raw & 0xFF
```
### PID 0x200A-0x200D — Timing Correction Cylinders 1-4
| Параметр | Значение |
|----------|----------|
| Request | `7E0 [03 22 20 0A 55 55 55 55]` (Cyl 1) |
| Response | `7E8 [05 62 20 0A XX YY 00 00]` |
| Formula | `signed(A * 256 + B) * 0.01` (°) |
| Min | -15° |
| Max | 5° |
| Bytes | 2 (signed) |
**Примеры (Knock retard usually negative):**
```
Cyl1 TC = 0°: 7E8 [05 62 20 0A 00 00 00 00]
Cyl1 TC = -2°: 7E8 [05 62 20 0A FF 38 00 00] (0xFF38 = -200 signed)
Cyl1 TC = -5°: 7E8 [05 62 20 0A FE 0C 00 00] (0xFE0C = -500 signed)
```
---
## UDS PIDs — Instrument Cluster (0x714 → 0x77E)
### PID 0x22D1 — RPM from Cluster
| Параметр | Значение |
|----------|----------|
| Request | `714 [03 22 22 D1 55 55 55 55]` |
| Response | `77E [05 62 22 D1 XX YY 00 00]` |
| Formula | `(A * 256 + B) / 4` (RPM) |
| Min | 0 RPM |
| Max | 8000 RPM |
| Bytes | 2 |
**Примеры:**
```
RPM = 850: 77E [05 62 22 D1 0D 48 00 00] (0x0D48 = 3400, /4 = 850)
RPM = 3000: 77E [05 62 22 D1 2E E0 00 00] (0x2EE0 = 12000, /4 = 3000)
```
### PID 0x224D — Ambient Light Sensor
| Параметр | Значение |
|----------|----------|
| Request | `714 [03 22 22 4D 55 55 55 55]` |
| Response | `77E [04 62 22 4D XX 00 00 00]` |
| Formula | `A` (0-255, 0=dark, 255=bright) |
| Bytes | 1 |
**Примеры:**
```
Night: 77E [04 62 22 4D 10 00 00 00] (0x10 = 16)
Daytime: 77E [04 62 22 4D E0 00 00 00] (0xE0 = 224)
```
---
## UDS PIDs — DSG Transmission (0x7E1 → 0x7E9)
### PID 0x3816 — Current Gear
| Параметр | Значение |
|----------|----------|
| Request | `7E1 [03 22 38 16 55 55 55 55]` |
| Response | `7E9 [04 62 38 16 XX 00 00 00]` |
| Formula | Lookup table |
| Bytes | 1 |
**Gear mapping:**
```
0x00 = Neutral (0)
0x0C = Reverse (-1)
0x02 = 1st gear
0x04 = 2nd gear
0x06 = 3rd gear
0x08 = 4th gear
0x0A = 5th gear
0x0E = 6th gear
0x10 = 7th gear
```
**Примеры:**
```
Neutral: 7E9 [04 62 38 16 00 00 00 00]
Reverse: 7E9 [04 62 38 16 0C 00 00 00]
3rd gear: 7E9 [04 62 38 16 06 00 00 00]
6th gear: 7E9 [04 62 38 16 0E 00 00 00]
```
### PID 0x3815 — Gear Selector Position (PRNDM)
| Параметр | Значение |
|----------|----------|
| Request | `7E1 [03 22 38 15 55 55 55 55]` |
| Response | `7E9 [04 62 38 15 XX 00 00 00]` |
| Formula | Lookup table |
| Bytes | 1 |
**Mode mapping:**
```
0x00 = P (Park)
0x01 = R (Reverse)
0x02 = N (Neutral)
0x03 = D (Drive)
0x04 = S (Sport)
0x05 = M (Manual)
```
**Примеры:**
```
Park: 7E9 [04 62 38 15 00 00 00 00]
Drive: 7E9 [04 62 38 15 03 00 00 00]
Sport: 7E9 [04 62 38 15 04 00 00 00]
Manual: 7E9 [04 62 38 15 05 00 00 00]
```
### PID 0x1940 — Transmission Oil Temperature
| Параметр | Значение |
|----------|----------|
| Request | `7E1 [03 22 19 40 55 55 55 55]` |
| Response | `7E9 [04 62 19 40 XX 00 00 00]` |
| Formula | `A - 40` (°C) |
| Min | -40°C |
| Max | 150°C |
| Bytes | 1 |
**Примеры:**
```
Temp = 80°C: 7E9 [04 62 19 40 78 00 00 00] (0x78 = 120)
Temp = 100°C: 7E9 [04 62 19 40 8C 00 00 00] (0x8C = 140)
```
---
## Таблица быстрого референса
### OBD2 Request/Response Summary
| PID | Request | Response Example | Value |
|-----|---------|------------------|-------|
| 0x04 | `7DF 02 01 04` | `7E8 03 41 04 80` | 50% load |
| 0x05 | `7DF 02 01 05` | `7E8 03 41 05 82` | 90°C coolant |
| 0x0C | `7DF 02 01 0C` | `7E8 04 41 0C 1F 40` | 2000 RPM |
| 0x0D | `7DF 02 01 0D` | `7E8 03 41 0D 78` | 120 km/h |
| 0x11 | `7DF 02 01 11` | `7E8 03 41 11 40` | 25% throttle |
| 0x2F | `7DF 02 01 2F` | `7E8 03 41 2F 80` | 50% fuel |
| 0x5C | `7DF 02 01 5C` | `7E8 03 41 5C 96` | 110°C oil |
### UDS Request/Response Summary
| ECU | PID | Request | Response Example | Value |
|-----|-----|---------|------------------|-------|
| Engine | 0x202A | `7E0 03 22 20 2A` | `7E8 05 62 20 2A 07 D0` | 200 kPa boost |
| Engine | 0x437C | `7E0 03 22 43 7C` | `7E8 05 62 43 7C 0D AC` | 350 Nm torque |
| Engine | 0x10C0 | `7E0 03 22 10 C0` | `7E8 05 62 10 C0 04 00` | λ=1.0 |
| Cluster | 0x22D1 | `714 03 22 22 D1` | `77E 05 62 22 D1 1F 40` | 2000 RPM |
| DSG | 0x3816 | `7E1 03 22 38 16` | `7E9 04 62 38 16 06` | 3rd gear |
| DSG | 0x3815 | `7E1 03 22 38 15` | `7E9 04 62 38 15 03` | Drive mode |

108
docs/UDS_PROTOCOL.md Normal file
View File

@@ -0,0 +1,108 @@
# UDS Protocol Support
Эмулятор поддерживает UDS (Unified Diagnostic Services) протокол согласно ISO 14229.
## Поддерживаемые ECU
| ECU | Request ID | Response ID | Описание |
|-----|------------|-------------|----------|
| Engine ECU | 0x7E0 | 0x7E8 | Блок управления двигателем |
| DSG Transmission | 0x7E1 | 0x7E9 | Коробка передач DSG |
| Instrument Cluster | 0x714 | 0x77E | Приборная панель |
## Формат запроса
```
CAN ID: Request ID ECU
Data: [03, 22, PID_H, PID_L, 55, 55, 55, 55]
│ │ └───┴── Data Identifier (DID)
│ └────────── Service 0x22 (Read Data By ID)
└────────────── Length: 3 bytes
```
## Формат ответа
### Положительный ответ
```
CAN ID: Response ID ECU
Data: [Length, 62, PID_H, PID_L, DataA, DataB, ...]
│ └───┴── Data Identifier
└────────── Service 0x62 (positive response)
```
### Отрицательный ответ
```
CAN ID: Response ID ECU
Data: [03, 7F, 22, ErrorCode, 00, 00, 00, 00]
│ │ └── Error code (0x31 = Request Out of Range)
│ └────── Service that failed
└────────── Negative Response Indicator
```
## Поддерживаемые DIDs
### Engine ECU (0x7E0 → 0x7E8)
| DID | Название | Формула | Единицы | Диапазон |
|-----|----------|---------|---------|----------|
| 0x202A | Boost Pressure Actual | (A*256+B) * 0.1 | kPa | 0-400 |
| 0x2029 | Boost Pressure Target | (A*256+B) * 0.1 | kPa | 0-400 |
| 0xF423 | Fuel Rail Pressure | (A*256+B) * 10 | kPa | 0-25000 |
| 0x10C0 | Lambda Actual | (A*256+B) * 0.000977 | λ | 0.5-2.0 |
| 0x1456 | Lambda Target | (A*256+B) * 0.000977 | λ | 0.5-2.0 |
| 0x437C | Torque Actual | (A*256+B) * 0.1 | Nm | 0-500 |
| 0x39A2 | Wastegate Actual | (A*256+B) * 0.01 | % | 0-100 |
| 0x39A3 | Wastegate Target | (A*256+B) * 0.01 | % | 0-100 |
| 0x2004 | Ignition Timing | signed(A*256+B) * 0.01 | ° | -10 to 50 |
| 0x200A | Timing Correction Cyl1 | signed(A*256+B) * 0.01 | ° | -15 to 5 |
| 0x200B | Timing Correction Cyl2 | signed(A*256+B) * 0.01 | ° | -15 to 5 |
| 0x200C | Timing Correction Cyl3 | signed(A*256+B) * 0.01 | ° | -15 to 5 |
| 0x200D | Timing Correction Cyl4 | signed(A*256+B) * 0.01 | ° | -15 to 5 |
### DSG Transmission (0x7E1 → 0x7E9)
| DID | Название | Формула | Значения |
|-----|----------|---------|----------|
| 0x3816 | Current Gear | Lookup | 0x00=N, 0x0C=R, 0x02=1, 0x04=2, 0x06=3, 0x08=4, 0x0A=5, 0x0E=6, 0x10=7 |
| 0x3815 | Gear Selector | Lookup | 0=P, 1=R, 2=N, 3=D, 4=S, 5=M |
| 0x1940 | Trans Oil Temp | A - 40 | °C (-40 to 150) |
### Instrument Cluster (0x714 → 0x77E)
| DID | Название | Формула | Единицы |
|-----|----------|---------|---------|
| 0x22D1 | RPM | (A*256+B) / 4 | RPM |
| 0x224D | Ambient Light | A | 0-255 (0=dark, 255=bright) |
## Примеры
### Запрос Boost Pressure
```
TX: 7E0 [03 22 20 2A 55 55 55 55]
RX: 7E8 [05 62 20 2A 07 D0 00 00] → 0x07D0 = 2000 → 200.0 kPa
```
### Запрос Current Gear (3rd)
```
TX: 7E1 [03 22 38 16 55 55 55 55]
RX: 7E9 [04 62 38 16 06 00 00 00] → 0x06 = 3rd gear
```
### Запрос неподдерживаемого DID
```
TX: 7E0 [03 22 FF FF 55 55 55 55]
RX: 7E8 [03 7F 22 31 00 00 00 00] → Negative response: Request Out of Range
```
## Тестирование
Используйте скрипт `scripts/test_uds.py`:
```bash
python scripts/test_uds.py --interface vcan0
```
Опции:
- `--engine` - тестировать только Engine ECU
- `--dsg` - тестировать только DSG
- `--cluster` - тестировать только Instrument Cluster

View File

@@ -14,52 +14,70 @@ except ImportError:
sys.exit(1)
# OBD2 PIDs для тестирования
# OBD2 PIDs для тестирования (согласно ECU_EMULATOR_SPEC.md)
TEST_PIDS = [
# Supported PIDs
(0x00, "Supported PIDs [01-20]"),
(0x20, "Supported PIDs [21-40]"),
(0x40, "Supported PIDs [41-60]"),
# Engine
(0x04, "Engine Load"),
(0x05, "Coolant Temperature"),
(0x0C, "Engine RPM"),
(0x0D, "Vehicle Speed"),
(0x0F, "Intake Air Temperature"),
(0x10, "MAF Air Flow Rate"),
(0x11, "Throttle Position"),
# Fuel
(0x2F, "Fuel Tank Level"),
(0x5C, "Oil Temperature"),
# Other
(0x46, "Ambient Temperature"),
]
def decode_pid(pid: int, data: bytes) -> str:
"""Декодировать значение PID."""
"""Декодировать значение PID согласно ECU_EMULATOR_SPEC.md."""
if len(data) < 1:
return "No data"
if pid == 0x00:
# Supported PIDs bitmap
if pid in (0x00, 0x20, 0x40, 0x60):
if len(data) >= 4:
mask = int.from_bytes(data[:4], "big")
supported = []
base = pid + 1
for i in range(32):
if mask & (1 << (31 - i)):
supported.append(f"{i+1:02X}")
return f"PIDs: {', '.join(supported[:10])}..."
supported.append(f"{base + i:02X}")
if supported:
return f"PIDs: {', '.join(supported)}"
return "None"
return f"Raw: {data.hex()}"
elif pid == 0x05 or pid == 0x0F or pid == 0x46:
# Temperature: A - 40
# Temperature PIDs: A - 40
elif pid in (0x05, 0x0F, 0x46, 0x5C):
return f"{data[0] - 40} °C"
elif pid == 0x0C:
# Engine Load / Throttle / Fuel Level: A * 100 / 255
elif pid in (0x04, 0x11, 0x2F):
return f"{data[0] * 100 / 255:.1f} %"
# RPM: (A*256 + B) / 4
elif pid == 0x0C:
if len(data) >= 2:
rpm = (data[0] * 256 + data[1]) / 4
return f"{rpm:.0f} rpm"
elif pid == 0x0D:
# Speed: A
elif pid == 0x0D:
return f"{data[0]} km/h"
elif pid == 0x11 or pid == 0x2F:
# Percentage: A * 100 / 255
return f"{data[0] * 100 / 255:.1f} %"
# MAF: (A*256 + B) / 100
elif pid == 0x10:
if len(data) >= 2:
maf = (data[0] * 256 + data[1]) / 100
return f"{maf:.2f} g/s"
return f"Raw: {data.hex()}"

250
scripts/test_uds.py Normal file
View File

@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""
Тестовый скрипт для проверки UDS протокола эмулятора.
Отправляет запросы UDS Service 0x22 и проверяет ответы.
Использование:
python test_uds.py --interface vcan0
Требования:
- Запущенный эмулятор на том же интерфейсе
- pip install python-can
"""
import argparse
import sys
import time
from dataclasses import dataclass
import can
@dataclass
class UDSTestCase:
"""Тестовый случай для UDS."""
name: str
request_id: int
response_id: int
did: int
description: str
# Тестовые случаи согласно спецификации
ENGINE_ECU_TESTS = [
UDSTestCase("Boost Pressure Actual", 0x7E0, 0x7E8, 0x202A, "Boost in kPa"),
UDSTestCase("Boost Pressure Target", 0x7E0, 0x7E8, 0x2029, "Target boost in kPa"),
UDSTestCase("Fuel Rail Pressure", 0x7E0, 0x7E8, 0xF423, "FRP in kPa"),
UDSTestCase("Lambda Actual", 0x7E0, 0x7E8, 0x10C0, "Lambda value"),
UDSTestCase("Lambda Target", 0x7E0, 0x7E8, 0x1456, "Target lambda"),
UDSTestCase("Torque Actual", 0x7E0, 0x7E8, 0x437C, "Torque in Nm"),
UDSTestCase("Wastegate Actual", 0x7E0, 0x7E8, 0x39A2, "Wastegate %"),
UDSTestCase("Wastegate Target", 0x7E0, 0x7E8, 0x39A3, "Target wastegate %"),
UDSTestCase("Ignition Timing", 0x7E0, 0x7E8, 0x2004, "Timing in degrees"),
UDSTestCase("Timing Correction Cyl1", 0x7E0, 0x7E8, 0x200A, "Knock retard"),
UDSTestCase("Timing Correction Cyl2", 0x7E0, 0x7E8, 0x200B, "Knock retard"),
UDSTestCase("Timing Correction Cyl3", 0x7E0, 0x7E8, 0x200C, "Knock retard"),
UDSTestCase("Timing Correction Cyl4", 0x7E0, 0x7E8, 0x200D, "Knock retard"),
]
DSG_TESTS = [
UDSTestCase("Current Gear", 0x7E1, 0x7E9, 0x3816, "DSG gear"),
UDSTestCase("Gear Selector", 0x7E1, 0x7E9, 0x3815, "PRNDM position"),
UDSTestCase("Trans Oil Temp", 0x7E1, 0x7E9, 0x1940, "Temperature"),
]
CLUSTER_TESTS = [
UDSTestCase("Cluster RPM", 0x714, 0x77E, 0x22D1, "RPM from cluster"),
UDSTestCase("Ambient Light", 0x714, 0x77E, 0x224D, "Light sensor"),
]
def build_uds_request(did: int) -> bytes:
"""Построить UDS запрос Service 0x22."""
did_h = (did >> 8) & 0xFF
did_l = did & 0xFF
# [length, service, did_h, did_l, padding...]
return bytes([0x03, 0x22, did_h, did_l, 0x55, 0x55, 0x55, 0x55])
def parse_uds_response(data: bytes) -> tuple[bool, int | None, bytes | None]:
"""
Распарсить UDS ответ.
Returns: (is_positive, did, data) или (False, None, None) для negative response
"""
if len(data) < 4:
return False, None, None
length = data[0]
service = data[1]
if service == 0x62: # Positive response
did = (data[2] << 8) | data[3]
response_data = data[4:4 + length - 3]
return True, did, response_data
elif service == 0x7F: # Negative response
error_code = data[3] if len(data) > 3 else 0
return False, None, bytes([error_code])
return False, None, None
def decode_value(did: int, data: bytes) -> str:
"""Декодировать значение UDS ответа."""
if len(data) == 0:
return "No data"
if len(data) == 1:
raw = data[0]
# Temperature PIDs (offset -40)
if did in (0x1940,):
return f"{raw - 40}°C"
# Gear
if did == 0x3816:
gear_map = {0x00: "N", 0x0C: "R", 0x02: "1", 0x04: "2", 0x06: "3",
0x08: "4", 0x0A: "5", 0x0E: "6", 0x10: "7"}
return gear_map.get(raw, f"Unknown ({raw:02X})")
# Selector
if did == 0x3815:
pos_map = {0: "P", 1: "R", 2: "N", 3: "D", 4: "S", 5: "M"}
return pos_map.get(raw, f"Unknown ({raw})")
# Ambient light
if did == 0x224D:
return f"{raw} (0=dark, 255=bright)"
return f"{raw}"
if len(data) >= 2:
raw = (data[0] << 8) | data[1]
# Boost pressure (scale 0.1)
if did in (0x202A, 0x2029):
return f"{raw * 0.1:.1f} kPa"
# Fuel rail pressure (scale 10)
if did == 0xF423:
return f"{raw * 10} kPa ({raw * 10 / 100:.0f} bar)"
# Lambda (scale 0.000977)
if did in (0x10C0, 0x1456):
return f"{raw * 0.000977:.3f} λ"
# Torque (scale 0.1)
if did == 0x437C:
return f"{raw * 0.1:.1f} Nm"
# Wastegate (scale 0.01)
if did in (0x39A2, 0x39A3):
return f"{raw * 0.01:.1f}%"
# Signed values (timing)
if did in (0x2004, 0x200A, 0x200B, 0x200C, 0x200D):
if raw >= 0x8000:
raw -= 0x10000
return f"{raw * 0.01:.2f}°"
# RPM (scale /4)
if did == 0x22D1:
return f"{raw / 4:.0f} RPM"
return f"0x{raw:04X} ({raw})"
return data.hex().upper()
def run_test(bus: can.Bus, test: UDSTestCase, timeout: float = 0.5) -> bool:
"""Выполнить один тест UDS."""
request_data = build_uds_request(test.did)
# Отправляем запрос
msg = can.Message(
arbitration_id=test.request_id,
data=request_data,
is_extended_id=False
)
bus.send(msg)
# Ждём ответ
start_time = time.time()
while time.time() - start_time < timeout:
response = bus.recv(timeout=0.1)
if response is None:
continue
if response.arbitration_id == test.response_id:
is_positive, did, data = parse_uds_response(bytes(response.data))
if is_positive and did == test.did:
decoded = decode_value(did, data)
print(f"{test.name}: {decoded}")
return True
elif not is_positive:
error_code = data[0] if data else 0
error_names = {
0x31: "Request Out of Range",
0x22: "Conditions Not Correct",
0x33: "Security Access Denied",
0x10: "General Reject",
}
error_name = error_names.get(error_code, f"Unknown (0x{error_code:02X})")
print(f"{test.name}: Negative response - {error_name}")
return False
print(f"{test.name}: No response (timeout)")
return False
def main():
parser = argparse.ArgumentParser(description="Test UDS protocol of OBD2 emulator")
parser.add_argument("-i", "--interface", default="vcan0", help="CAN interface")
parser.add_argument("--timeout", type=float, default=0.5, help="Response timeout")
parser.add_argument("--engine", action="store_true", help="Test Engine ECU only")
parser.add_argument("--dsg", action="store_true", help="Test DSG only")
parser.add_argument("--cluster", action="store_true", help="Test Cluster only")
args = parser.parse_args()
print(f"Connecting to {args.interface}...")
try:
bus = can.Bus(interface="socketcan", channel=args.interface)
except Exception as e:
print(f"Error: Cannot connect to {args.interface}: {e}")
return 1
print("=" * 60)
print(" UDS PROTOCOL TEST")
print("=" * 60)
# Определяем какие тесты запускать
run_all = not (args.engine or args.dsg or args.cluster)
total_tests = 0
passed_tests = 0
if run_all or args.engine:
print("\n[Engine ECU - 0x7E0]")
for test in ENGINE_ECU_TESTS:
total_tests += 1
if run_test(bus, test, args.timeout):
passed_tests += 1
if run_all or args.dsg:
print("\n[DSG Transmission - 0x7E1]")
for test in DSG_TESTS:
total_tests += 1
if run_test(bus, test, args.timeout):
passed_tests += 1
if run_all or args.cluster:
print("\n[Instrument Cluster - 0x714]")
for test in CLUSTER_TESTS:
total_tests += 1
if run_test(bus, test, args.timeout):
passed_tests += 1
print("\n" + "=" * 60)
print(f"Results: {passed_tests}/{total_tests} tests passed")
print("=" * 60)
bus.shutdown()
return 0 if passed_tests == total_tests else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -33,6 +33,7 @@ class VehicleConfig(BaseModel):
idle_rpm: int = Field(default=850, ge=600, le=1200)
max_rpm: int = Field(default=6500, ge=4000, le=9000)
redline_rpm: int = Field(default=6000, ge=3500, le=8500)
max_torque_nm: float = Field(default=350.0, ge=100, le=800, description="Max torque in Nm")
# Температуры
ambient_temp: float = Field(default=20.0, ge=-40, le=50)

View File

@@ -13,6 +13,7 @@ from config import Config, CANConfig, VehicleConfig, SimulatorConfig, set_config
from logger import get_logger, setup_logging
from can_interface import CANInterface, CANFrame, CANInterfaceError
from obd2 import OBD2Protocol
from uds import UDSProtocol
from simulator import VehicleSimulator, ScenarioManager
logger = get_logger(__name__)
@@ -28,6 +29,7 @@ class OBD2Emulator:
self.config = config
self.can_interface = CANInterface(config.can)
self.obd2_protocol = OBD2Protocol()
self.uds_protocol = UDSProtocol()
self.vehicle_simulator = VehicleSimulator(config.vehicle)
self.scenario_manager = ScenarioManager(self.vehicle_simulator)
@@ -116,11 +118,14 @@ class OBD2Emulator:
if frame is None:
continue
# Обрабатываем как OBD2 запрос
response_frame = self.obd2_protocol.process_frame(
frame,
self.vehicle_simulator.get_state()
)
vehicle_state = self.vehicle_simulator.get_state()
# Пробуем обработать как OBD2 запрос
response_frame = self.obd2_protocol.process_frame(frame, vehicle_state)
# Если не OBD2, пробуем как UDS запрос
if response_frame is None:
response_frame = self.uds_protocol.process_frame(frame, vehicle_state)
# Отправляем ответ
if response_frame:
@@ -153,18 +158,23 @@ class OBD2Emulator:
"coolant_temp": round(state.coolant_temp, 1),
"throttle": round(state.throttle_pos, 1),
"fuel_level": round(state.fuel_level, 1),
"boost": round(state.boost_pressure_actual, 1),
"lambda": round(state.lambda_actual, 3),
"torque": round(state.torque_nm, 1),
},
"obd2_stats": self.obd2_protocol.get_stats(),
"uds_stats": self.uds_protocol.get_stats(),
}
def print_status(self) -> None:
"""Вывести статус в консоль."""
state = self.vehicle_simulator.get_state()
stats = self.obd2_protocol.get_stats()
obd2_stats = self.obd2_protocol.get_stats()
uds_stats = self.uds_protocol.get_stats()
print("\033[2J\033[H") # Clear screen
print("=" * 60)
print(" OBD2 EMULATOR STATUS")
print(" OBD2 + UDS EMULATOR STATUS")
print("=" * 60)
print(f" Interface: {self.config.can.interface}")
print(f" Scenario: {self.scenario_manager.current_scenario.name if self.scenario_manager.current_scenario else 'None'}")
@@ -180,20 +190,28 @@ class OBD2Emulator:
print(f" Gear: {state.gear:>6}")
print(f" Distance: {state.total_distance:>6.1f} km")
print("-" * 60)
print(" TURBO (UDS):")
print(f" Boost: {state.boost_pressure_actual:>6.1f} kPa")
print(f" Wastegate: {state.wastegate_actual:>6.1f} %")
print(f" Lambda: {state.lambda_actual:>6.3f} λ")
print(f" Torque: {state.torque_nm:>6.1f} Nm")
print(f" Ign Timing: {state.ignition_timing:>6.1f} °")
print("-" * 60)
print(" TEMPERATURES:")
print(f" Coolant: {state.coolant_temp:>6.1f} °C")
print(f" Oil: {state.oil_temp:>6.1f} °C")
print(f" Intake Air: {state.intake_temp:>6.1f} °C")
print(f" Ambient: {state.ambient_temp:>6.1f} °C")
print(f" Trans Oil: {state.transmission_oil_temp:>6.1f} °C")
print("-" * 60)
print(" FUEL:")
print(f" Level: {state.fuel_level:>6.1f} %")
print(f" Rate: {state.fuel_rate:>6.2f} L/h")
print(f" Rail Press: {state.fuel_rail_pressure_uds:>6.0f} kPa")
print("-" * 60)
print(" OBD2 STATS:")
print(f" Requests: {stats['requests_received']:>6}")
print(f" Responses: {stats['responses_sent']:>6}")
print(f" Unsupported: {stats['unsupported_requests']:>6}")
print(" PROTOCOL STATS:")
print(f" OBD2 Req: {obd2_stats['requests_received']:>6} | UDS Req: {uds_stats['requests_received']:>6}")
print(f" OBD2 Resp: {obd2_stats['responses_sent']:>6} | UDS Resp: {uds_stats['responses_sent']:>6}")
print(f" OBD2 Unsup: {obd2_stats['unsupported_requests']:>6} | UDS Neg: {uds_stats['negative_responses']:>6}")
print("=" * 60)
print(" Press Ctrl+C to stop")
print("=" * 60)

View File

@@ -3,6 +3,7 @@
Симулирует двигатель, трансмиссию, температуры и расход топлива.
"""
import math
import random
import time
from dataclasses import dataclass, field
from typing import Callable
@@ -57,6 +58,29 @@ class VehicleState:
engine_running: bool = False
gear: int = 0 # 0 = N/P, 1-6 = gears
# === UDS Engine ECU параметры ===
boost_pressure_actual: float = 101.0 # kPa (атмосферное = 101)
boost_pressure_target: float = 101.0 # kPa
fuel_rail_pressure_uds: float = 5000.0 # kPa (UDS scale: /10)
lambda_actual: float = 1.0 # λ (стехиометрия = 1.0)
lambda_target: float = 1.0 # λ
torque_nm: float = 0.0 # Nm (абсолютный момент)
wastegate_actual: float = 0.0 # % (0 = closed, 100 = open)
wastegate_target: float = 0.0 # %
ignition_timing: float = 10.0 # ° (опережение зажигания)
timing_correction_cyl1: float = 0.0 # ° (коррекция по детонации)
timing_correction_cyl2: float = 0.0 # °
timing_correction_cyl3: float = 0.0 # °
timing_correction_cyl4: float = 0.0 # °
# === UDS DSG Transmission параметры ===
current_gear_dsg: int = 0 # -1=R, 0=N, 1-7=gears
gear_selector_position: int = 0 # 0=P, 1=R, 2=N, 3=D, 4=S, 5=M
transmission_oil_temp: float = 20.0 # °C
# === UDS Instrument Cluster параметры ===
ambient_light: int = 128 # 0-255 (0=dark, 255=bright)
def __post_init__(self):
self._start_time = time.time()
@@ -385,8 +409,132 @@ class VehicleSimulator:
self.state.total_distance += distance_km
self.state.distance_since_clear += distance_km
# === UDS параметры ===
self._update_uds_parameters(dt)
return self.state
def _update_uds_parameters(self, dt: float) -> None:
"""Обновить UDS-специфичные параметры."""
state = self.state
# --- Турбонаддув (boost) ---
# Boost зависит от нагрузки и оборотов
if state.engine_load > 30 and state.rpm > 2000:
# Целевое давление наддува (макс 250 kPa при полной нагрузке)
target_boost = 101 + (state.engine_load - 30) * 2.1
state.boost_pressure_target = min(250, target_boost)
# Фактическое давление следует за целевым с задержкой
diff = state.boost_pressure_target - state.boost_pressure_actual
state.boost_pressure_actual += diff * 0.3 * dt * 10
else:
# Атмосферное давление
state.boost_pressure_target = 101.0
diff = 101.0 - state.boost_pressure_actual
state.boost_pressure_actual += diff * 0.5 * dt * 10
# --- Wastegate (вестгейт) ---
# Вестгейт открывается для сброса избыточного давления
if state.boost_pressure_actual > state.boost_pressure_target:
state.wastegate_target = min(100, (state.boost_pressure_actual - state.boost_pressure_target) * 2)
else:
state.wastegate_target = max(0, 100 - state.engine_load)
diff = state.wastegate_target - state.wastegate_actual
state.wastegate_actual += diff * 0.2 * dt * 10
# --- Lambda (AFR) ---
# Стехиометрия = 1.0, богатая смесь при полной нагрузке
if state.engine_load > 80:
state.lambda_target = 0.85 # Богатая смесь для охлаждения
elif state.engine_load > 50:
state.lambda_target = 0.95
else:
state.lambda_target = 1.0 # Стехиометрия
# Фактическая lambda колеблется около целевой
diff = state.lambda_target - state.lambda_actual
state.lambda_actual += diff * 0.5 * dt * 10
# Небольшие колебания
state.lambda_actual += random.uniform(-0.01, 0.01)
state.lambda_actual = max(0.7, min(1.3, state.lambda_actual))
# --- Torque (крутящий момент) ---
# Максимальный момент зависит от RPM (кривая момента)
max_torque = self.config.max_torque_nm
if state.rpm < 1500:
torque_factor = state.rpm / 1500 * 0.7
elif state.rpm < 4500:
torque_factor = 0.7 + (state.rpm - 1500) / 3000 * 0.3
else:
torque_factor = 1.0 - (state.rpm - 4500) / 2000 * 0.2
state.torque_nm = max_torque * torque_factor * (state.engine_load / 100)
# --- Fuel Rail Pressure (UDS) ---
# Давление топлива зависит от нагрузки
base_pressure = 5000 # kPa
if state.engine_running:
state.fuel_rail_pressure_uds = base_pressure + state.engine_load * 150
else:
state.fuel_rail_pressure_uds = 0
# --- Ignition Timing ---
# Базовое опережение + коррекция от оборотов и нагрузки
base_timing = 10.0
rpm_advance = (state.rpm - 1000) / 500 # +2° на каждые 1000 RPM
load_retard = state.engine_load * 0.15 # Уменьшение при нагрузке
state.ignition_timing = base_timing + rpm_advance - load_retard
state.ignition_timing = max(-5, min(40, state.ignition_timing))
# --- Timing Corrections (детонация) ---
# При высокой нагрузке и температуре возможна детонация
knock_probability = (state.engine_load / 100) * (state.coolant_temp / 100) * 0.3
if random.random() < knock_probability:
# Случайная коррекция на одном из цилиндров
cyl = random.randint(1, 4)
correction = random.uniform(-3, 0)
if cyl == 1:
state.timing_correction_cyl1 = correction
elif cyl == 2:
state.timing_correction_cyl2 = correction
elif cyl == 3:
state.timing_correction_cyl3 = correction
else:
state.timing_correction_cyl4 = correction
else:
# Постепенное восстановление
state.timing_correction_cyl1 *= 0.95
state.timing_correction_cyl2 *= 0.95
state.timing_correction_cyl3 *= 0.95
state.timing_correction_cyl4 *= 0.95
# --- DSG Gear ---
state.current_gear_dsg = state.gear
# --- Gear Selector Position ---
# 0=P, 1=R, 2=N, 3=D, 4=S, 5=M
if state.speed < 1 and state.throttle_pos < 1:
if state.gear == 0:
state.gear_selector_position = 2 # Neutral
else:
state.gear_selector_position = 3 # Drive
else:
state.gear_selector_position = 3 # Drive
# --- Transmission Oil Temperature ---
# Следует за температурой масла двигателя с задержкой
target_trans_temp = state.oil_temp - 10
diff = target_trans_temp - state.transmission_oil_temp
state.transmission_oil_temp += diff * 0.01 * dt
# --- Ambient Light ---
# Симулируем дневной свет (можно менять в сценариях)
# По умолчанию: дневной свет = 200
if not hasattr(self, '_ambient_light_target'):
self._ambient_light_target = 200
state.ambient_light = int(self._ambient_light_target)
def _update_speed(self, dt: float) -> None:
"""Обновить скорость автомобиля."""
current = self.state.speed

19
src/uds/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""
UDS (Unified Diagnostic Services) протокол согласно ISO 14229.
Поддержка Service 0x22 (Read Data By Identifier).
"""
from .protocol import UDSProtocol, UDSRequest, UDSResponse
from .pids import UDSService, UDSPID, UDSPIDRegistry, get_uds_pid_registry
from .ecu import ECUType, ECU_CONFIG
__all__ = [
"UDSProtocol",
"UDSRequest",
"UDSResponse",
"UDSService",
"UDSPID",
"UDSPIDRegistry",
"get_uds_pid_registry",
"ECUType",
"ECU_CONFIG",
]

57
src/uds/ecu.py Normal file
View File

@@ -0,0 +1,57 @@
"""
Конфигурация ECU для UDS протокола.
Определяет CAN ID для каждого ECU.
"""
from dataclasses import dataclass
from enum import Enum, auto
class ECUType(Enum):
"""Типы ECU в автомобиле."""
ENGINE = auto() # Блок управления двигателем
TRANSMISSION = auto() # Блок управления трансмиссией (DSG)
INSTRUMENT_CLUSTER = auto() # Приборная панель
@dataclass
class ECUConfig:
"""Конфигурация ECU."""
name: str
request_id: int # CAN ID для запросов
response_id: int # CAN ID для ответов
# Конфигурация ECU согласно спецификации
ECU_CONFIG: dict[ECUType, ECUConfig] = {
ECUType.ENGINE: ECUConfig(
name="Engine ECU",
request_id=0x7E0,
response_id=0x7E8,
),
ECUType.TRANSMISSION: ECUConfig(
name="DSG Transmission",
request_id=0x7E1,
response_id=0x7E9,
),
ECUType.INSTRUMENT_CLUSTER: ECUConfig(
name="Instrument Cluster",
request_id=0x714,
response_id=0x77E,
),
}
def get_ecu_by_request_id(request_id: int) -> ECUType | None:
"""Получить тип ECU по CAN ID запроса."""
for ecu_type, config in ECU_CONFIG.items():
if config.request_id == request_id:
return ecu_type
return None
def get_response_id(request_id: int) -> int | None:
"""Получить CAN ID ответа для заданного CAN ID запроса."""
for config in ECU_CONFIG.values():
if config.request_id == request_id:
return config.response_id
return None

351
src/uds/pids.py Normal file
View File

@@ -0,0 +1,351 @@
"""
Определения UDS PIDs (Data Identifiers) согласно ISO 14229.
Поддержка Service 0x22 (Read Data By Identifier).
"""
from dataclasses import dataclass
from enum import IntEnum
from typing import Callable
from .ecu import ECUType
class UDSService(IntEnum):
"""UDS сервисы."""
READ_DATA_BY_ID = 0x22 # Чтение данных по идентификатору
READ_DATA_BY_ID_RESPONSE = 0x62 # Положительный ответ
NEGATIVE_RESPONSE = 0x7F # Отрицательный ответ
class UDSNegativeResponse(IntEnum):
"""Коды отрицательных ответов UDS."""
SERVICE_NOT_SUPPORTED = 0x11
SUB_FUNCTION_NOT_SUPPORTED = 0x12
INCORRECT_MESSAGE_LENGTH = 0x13
CONDITIONS_NOT_CORRECT = 0x22
REQUEST_OUT_OF_RANGE = 0x31
SECURITY_ACCESS_DENIED = 0x33
GENERAL_REJECT = 0x10
@dataclass
class UDSPID:
"""Определение UDS PID (Data Identifier)."""
did: int # Data Identifier (2 bytes)
name: str
description: str
unit: str
min_value: float
max_value: float
bytes_count: int
ecu: ECUType # К какому ECU принадлежит
encoder: Callable[[float], bytes]
decoder: Callable[[bytes], float] | None = None
signed: bool = False # Для знаковых значений (timing correction)
class UDSPIDRegistry:
"""Реестр всех поддерживаемых UDS PIDs."""
def __init__(self):
self._pids: dict[tuple[ECUType, int], UDSPID] = {}
self._register_engine_pids()
self._register_cluster_pids()
self._register_dsg_pids()
def register(self, pid: UDSPID) -> None:
"""Зарегистрировать PID."""
key = (pid.ecu, pid.did)
self._pids[key] = pid
def get(self, ecu: ECUType, did: int) -> UDSPID | None:
"""Получить PID по ECU и DID."""
return self._pids.get((ecu, did))
def get_supported_dids(self, ecu: ECUType) -> list[int]:
"""Получить список поддерживаемых DIDs для ECU."""
return [did for (e, did) in self._pids.keys() if e == ecu]
def _encode_unsigned_2bytes(self, value: float, scale: float = 1.0) -> bytes:
"""Кодировать unsigned 2-байтное значение."""
raw = int(value / scale)
raw = max(0, min(0xFFFF, raw))
return raw.to_bytes(2, "big")
def _encode_signed_2bytes(self, value: float, scale: float = 1.0) -> bytes:
"""Кодировать signed 2-байтное значение (two's complement)."""
raw = int(value / scale)
if raw < 0:
raw = 0x10000 + raw # Two's complement
raw = raw & 0xFFFF
return raw.to_bytes(2, "big")
def _register_engine_pids(self) -> None:
"""Регистрация PIDs для Engine ECU."""
# PID 0x202A - Boost Pressure Actual
self.register(UDSPID(
did=0x202A,
name="BOOST_PRESSURE_ACTUAL",
description="Boost Pressure Actual",
unit="kPa",
min_value=0,
max_value=400,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_unsigned_2bytes(v, 0.1),
decoder=lambda b: int.from_bytes(b, "big") * 0.1
))
# PID 0x2029 - Boost Pressure Target
self.register(UDSPID(
did=0x2029,
name="BOOST_PRESSURE_TARGET",
description="Boost Pressure Target",
unit="kPa",
min_value=0,
max_value=400,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_unsigned_2bytes(v, 0.1),
decoder=lambda b: int.from_bytes(b, "big") * 0.1
))
# PID 0xF423 - Fuel Rail Pressure
self.register(UDSPID(
did=0xF423,
name="FUEL_RAIL_PRESSURE",
description="Fuel Rail Pressure",
unit="kPa",
min_value=0,
max_value=25000,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_unsigned_2bytes(v, 10.0),
decoder=lambda b: int.from_bytes(b, "big") * 10
))
# PID 0x10C0 - Lambda Actual
self.register(UDSPID(
did=0x10C0,
name="LAMBDA_ACTUAL",
description="Lambda Actual",
unit="λ",
min_value=0.5,
max_value=2.0,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_unsigned_2bytes(v, 0.000977),
decoder=lambda b: int.from_bytes(b, "big") * 0.000977
))
# PID 0x1456 - Lambda Target
self.register(UDSPID(
did=0x1456,
name="LAMBDA_TARGET",
description="Lambda Target",
unit="λ",
min_value=0.5,
max_value=2.0,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_unsigned_2bytes(v, 0.000977),
decoder=lambda b: int.from_bytes(b, "big") * 0.000977
))
# PID 0x437C - Torque Actual
self.register(UDSPID(
did=0x437C,
name="TORQUE_ACTUAL",
description="Torque Actual",
unit="Nm",
min_value=0,
max_value=500,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_unsigned_2bytes(v, 0.1),
decoder=lambda b: int.from_bytes(b, "big") * 0.1
))
# PID 0x39A2 - Wastegate Position Actual
self.register(UDSPID(
did=0x39A2,
name="WASTEGATE_ACTUAL",
description="Wastegate Position Actual",
unit="%",
min_value=0,
max_value=100,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_unsigned_2bytes(v, 0.01),
decoder=lambda b: int.from_bytes(b, "big") * 0.01
))
# PID 0x39A3 - Wastegate Position Target
self.register(UDSPID(
did=0x39A3,
name="WASTEGATE_TARGET",
description="Wastegate Position Target",
unit="%",
min_value=0,
max_value=100,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_unsigned_2bytes(v, 0.01),
decoder=lambda b: int.from_bytes(b, "big") * 0.01
))
# PID 0x2004 - Ignition Timing
self.register(UDSPID(
did=0x2004,
name="IGNITION_TIMING",
description="Ignition Timing",
unit="°",
min_value=-10,
max_value=50,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_signed_2bytes(v, 0.01),
decoder=lambda b: self._decode_signed_2bytes(b, 0.01),
signed=True
))
# PIDs 0x200A-0x200D - Timing Correction Cylinders 1-4
for i, did in enumerate([0x200A, 0x200B, 0x200C, 0x200D], start=1):
self.register(UDSPID(
did=did,
name=f"TIMING_CORRECTION_CYL{i}",
description=f"Timing Correction Cylinder {i}",
unit="°",
min_value=-15,
max_value=5,
bytes_count=2,
ecu=ECUType.ENGINE,
encoder=lambda v: self._encode_signed_2bytes(v, 0.01),
decoder=lambda b: self._decode_signed_2bytes(b, 0.01),
signed=True
))
def _register_cluster_pids(self) -> None:
"""Регистрация PIDs для Instrument Cluster."""
# PID 0x22D1 - RPM from Cluster
self.register(UDSPID(
did=0x22D1,
name="CLUSTER_RPM",
description="RPM from Cluster",
unit="RPM",
min_value=0,
max_value=8000,
bytes_count=2,
ecu=ECUType.INSTRUMENT_CLUSTER,
encoder=lambda v: int(v * 4).to_bytes(2, "big"),
decoder=lambda b: int.from_bytes(b, "big") / 4
))
# PID 0x224D - Ambient Light Sensor
self.register(UDSPID(
did=0x224D,
name="AMBIENT_LIGHT",
description="Ambient Light Sensor",
unit="",
min_value=0,
max_value=255,
bytes_count=1,
ecu=ECUType.INSTRUMENT_CLUSTER,
encoder=lambda v: bytes([int(v)]),
decoder=lambda b: b[0]
))
def _register_dsg_pids(self) -> None:
"""Регистрация PIDs для DSG Transmission."""
# PID 0x3816 - Current Gear
self.register(UDSPID(
did=0x3816,
name="CURRENT_GEAR",
description="Current Gear",
unit="",
min_value=-1,
max_value=7,
bytes_count=1,
ecu=ECUType.TRANSMISSION,
encoder=self._encode_gear,
decoder=self._decode_gear
))
# PID 0x3815 - Gear Selector Position
self.register(UDSPID(
did=0x3815,
name="GEAR_SELECTOR",
description="Gear Selector Position (PRNDM)",
unit="",
min_value=0,
max_value=5,
bytes_count=1,
ecu=ECUType.TRANSMISSION,
encoder=lambda v: bytes([int(v)]),
decoder=lambda b: b[0]
))
# PID 0x1940 - Transmission Oil Temperature
self.register(UDSPID(
did=0x1940,
name="TRANS_OIL_TEMP",
description="Transmission Oil Temperature",
unit="°C",
min_value=-40,
max_value=150,
bytes_count=1,
ecu=ECUType.TRANSMISSION,
encoder=lambda v: bytes([int(v + 40)]),
decoder=lambda b: b[0] - 40
))
def _decode_signed_2bytes(self, data: bytes, scale: float) -> float:
"""Декодировать signed 2-байтное значение."""
raw = int.from_bytes(data, "big")
if raw >= 0x8000:
raw -= 0x10000
return raw * scale
def _encode_gear(self, gear: float) -> bytes:
"""Кодировать передачу в DSG формат."""
gear_map = {
0: 0x00, # Neutral
-1: 0x0C, # Reverse
1: 0x02,
2: 0x04,
3: 0x06,
4: 0x08,
5: 0x0A,
6: 0x0E,
7: 0x10,
}
return bytes([gear_map.get(int(gear), 0x00)])
def _decode_gear(self, data: bytes) -> float:
"""Декодировать передачу из DSG формата."""
gear_map = {
0x00: 0, # Neutral
0x0C: -1, # Reverse
0x02: 1,
0x04: 2,
0x06: 3,
0x08: 4,
0x0A: 5,
0x0E: 6,
0x10: 7,
}
return gear_map.get(data[0], 0)
# Синглтон реестра
_uds_pid_registry: UDSPIDRegistry | None = None
def get_uds_pid_registry() -> UDSPIDRegistry:
"""Получить глобальный реестр UDS PIDs."""
global _uds_pid_registry
if _uds_pid_registry is None:
_uds_pid_registry = UDSPIDRegistry()
return _uds_pid_registry

250
src/uds/protocol.py Normal file
View File

@@ -0,0 +1,250 @@
"""
UDS протокол (ISO 14229) поверх CAN.
Обработка запросов Service 0x22 (Read Data By Identifier).
"""
from dataclasses import dataclass
from typing import TYPE_CHECKING
from can_interface import CANFrame
from logger import get_logger
from .ecu import ECUType, ECU_CONFIG, get_ecu_by_request_id, get_response_id
from .pids import UDSService, UDSNegativeResponse, get_uds_pid_registry
if TYPE_CHECKING:
from simulator.vehicle import VehicleState
logger = get_logger(__name__)
@dataclass
class UDSRequest:
"""Разобранный UDS запрос."""
service: int
did: int # Data Identifier (2 bytes)
ecu: ECUType
request_id: int # CAN ID запроса
raw_data: bytes
@classmethod
def from_can_frame(cls, frame: CANFrame) -> "UDSRequest | None":
"""Распарсить CAN фрейм как UDS запрос."""
# Проверяем, является ли это запросом к известному ECU
ecu = get_ecu_by_request_id(frame.arbitration_id)
if ecu is None:
return None
data = frame.data
if len(data) < 4:
return None
# Первый байт - длина данных (PCI byte для Single Frame)
length = data[0]
if length < 3: # Минимум: service + DID (2 bytes)
return None
# Второй байт - сервис
service = data[1]
# Поддерживаем только Service 0x22
if service != UDSService.READ_DATA_BY_ID:
return None
# Третий и четвёртый байты - DID
did = (data[2] << 8) | data[3]
return cls(
service=service,
did=did,
ecu=ecu,
request_id=frame.arbitration_id,
raw_data=data
)
def __str__(self) -> str:
ecu_name = ECU_CONFIG[self.ecu].name
return f"UDSRequest(ecu={ecu_name}, service={self.service:02X}, did={self.did:04X})"
@dataclass
class UDSResponse:
"""UDS ответ для отправки."""
service: int # Response service (0x62 или 0x7F)
did: int # Data Identifier
data: bytes # Данные ответа
response_id: int # CAN ID ответа
is_negative: bool = False
negative_code: int = 0
def to_can_frame(self) -> CANFrame:
"""Преобразовать в CAN фрейм."""
if self.is_negative:
# Negative response: [length, 0x7F, service, error_code]
payload = bytes([
3, # Length
UDSService.NEGATIVE_RESPONSE,
UDSService.READ_DATA_BY_ID,
self.negative_code,
])
else:
# Positive response: [length, 0x62, DID_H, DID_L, data...]
did_h = (self.did >> 8) & 0xFF
did_l = self.did & 0xFF
payload = bytes([
len(self.data) + 3, # Length: service + DID + data
UDSService.READ_DATA_BY_ID_RESPONSE,
did_h,
did_l,
]) + self.data
# Дополняем до 8 байт
payload = payload.ljust(8, b'\x00')
return CANFrame(
arbitration_id=self.response_id,
data=payload
)
@classmethod
def negative(cls, did: int, response_id: int, error_code: int) -> "UDSResponse":
"""Создать отрицательный ответ."""
return cls(
service=UDSService.NEGATIVE_RESPONSE,
did=did,
data=bytes(),
response_id=response_id,
is_negative=True,
negative_code=error_code
)
class UDSProtocol:
"""
Обработчик UDS протокола.
Получает запросы Service 0x22, формирует ответы на основе состояния автомобиля.
"""
def __init__(self):
self.pid_registry = get_uds_pid_registry()
self._stats = {
"requests_received": 0,
"responses_sent": 0,
"unsupported_requests": 0,
"negative_responses": 0,
}
def process_frame(self, frame: CANFrame, vehicle_state: "VehicleState") -> CANFrame | None:
"""
Обработать входящий CAN фрейм.
Возвращает ответный фрейм или None, если фрейм не является UDS запросом.
"""
request = UDSRequest.from_can_frame(frame)
if request is None:
return None
self._stats["requests_received"] += 1
logger.debug(f"UDS Received: {request}")
response = self._handle_request(request, vehicle_state)
if response:
self._stats["responses_sent"] += 1
if response.is_negative:
self._stats["negative_responses"] += 1
return response.to_can_frame()
self._stats["unsupported_requests"] += 1
return None
def _handle_request(self, request: UDSRequest, state: "VehicleState") -> UDSResponse | None:
"""Обработать UDS запрос и вернуть ответ."""
response_id = get_response_id(request.request_id)
if response_id is None:
return None
# Получаем определение PID
pid_def = self.pid_registry.get(request.ecu, request.did)
if pid_def is None:
logger.debug(f"UDS DID {request.did:04X} not supported for {request.ecu}")
return UDSResponse.negative(
request.did,
response_id,
UDSNegativeResponse.REQUEST_OUT_OF_RANGE
)
# Получаем значение из состояния автомобиля
value = self._get_did_value(request.ecu, request.did, state)
if value is None:
logger.debug(f"UDS DID {request.did:04X} no value available")
return UDSResponse.negative(
request.did,
response_id,
UDSNegativeResponse.CONDITIONS_NOT_CORRECT
)
# Кодируем значение
try:
encoded = pid_def.encoder(value)
return UDSResponse(
service=UDSService.READ_DATA_BY_ID_RESPONSE,
did=request.did,
data=encoded,
response_id=response_id
)
except Exception as e:
logger.error(f"Failed to encode UDS DID {request.did:04X}: {e}")
return UDSResponse.negative(
request.did,
response_id,
UDSNegativeResponse.GENERAL_REJECT
)
def _get_did_value(self, ecu: ECUType, did: int, state: "VehicleState") -> float | None:
"""Получить значение DID из состояния автомобиля."""
if ecu == ECUType.ENGINE:
return self._get_engine_value(did, state)
elif ecu == ECUType.TRANSMISSION:
return self._get_transmission_value(did, state)
elif ecu == ECUType.INSTRUMENT_CLUSTER:
return self._get_cluster_value(did, state)
return None
def _get_engine_value(self, did: int, state: "VehicleState") -> float | None:
"""Получить значение для Engine ECU."""
mapping = {
0x202A: state.boost_pressure_actual, # Boost Pressure Actual
0x2029: state.boost_pressure_target, # Boost Pressure Target
0xF423: state.fuel_rail_pressure_uds, # Fuel Rail Pressure (UDS scale)
0x10C0: state.lambda_actual, # Lambda Actual
0x1456: state.lambda_target, # Lambda Target
0x437C: state.torque_nm, # Torque Actual (Nm)
0x39A2: state.wastegate_actual, # Wastegate Position Actual
0x39A3: state.wastegate_target, # Wastegate Position Target
0x2004: state.ignition_timing, # Ignition Timing
0x200A: state.timing_correction_cyl1, # Timing Correction Cyl 1
0x200B: state.timing_correction_cyl2, # Timing Correction Cyl 2
0x200C: state.timing_correction_cyl3, # Timing Correction Cyl 3
0x200D: state.timing_correction_cyl4, # Timing Correction Cyl 4
}
return mapping.get(did)
def _get_transmission_value(self, did: int, state: "VehicleState") -> float | None:
"""Получить значение для DSG Transmission."""
mapping = {
0x3816: state.current_gear_dsg, # Current Gear
0x3815: state.gear_selector_position, # Gear Selector (PRNDM)
0x1940: state.transmission_oil_temp, # Transmission Oil Temp
}
return mapping.get(did)
def _get_cluster_value(self, did: int, state: "VehicleState") -> float | None:
"""Получить значение для Instrument Cluster."""
mapping = {
0x22D1: state.rpm, # RPM (same as OBD2)
0x224D: state.ambient_light, # Ambient Light Sensor
}
return mapping.get(did)
def get_stats(self) -> dict:
"""Получить статистику протокола."""
return self._stats.copy()