diff --git a/ECU_EMULATOR_SPEC.md b/ECU_EMULATOR_SPEC.md new file mode 100644 index 0000000..e15e004 --- /dev/null +++ b/ECU_EMULATOR_SPEC.md @@ -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 | diff --git a/docs/UDS_PROTOCOL.md b/docs/UDS_PROTOCOL.md new file mode 100644 index 0000000..2020904 --- /dev/null +++ b/docs/UDS_PROTOCOL.md @@ -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 diff --git a/scripts/test_obd2.py b/scripts/test_obd2.py index 9aa8ebf..8fcb766 100644 --- a/scripts/test_obd2.py +++ b/scripts/test_obd2.py @@ -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 + # 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" + # 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: - # RPM: (A*256 + B) / 4 if len(data) >= 2: rpm = (data[0] * 256 + data[1]) / 4 return f"{rpm:.0f} rpm" + # Speed: A elif pid == 0x0D: - # Speed: A return f"{data[0]} km/h" - elif pid == 0x11 or pid == 0x2F: - # Percentage: A * 100 / 255 - return f"{data[0] * 100 / 255:.1f} %" + # 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()}" diff --git a/scripts/test_uds.py b/scripts/test_uds.py new file mode 100644 index 0000000..8845ce7 --- /dev/null +++ b/scripts/test_uds.py @@ -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()) diff --git a/src/config.py b/src/config.py index df60296..cabf5f6 100644 --- a/src/config.py +++ b/src/config.py @@ -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) diff --git a/src/main.py b/src/main.py index 5c084cd..2e54e31 100644 --- a/src/main.py +++ b/src/main.py @@ -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) diff --git a/src/simulator/vehicle.py b/src/simulator/vehicle.py index 3b3613d..79f38c4 100644 --- a/src/simulator/vehicle.py +++ b/src/simulator/vehicle.py @@ -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 diff --git a/src/uds/__init__.py b/src/uds/__init__.py new file mode 100644 index 0000000..74ac947 --- /dev/null +++ b/src/uds/__init__.py @@ -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", +] diff --git a/src/uds/ecu.py b/src/uds/ecu.py new file mode 100644 index 0000000..0dce826 --- /dev/null +++ b/src/uds/ecu.py @@ -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 diff --git a/src/uds/pids.py b/src/uds/pids.py new file mode 100644 index 0000000..3e41e40 --- /dev/null +++ b/src/uds/pids.py @@ -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 diff --git a/src/uds/protocol.py b/src/uds/protocol.py new file mode 100644 index 0000000..b708207 --- /dev/null +++ b/src/uds/protocol.py @@ -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()