update 0.1.9

This commit is contained in:
controllerzz
2026-06-17 20:44:18 +03:00
parent 32163c0001
commit 99451aceb8
12 changed files with 814 additions and 275 deletions
+1
View File
@@ -206,3 +206,4 @@ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
*.xml *.xml
/RELEASE.md
+357 -208
View File
@@ -1,27 +1,67 @@
# carbus-lib (async CAN / ISO-TP / UDS stack) # carbus-lib
Асинхронная библиотека на Python для работы с CAN-адаптером **CAN-Hacker / Car Bus Analyzer**: Асинхронная Python-библиотека для работы с CAN / CAN-FD адаптерами
**CAN-Hacker / Car Bus Analyzer**: низкоуровневый CAN/LIN, ISO-TP, UDS и удалённый
доступ к адаптеру через сеть.
- 📡 **`carbus_async`** низкоуровневая работа с железкой (CAN/LIN, фильтры, терминаторы и т.д.) - 📡 **`carbus_async`** работа с железом: CAN/CAN-FD, фильтры, терминатор 120 Ω, периодическая отправка, хуки
- 📦 **`isotp_async`** ISO-TP (ISO 15765-2) поверх CAN (single + multi-frame) - 📦 **`isotp_async`** ISO-TP (ISO 15765-2): single- и multi-frame
- 🩺 **`uds_async`** UDS (ISO 14229) клиент и сервер (диагностика, чтение VIN и т.п.) - 🩺 **`uds_async`** UDS (ISO 14229): клиент и сервер (эмуляция ЭБУ)
- 🌐 **`Remote/TCP-bridge`** удалённое подключение к адаптеру через сеть (как будто он воткнут локально) - 🌐 **Remote / TCP-bridge** удалённый доступ к адаптеру через сеть, как к локальному
> Python 3.10 и выше > Python 3.10 · зависимости: `pyserial`, `pyserial-asyncio` · целиком на `asyncio`
> Никаких «магических» зависимостей — всё на `asyncio`. > Протестировано на устройствах с **Протоколом версии 22**
> Поддерживаемые интерфейсы: https://canhacker.ru/shop/ > Поддерживаемые устройства: https://canhacker.ru/shop/
> _*Тестировалось на устройствах с Протоколом Версии 22_
---
## Содержание
- [Возможности](#возможности)
- [Установка](#установка)
- [Быстрый старт](#быстрый-старт)
- [CAN-FD и BRS](#can-fd-и-brs)
- [Приём и хуки](#приём-и-хуки)
- [Периодическая отправка](#периодическая-отправка)
- [Фильтры](#фильтры)
- [Терминатор](#терминатор)
- [Информация об устройстве](#информация-об-устройстве)
- [Маршрутизация по CAN-ID](#маршрутизация-по-can-id)
- [ISO-TP](#iso-tp)
- [UDS клиент](#uds-клиент)
- [UDS сервер](#uds-сервер)
- [Удалённая работа](#удалённая-работа)
- [Логирование](#логирование)
- [Примеры](#примеры)
- [Лицензия](#лицензия)
---
## Возможности
- CAN / CAN-FD: отправка и приём, BRS (bit-rate switching)
- Настройка каналов по битрейту или по точным bit-timing
- Фильтры по 11- и 29-битным ID, очистка фильтров
- Управление встроенным терминатором 120 Ω
- Периодическая отправка кадров (статичные данные или с модификацией на лету)
- Хуки на приём кадров по ID и по маске данных
- Маршрутизация принятых кадров по CAN-ID в отдельные очереди
- ISO-TP (single + multi-frame)
- UDS клиент и UDS сервер (эмуляция ЭБУ)
- Удалённая работа через relay-сервер и TCP-мост
- Подробное логирование всего протокольного трафика
--- ---
## Установка ## Установка
Через систему управления пакетами PIP Из PyPI:
````bash
python -m pip install carbus-lib
````
Либо как editable-модуль из репозитория: ```bash
python -m pip install carbus-lib
```
Либо editable-режим из репозитория (нужно для разработки):
```bash ```bash
git clone https://github.com/controllerzz/carbus_lib.git git clone https://github.com/controllerzz/carbus_lib.git
@@ -29,142 +69,137 @@ cd carbus_lib
pip install -e . pip install -e .
``` ```
# carbus-lib
Асинхронная библиотека для работы с CAN / CAN-FD, ISO-TP и UDS.
Поддерживает локальное подключение через USB CDC и удалённую работу через TCP-bridge.
--- ---
## Возможности ## Быстрый старт
- CAN / CAN-FD отправка и приём Открыть устройство, настроить классический CAN-канал, отправить и принять кадр.
- Настройка каналов, скоростей, режимов, BRS
- Фильтры ID, очистка фильтров, управление терминатором 120 Ω
- ISO-TP (single + multi-frame)
- UDS Client и UDS Server (эмуляция ЭБУ)
- TCP-мост: удалённая работа с адаптером так, как будто он подключён локально
- Логирование всего протокольного трафика
--- ```python
## Работа с CAN
Простейший пример: открыть устройство, настроить канал и отправить / принять кадр.
````python
import asyncio import asyncio
from carbus_async.device import CarBusDevice from carbus_async import CarBusDevice, CanMessage
from carbus_async.messages import CanMessage
async def main(): async def main():
dev = await CarBusDevice.open("COM6", baudrate=115200) dev = await CarBusDevice.open("COM6", baudrate=115200)
# классический CAN 500 kbit/s # классический CAN, 500 kbit/s, канал 1
await dev.open_can_channel( await dev.open_can_channel(channel=1, nominal_bitrate=500_000)
channel=1,
nominal_bitrate=500_000,
)
# включаем терминатор 120 Ω на канале 1 # внутренний терминатор 120 Ω (если поддерживается устройством)
await dev.set_terminator(channel=1, enabled=True) await dev.ensure_terminator(channel=1, enabled=True)
# отправка кадра 0x7E0 8 байт # отправка кадра
msg = CanMessage(can_id=0x7E0, data=b"\x02\x3E\x00\x00\x00\x00\x00\x00") msg = CanMessage(can_id=0x7E0, data=b"\x02\x3E\x00\x00\x00\x00\x00\x00")
await dev.send_can(msg, channel=1) await dev.send_can(msg, channel=1)
# приём любого сообщения # приём любого кадра -> (channel, CanMessage)
ch, rx = await dev.receive_can() ch, rx = await dev.receive_can()
print("RX:", ch, rx) print("RX:", ch, hex(rx.can_id), rx.data.hex())
await dev.close() await dev.close()
asyncio.run(main())
````
## Настройка канала через Bit Timing asyncio.run(main())
Возможность конфигруации скорости CAN канала через Bit Timing ```
````python
# CANFD+BRS 500/2000 kbit/s Классический CAN через `open_can_channel` поддерживает битрейты
await dev.open_can_channel_custom( 10k / 20k / 33.3k / 50k / 62.5k / 83.3k / 95.2k / 100k / 125k / 250k / 400k / 500k / 800k / 1M.
---
## CAN-FD и BRS
### 1. По стандартным битрейтам
```python
await dev.open_can_channel(
channel=1, channel=1,
nominal_timing=CanTiming( nominal_bitrate=500_000, # арбитражная фаза
prescaler=15, data_bitrate=2_000_000, # фаза данных (используется при BRS)
tq_seg1=12,
tq_seg2=3,
sjw=1
),
data_timing=CanTiming(
prescaler=6,
tq_seg1=7,
tq_seg2=2,
sjw=1
),
fd=True, fd=True,
brs=True, brs=True,
) )
```` ```
## Получение информации об устройстве: Поддерживаемые битрейты (CAN-модуль 120 МГц, точка выборки ~80%):
Получение инфмормации об устройстве и его фичах
````python
info = await dev.get_device_info()
print("HW:", info.hardware_name) - **nominal:** 125k / 250k / 500k / 1M
print("FW:", info.firmware_version) - **data:** 500k / 1M / 2M / 4M / 5M
print("Serial:", info.serial_int)
print("Features:", > На этих адаптерах FD-канал настраивается точными bit-timing, а не одиночным
"gateway" if info.feature_gateway else "", > индексом скорости данных — иначе канал открывается, но FD/BRS-кадры не уходят
"isotp" if info.feature_isotp else "", > в шину. `open_can_channel` делает это автоматически. Если тактовая модуля не
"txbuf" if info.feature_tx_buffer else "", > 120 МГц, передайте её через `can_clock_hz=...`.
"txtask" if info.feature_tx_task else "",
)
````
## Пример настройки фильтров: ### 2. По точным bit-timing
11 bit фильтры имеют index от 0 до 27 включительно,
29 bit фильтры имеют index от 28 до 35 включительно
````python
# очистить все фильтры на канале 1
await dev.clear_all_filters(1)
# разрешить только ответы с ID 0x7E8 (11-битный стандартный ID) Для нестандартных скоростей задайте тайминги напрямую:
await dev.set_std_id_filter(
```python
from carbus_async import CanTiming
await dev.open_can_channel_custom(
channel=1, channel=1,
index=0, nominal_timing=CanTiming(prescaler=15, tq_seg1=12, tq_seg2=3, sjw=1), # 500k @ 120 МГц
can_id=0x7E8, data_timing=CanTiming(prescaler=6, tq_seg1=7, tq_seg2=2, sjw=1), # 2M @ 120 МГц
mask=0x7FF, fd=True,
brs=True,
) )
```` ```
## Управление терминатором 120 Ω: ### Отправка FD-кадра
Включаем терминатор на канале 1 и выключаем терминатор на канале 2
````python
await dev.set_terminator(channel=1, enabled=True)
await dev.set_terminator(channel=2, enabled=False)
````
Проверка наличия внутреннего терминатора у девайса Тип кадра задаётся флагами `fd` / `brs` в самом `CanMessage`:
````python
if await dev.has_terminator():
await dev.set_terminator(channel=1, enabled=True)
print("Device has an internal terminator")
else:
print("Device does not have an internal terminator")
````
Включаем терминатор на канале 1, если это поддерживает девайс ```python
````python # FD-кадр с переключением скорости (BRS), 16 байт данных
await dev.ensure_terminator(channel=1, enabled=True) msg = CanMessage(can_id=0x100, data=bytes(range(16)), fd=True, brs=True)
```` await dev.send_can(msg, channel=1)
```
## Отправка периодичных сообщений: > Допустимые длины данных CAN-FD: 08, 12, 16, 20, 24, 32, 48, 64 байт.
Отправка сообщений с статичными данными и периодом 100мс
````python ---
## Приём и хуки
Способы приёма:
```python
ch, msg = await dev.receive_can() # с любого канала
msg = await dev.receive_can_on(channel=1) # только канал 1
msg = await dev.receive_can_on_timeout(channel=1, timeout=1.0) # None при таймауте
```
Хуки (вызываются на каждый подходящий принятый кадр):
```python
# по CAN-ID
@dev.on_can_id(0x7E0)
async def on_engine(ch, msg):
print("ENGINE:", hex(msg.can_id), msg.data.hex())
# по CAN-ID + совпадению данных по маске
@dev.on_can_match(
can_id=0x7E0,
value=b"\x02\x10\x00",
mask=b"\xFF\xFF\x00",
)
async def on_session_control(ch, msg):
print("DiagnosticSessionControl")
```
---
## Периодическая отправка
```python
from carbus_async import PeriodicCanSender from carbus_async import PeriodicCanSender
sender = PeriodicCanSender(dev) sender = PeriodicCanSender(dev)
# статичные данные, период 100 мс (классический CAN)
sender.add( sender.add(
"heartbeat", "heartbeat",
channel=1, channel=1,
@@ -172,14 +207,8 @@ sender.add(
data=b"\x01\x02\x03\x04\x05\x06\x07\x08", data=b"\x01\x02\x03\x04\x05\x06\x07\x08",
period_s=0.1, period_s=0.1,
) )
````
Отправка сообщений с модификацией данных и периодом 500мс
````python
from carbus_async import PeriodicCanSender
sender = PeriodicCanSender(dev)
# CAN-FD + BRS со счётчиком в первом байте
def mod(tick, data): def mod(tick, data):
b = bytearray(data) b = bytearray(data)
b[0] = tick & 0xFF b[0] = tick & 0xFF
@@ -191,160 +220,280 @@ sender.add(
can_id=0x100, can_id=0x100,
data=b"\x00" * 8, data=b"\x00" * 8,
period_s=0.5, period_s=0.5,
modify=mod) fd=True,
```` brs=True,
modify=mod,
## Хуки подписка на сообщение / сообщение + данные по маске:
Подписка по CAN ID
````python
@dev.on_can_id(0x7E0)
async def on_engine_req(ch, msg):
print("ENGINE:", hex(msg.can_id), msg.data.hex())
````
Подписка по CAN ID + маске данных
````python
@dev.on_can_match(
can_id=0x7E0,
value=b"\x02\x10\x00",
mask=b"\xFF\xFF\x00",
) )
async def on_session_control(ch, msg):
print("SessionControl") # управление задачами
```` await sender.remove("cnt") # остановить и удалить одну задачу
await sender.stop_all() # остановить все
## ISO-TP (isotp_async) ```
ISO-TP канал строится поверх CarBusDevice:
````python Параметры `add(...)`: `channel`, `can_id`, `data`, `period_s`,
`modify` (функция `(tick, data) -> bytes`, может быть async), `extended`,
`fd`, `brs`, `rtr`, `echo`, `confirm`, `autostart`.
---
## Фильтры
11-битные фильтры занимают индексы `0..27`, 29-битные — `28..35`.
```python
# очистить все фильтры на канале 1
await dev.clear_all_filters(1)
# пропускать только ответы с ID 0x7E8 (11-бит)
await dev.set_std_id_filter(channel=1, index=0, can_id=0x7E8, mask=0x7FF)
# 29-битный фильтр
await dev.set_ext_id_filter(channel=1, index=28, can_id=0x18DAF110)
```
---
## Терминатор
```python
await dev.set_terminator(channel=1, enabled=True)
await dev.set_terminator(channel=2, enabled=False)
# проверка поддержки и включение
if await dev.has_terminator(channel=1):
await dev.set_terminator(channel=1, enabled=True)
# включить, только если устройство это поддерживает
await dev.ensure_terminator(channel=1, enabled=True)
```
---
## Информация об устройстве
```python
info = await dev.get_device_info()
print("HW:", info.hardware_name)
print("FW:", info.firmware_version)
print("Serial:", info.serial_int)
print("Каналы:", info.channel_types) # {1: 'CANFD', 2: 'CAN', ...}
print("Частоты:", info.channel_frequencies) # {1: 120000000, ...} Гц
print("Фичи:",
"gateway" if info.feature_gateway else "",
"isotp" if info.feature_isotp else "",
"txbuf" if info.feature_tx_buffer else "",
"txtask" if info.feature_tx_task else "")
```
---
## Маршрутизация по CAN-ID
`CanIdRouter` раскладывает принятые кадры по отдельным очередям на каждый CAN-ID —
удобно, когда на одном канале несколько независимых потоков.
```python
from carbus_async import CanIdRouter
router = CanIdRouter(dev, channel=1)
await router.start()
q = router.get_queue(0x7E8) # очередь только для кадров с ID 0x7E8
msg = await q.get()
print(msg.data.hex())
await router.stop()
```
Роутер можно передать в `open_isotp(..., router=router)`, чтобы держать несколько
ISO-TP соединений на одном канале, не «перехватывая» кадры друг друга.
---
## ISO-TP
ISO-TP-канал строится поверх `CarBusDevice`:
```python
from isotp_async import open_isotp from isotp_async import open_isotp
isotp = await open_isotp(dev, channel=1, tx_id=0x7E0, rx_id=0x7E8) isotp = await open_isotp(dev, channel=1, tx_id=0x7E0, rx_id=0x7E8)
# отправить запрос ReadDataByIdentifier F190 (VIN) # ReadDataByIdentifier F190 (VIN)
await isotp.send_pdu(b"\x22\xF1\x90") await isotp.send_pdu(b"\x22\xF1\x90")
# получить полный ответ (single или multi-frame) # полный ответ (single или multi-frame)
resp = await isotp.recv_pdu(timeout=5.0) resp = await isotp.recv_pdu(timeout=5.0)
print("ISO-TP:", resp.hex()) print("ISO-TP:", resp.hex())
```` ```
## UDS Client (uds_async.client) ---
Клиент UDS использует IsoTpChannel: ## UDS клиент
````python
```python
from isotp_async import open_isotp from isotp_async import open_isotp
from uds_async import UdsClient from uds_async import UdsClient
isotp = await open_isotp(dev, channel=1, tx_id=0x7E0, rx_id=0x7E8) isotp = await open_isotp(dev, channel=1, tx_id=0x7E0, rx_id=0x7E8)
uds = UdsClient(isotp) uds = UdsClient(isotp)
await uds.diagnostic_session_control(0x03) # extended session
vin = await uds.read_data_by_identifier(0xF190) vin = await uds.read_data_by_identifier(0xF190)
print("VIN:", vin.decode(errors="ignore")) print("VIN:", vin.decode(errors="ignore"))
```` await uds.tester_present()
```
## Удалённая работа через внешний сервер Доступные сервисы: `diagnostic_session_control`, `read_data_by_identifier`,
### 0. Бесплатный Сервер/Relay `write_data_by_identifier`, `security_access_get_seed`,
Можно воспользоваться бесплатным сервером: `security_access_send_key`, `tester_present`.
IP: 185.42.26.80 ---
PORT: 9000
### 1. Сервер/Relay ## UDS сервер
На сервере устанавливаем библиотеку carbus-lib, далее запускаем сервер
carbus-relay-server --host 0.0.0.0 --port 9000 Эмуляция ЭБУ: на каждый сервис (SID) вешается обработчик, возвращающий полный
или ответный PDU. Отрицательный ответ — через `UdsNegativeResponse`.
python -m carbus_async.remote.server --host 0.0.0.0 --port 9000 ```python
from isotp_async import open_isotp
from uds_async import UdsServer
from uds_async.exceptions import UdsNegativeResponse
### 2. Агент # слушаем запросы на 0x7E0, отвечаем на 0x7E8
На машине куда подключен девайс запускаем агента isotp = await open_isotp(dev, channel=1, tx_id=0x7E8, rx_id=0x7E0)
server = UdsServer(isotp)
carbus-relay-agent --port COM6 --baudrate 115200 --server <IP_СЕРВЕРА>:9000 --serial 5957 --password 1234 @server.service(0x22) # ReadDataByIdentifier
или async def on_rdbi(req: bytes) -> bytes:
did = (req[1] << 8) | req[2]
python -m carbus_async.remote.agent --port COM6 --baudrate 115200 --server <IP_СЕРВЕРА>:9000 --serial 5957 --password 1234 if did == 0xF190:
return b"\x62\xF1\x90" + b"WVWZZZ1KZAW000001"
raise UdsNegativeResponse(req_sid=0x22, nrc=0x31) # requestOutOfRange
где await server.serve_forever()
```
--server <IP_СЕРВЕРА>:9000 - адрес и порт сервера ---
--serial 5957 - серийный номер девайса
--password 1234 - пароль, требуется для получения уделнного доступа
### 3. Клиент (удалённая машина) ## Удалённая работа
Для получения удаленного доступа используем функцию ***open_remote_device***
````python ### Через relay-сервер (любой IP)
Можно воспользоваться бесплатным сервером:
```
IP: 185.42.26.80
PORT: 9000
```
**Сервер / Relay** (машина с белым IP):
```bash
carbus-relay-server --host 0.0.0.0 --port 9000
# или
python -m carbus_async.remote.server --host 0.0.0.0 --port 9000
```
**Агент** (машина, к которой подключён адаптер):
```bash
carbus-relay-agent --port COM6 --baudrate 115200 --server <IP_СЕРВЕРА>:9000 --serial 5957 --password 1234
# или
python -m carbus_async.remote.agent --port COM6 --baudrate 115200 --server <IP_СЕРВЕРА>:9000 --serial 5957 --password 1234
```
| параметр | назначение |
|---|---|
| `--server <IP>:9000` | адрес и порт relay-сервера |
| `--serial 5957` | серийный номер устройства |
| `--password 1234` | пароль для удалённого доступа |
**Клиент** (удалённая машина):
```python
from carbus_async import open_remote_device from carbus_async import open_remote_device
dev = await open_remote_device(<IP_СЕРВЕРА>, 9000, serial=5957, password="1234")
````
## Удалённая работа в локальной сети через TCP (tcp_bridge) dev = await open_remote_device("185.42.26.80", 9000, serial=5957, password="1234")
# дальше — обычный API CarBusDevice
```
### 1. Сервер (рядом с адаптером) ### Через TCP-мост в локальной сети
На машине, где физически подключён CAN-адаптер: **Сервер** (рядом с адаптером):
python.exe -m carbus_async.tcp_bridge --serial COM6 --port 7000 ```bash
python -m carbus_async.tcp_bridge --serial COM6 --port 7000
```
Адаптер открывается локально, а поверх него поднимается TCP-мост. **Клиент** (та же сеть) — тот же API, но через `open_tcp`:
### 2. Клиент (удалённая машина) ```python
На другой машине можно использовать тот же API, как с локальным COM, но через `open_tcp`:
````python
import asyncio import asyncio
from carbus_async.device import CarBusDevice from carbus_async import CarBusDevice, CanMessage
from carbus_async.messages import CanMessage
async def main(): async def main():
dev = await CarBusDevice.open_tcp("192.168.1.10", 7000) dev = await CarBusDevice.open_tcp("192.168.1.10", 7000)
await dev.open_can_channel( await dev.open_can_channel(channel=1, nominal_bitrate=500_000)
channel=1,
nominal_bitrate=500_000,
fd=False,
)
msg = CanMessage(can_id=0x321, data=b"\x01\x02\x03\x04") msg = CanMessage(can_id=0x321, data=b"\x01\x02\x03\x04")
await dev.send_can(msg, channel=1) await dev.send_can(msg, channel=1)
ch, rx = await dev.receive_can() ch, rx = await dev.receive_can()
print("REMOTE RX:", ch, rx) print("REMOTE RX:", ch, hex(rx.can_id), rx.data.hex())
await dev.close() await dev.close()
asyncio.run(main()) asyncio.run(main())
```` ```
---
## Логирование ## Логирование
Для отладки удобно включить подробное логирование: ```python
````python
import logging import logging
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG, level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
) )
```` ```
Логгеры: Логгеры:
- `carbus_async.wire.*` — сырые кадры по USB/TCP (TX/RX) - `carbus_async.wire.*` — сырые кадры по USB/TCP (TX/RX)
- `carbus_async.device.*` — высокоуровневые события, ошибки, BUS_ERROR - `carbus_async.device.*` — высокоуровневые события, ошибки, `BUS_ERROR`
- дополнительные логгеры в isotp_async / uds_async - дополнительные логгеры в `isotp_async` / `uds_async`
---
## Примеры
Готовые скрипты — в каталоге [`example/`](example):
| файл | что показывает |
|---|---|
| `can_periodic_message.py` | периодическая отправка (классический CAN) |
| `canfd_periodic_message.py` | периодическая отправка CAN-FD / BRS |
| `can_message_hook.py` | хуки на приём кадров |
| `uds_service22_scaner.py` | скан UDS-сервиса 0x22 (RDBI) |
| `uds_id_scaner.py` | перебор идентификаторов (DID) |
| `uds_ecu_emulated.py` | эмуляция ЭБУ (UDS-сервер) |
| `car_ecus_emulated.py` | эмуляция нескольких ЭБУ |
| `remote_can_periodic_message.py` | периодика через relay-сервер |
| `remote_can_bridge.py` | мост CAN ↔ удалённое устройство |
| `remote_relay.py` | запуск relay-сервера |
--- ---
## Лицензия ## Лицензия
MIT. MIT — распространяется «как есть», без каких-либо гарантий. См. [LICENSE](LICENSE).
ДАННОЕ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ», БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ, ЯВНО ВЫРАЖЕННЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ ГАРАНТИИ ТОВАРНОЙ ПРИГОДНОСТИ, СООТВЕТСТВИЯ ПО ЕГО КОНКРЕТНОМУ НАЗНАЧЕНИЮ И ОТСУТСТВИЯ НАРУШЕНИЙ, НО НЕ ОГРАНИЧИВАЯСЬ ИМИ. НИ В КАКОМ СЛУЧАЕ АВТОРЫ ИЛИ ПРАВООБЛАДАТЕЛИ НЕ НЕСУТ ОТВЕТСТВЕННОСТИ ПО КАКИМ-ЛИБО ИСКАМ, ЗА УЩЕРБ ИЛИ ПО ИНЫМ ТРЕБОВАНИЯМ, В ТОМ ЧИСЛЕ, ПРИ ДЕЙСТВИИ КОНТРАКТА, ДЕЛИКТЕ ИЛИ ИНОЙ СИТУАЦИИ, ВОЗНИКШИМ ИЗ-ЗА ИСПОЛЬЗОВАНИЯ ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ ИЛИ ИНЫХ ДЕЙСТВИЙ С ПРОГРАММНЫМ ОБЕСПЕЧЕНИЕМ.
Pull Requests и предложения по улучшению приветствуются 🚗 Pull Requests и предложения по улучшению приветствуются 🚗
+2 -1
View File
@@ -1,4 +1,4 @@
from .device import CarBusDevice from .device import CarBusDevice, CanTiming
from .messages import CanMessage, MessageDirection from .messages import CanMessage, MessageDirection
from .exceptions import CarBusError, CommandError, SyncError from .exceptions import CarBusError, CommandError, SyncError
from .can_router import CanIdRouter, RoutedCarBusCanTransport from .can_router import CanIdRouter, RoutedCarBusCanTransport
@@ -7,6 +7,7 @@ from .remote.client import open_remote_device
__all__ = [ __all__ = [
"CarBusDevice", "CarBusDevice",
"CanTiming",
"CanMessage", "CanMessage",
"MessageDirection", "MessageDirection",
"CarBusError", "CarBusError",
+59
View File
@@ -49,6 +49,40 @@ class CanTiming:
sjw: int sjw: int
DEFAULT_CAN_CLOCK_HZ = 120_000_000
# Проверенные bit-timing'и для CAN-модуля с тактовой 120 МГц (точка выборки ~80%).
# open_can_channel() для FD-канала задаёт скорость именно таймингами, а не
# одиночным индексом data-скорости (CC_BUS_SPEED_D): прошивка этих адаптеров
# применяет явные тайминги надёжно, а индекс data-скорости не отрабатывает —
# канал открывается, но FD/BRS-кадры не уходят в шину.
_NOMINAL_TIMINGS_120M: Dict[int, CanTiming] = {
1_000_000: CanTiming(prescaler=6, tq_seg1=15, tq_seg2=4, sjw=1), # 120/(6*20)
500_000: CanTiming(prescaler=15, tq_seg1=12, tq_seg2=3, sjw=1), # 120/(15*16)
250_000: CanTiming(prescaler=30, tq_seg1=12, tq_seg2=3, sjw=1), # 120/(30*16)
125_000: CanTiming(prescaler=60, tq_seg1=12, tq_seg2=3, sjw=1), # 120/(60*16)
}
_DATA_TIMINGS_120M: Dict[int, CanTiming] = {
5_000_000: CanTiming(prescaler=3, tq_seg1=5, tq_seg2=2, sjw=1), # 120/(3*8)
4_000_000: CanTiming(prescaler=3, tq_seg1=7, tq_seg2=2, sjw=1), # 120/(3*10)
2_000_000: CanTiming(prescaler=6, tq_seg1=7, tq_seg2=2, sjw=1), # 120/(6*10)
1_000_000: CanTiming(prescaler=12, tq_seg1=7, tq_seg2=2, sjw=1), # 120/(12*10)
500_000: CanTiming(prescaler=24, tq_seg1=7, tq_seg2=2, sjw=1), # 120/(24*10)
}
def _resolve_timing(
table: Dict[int, CanTiming], bitrate: int, clock_hz: int, kind: str
) -> CanTiming:
if clock_hz == DEFAULT_CAN_CLOCK_HZ and bitrate in table:
return table[bitrate]
raise ValueError(
f"Нет встроенного FD bit-timing для {kind}={bitrate} bps при тактовой "
f"{clock_hz} Гц. Доступно (для 120 МГц): {sorted(table)}. "
f"Задайте скорость явными таймингами через open_can_channel_custom(...)."
)
@dataclass @dataclass
class DeviceInfoParam: class DeviceInfoParam:
header: int header: int
@@ -823,8 +857,33 @@ class CarBusDevice:
non_iso: bool = False, non_iso: bool = False,
nominal_index: Optional[int] = None, nominal_index: Optional[int] = None,
data_index: Optional[int] = None, data_index: Optional[int] = None,
can_clock_hz: Optional[int] = None,
) -> None: ) -> None:
# FD-канал настраиваем явными таймингами (рабочий путь): одиночный индекс
# data-скорости прошивкой не применяется, канал открывается, но FD/BRS-кадры
# не уходят в шину. Явное переопределение индексами/auto_detect не трогаем.
if fd and not (auto_detect or nominal_index is not None or data_index is not None):
if data_bitrate is None:
raise ValueError("fd=True требует указать data_bitrate")
clock = can_clock_hz or DEFAULT_CAN_CLOCK_HZ
await self.open_can_channel_custom(
channel,
nominal_timing=_resolve_timing(
_NOMINAL_TIMINGS_120M, nominal_bitrate, clock, "nominal"
),
data_timing=_resolve_timing(
_DATA_TIMINGS_120M, data_bitrate, clock, "data"
),
fd=True,
brs=brs,
listen_only=listen_only,
loopback=loopback,
retransmit=retransmit,
non_iso=non_iso,
)
return
if loopback: if loopback:
mode_val = 0x02 mode_val = 0x02
elif listen_only: elif listen_only:
+4
View File
@@ -72,6 +72,10 @@ class PeriodicJob:
msg = CanMessage( msg = CanMessage(
can_id=self.can_id, can_id=self.can_id,
data=out, data=out,
extended=self.extended,
rtr=self.rtr,
fd=self.fd,
brs=self.brs,
) )
await dev.send_can( await dev.send_can(
msg, msg,
+115 -48
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
import contextlib
import json import json
import logging import logging
from typing import Optional from typing import Optional
@@ -10,6 +11,15 @@ import serial_asyncio
log = logging.getLogger("carbus_remote.agent") log = logging.getLogger("carbus_remote.agent")
RECONNECT_MIN_S = 1.0
RECONNECT_MAX_S = 15.0
CONNECT_TIMEOUT_S = 5.0
AUTH_TIMEOUT_S = 10.0
DATA_HELLO_TIMEOUT_S = 5.0
COM_REOPEN_DELAY_S = 0.25
def parse_hostport(s: str) -> tuple[str, int]: def parse_hostport(s: str) -> tuple[str, int]:
if ":" not in s: if ":" not in s:
@@ -38,10 +48,8 @@ async def pipe_bidirectional_streams(
except Exception: except Exception:
pass pass
finally: finally:
try: with contextlib.suppress(Exception):
dst.close() dst.close()
except Exception:
pass
t1 = asyncio.create_task(pump(a_reader, b_writer), name="pipe_a_to_b") t1 = asyncio.create_task(pump(a_reader, b_writer), name="pipe_a_to_b")
t2 = asyncio.create_task(pump(b_reader, a_writer), name="pipe_b_to_a") t2 = asyncio.create_task(pump(b_reader, a_writer), name="pipe_b_to_a")
@@ -49,32 +57,35 @@ async def pipe_bidirectional_streams(
await asyncio.wait({t1, t2}, return_when=asyncio.FIRST_COMPLETED) await asyncio.wait({t1, t2}, return_when=asyncio.FIRST_COMPLETED)
for w in (a_writer, b_writer): for w in (a_writer, b_writer):
try: with contextlib.suppress(Exception):
w.close() w.close()
except Exception:
pass
await asyncio.gather(t1, t2, return_exceptions=True) await asyncio.gather(t1, t2, return_exceptions=True)
for w in (a_writer, b_writer): for w in (a_writer, b_writer):
try: with contextlib.suppress(Exception):
await w.wait_closed() await w.wait_closed()
except Exception:
pass
async def open_serial_with_retry(port: str, baudrate: int, *, attempts: int = 3) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: async def open_serial_with_retry(
port: str,
baudrate: int,
*,
attempts: int = 5,
) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
last: Optional[BaseException] = None last: Optional[BaseException] = None
for i in range(attempts): for i in range(attempts):
try: try:
return await serial_asyncio.open_serial_connection(url=port, baudrate=baudrate) return await serial_asyncio.open_serial_connection(url=port, baudrate=baudrate)
except Exception as e: except Exception as e:
last = e last = e
await asyncio.sleep(0.25 + 0.25 * i) await asyncio.sleep(0.25 + 0.35 * i)
assert last is not None assert last is not None
raise last raise last
async def agent_run( async def agent_run_once(
*, *,
port: str, port: str,
baudrate: int, baudrate: int,
@@ -82,11 +93,15 @@ async def agent_run(
serial: str, serial: str,
password: str, password: str,
) -> None: ) -> None:
server_host, server_port = parse_hostport(server) server_host, server_port = parse_hostport(server)
session_lock = asyncio.Lock() session_lock = asyncio.Lock()
active_data_task: Optional[asyncio.Task] = None
async def open_data_session(session: str) -> None: async def open_data_session(session: str) -> None:
nonlocal active_data_task
async with session_lock: async with session_lock:
net_r: Optional[asyncio.StreamReader] = None net_r: Optional[asyncio.StreamReader] = None
net_w: Optional[asyncio.StreamWriter] = None net_w: Optional[asyncio.StreamWriter] = None
@@ -98,63 +113,71 @@ async def agent_run(
net_r, net_w = await asyncio.wait_for( net_r, net_w = await asyncio.wait_for(
asyncio.open_connection(server_host, server_port), asyncio.open_connection(server_host, server_port),
timeout=5.0, timeout=CONNECT_TIMEOUT_S,
) )
net_w.write((json.dumps({"role": "agent_data", "session": session}) + "\n").encode("utf-8")) net_w.write((json.dumps({"role": "agent_data", "session": session}) + "\n").encode("utf-8"))
await net_w.drain() await net_w.drain()
line = await asyncio.wait_for(net_r.readline(), timeout=5.0) line = await asyncio.wait_for(net_r.readline(), timeout=DATA_HELLO_TIMEOUT_S)
if not line:
raise ConnectionError("relay closed data socket before hello")
resp = json.loads(line.decode("utf-8", errors="ignore") or "{}") resp = json.loads(line.decode("utf-8", errors="ignore") or "{}")
if not resp.get("ok"): if not resp.get("ok"):
log.error("Data session refused %s: %s", session, resp) log.error("Data session refused %s: %s", session, resp)
return return
await asyncio.sleep(COM_REOPEN_DELAY_S)
log.info("Opening COM %s @ %d for session %s", port, baudrate, session) log.info("Opening COM %s @ %d for session %s", port, baudrate, session)
dev_r, dev_w = await open_serial_with_retry(port, baudrate, attempts=3) dev_r, dev_w = await open_serial_with_retry(port, baudrate, attempts=5)
log.info("Session %s accepted. Piping bytes (COM <-> relay).", session) log.info("Session %s accepted. Piping bytes (COM <-> relay).", session)
await pipe_bidirectional_streams(dev_r, dev_w, net_r, net_w) await pipe_bidirectional_streams(dev_r, dev_w, net_r, net_w)
log.info("Session %s finished.", session) log.info("Session %s finished.", session)
except asyncio.CancelledError:
log.info("Data session %s cancelled", session)
raise
except Exception: except Exception:
log.exception("Data session %s crashed", session) log.exception("Data session %s crashed", session)
finally: finally:
if net_w is not None: if net_w is not None:
try: with contextlib.suppress(Exception):
net_w.close() net_w.close()
with contextlib.suppress(Exception):
await net_w.wait_closed() await net_w.wait_closed()
except Exception:
pass
if dev_w is not None: if dev_w is not None:
try: with contextlib.suppress(Exception):
dev_w.close() dev_w.close()
with contextlib.suppress(Exception):
await dev_w.wait_closed() await dev_w.wait_closed()
except Exception:
pass
# --- CONTROL CONNECT ---
log.info("Connecting to relay %s:%d (control)", server_host, server_port) log.info("Connecting to relay %s:%d (control)", server_host, server_port)
ctrl_reader, ctrl_writer = await asyncio.open_connection(server_host, server_port) ctrl_reader, ctrl_writer = await asyncio.wait_for(
asyncio.open_connection(server_host, server_port),
ctrl_writer.write((json.dumps({"role": "agent", "serial": serial, "password": password}) + "\n").encode("utf-8")) timeout=CONNECT_TIMEOUT_S,
await ctrl_writer.drain() )
line = await asyncio.wait_for(ctrl_reader.readline(), timeout=10.0)
resp = json.loads(line.decode("utf-8", errors="ignore") or "{}")
if not resp.get("ok"):
ctrl_writer.close()
try:
await ctrl_writer.wait_closed()
except Exception:
pass
raise RuntimeError(f"relay refused agent: {resp}")
log.info("Agent registered OK. Waiting for sessions... (serial=%s)", serial)
try: try:
# AUTH
ctrl_writer.write((json.dumps({"role": "agent", "serial": serial, "password": password}) + "\n").encode("utf-8"))
await ctrl_writer.drain()
line = await asyncio.wait_for(ctrl_reader.readline(), timeout=AUTH_TIMEOUT_S)
if not line:
raise ConnectionError("relay closed control socket during auth")
resp = json.loads(line.decode("utf-8", errors="ignore") or "{}")
if not resp.get("ok"):
raise RuntimeError(f"relay refused agent: {resp}")
log.info("Agent registered OK. Waiting for sessions... (serial=%s)", serial)
# CONTROL LOOP
while True: while True:
line = await ctrl_reader.readline() line = await ctrl_reader.readline()
if not line: if not line:
@@ -168,17 +191,62 @@ async def agent_run(
if msg.get("cmd") == "open_session": if msg.get("cmd") == "open_session":
session = str(msg.get("session", "")).strip() session = str(msg.get("session", "")).strip()
if session: if not session:
asyncio.create_task(open_data_session(session), name=f"agent_data_session_{session}") continue
# если уже идёт data-session — игнорируем новый запрос (или можно логировать busy)
if active_data_task is not None and not active_data_task.done():
log.warning("Data session already running; ignoring open_session=%s", session)
continue
active_data_task = asyncio.create_task(
open_data_session(session),
name=f"agent_data_session_{session}",
)
finally: finally:
try: if active_data_task is not None and not active_data_task.done():
ctrl_writer.close() active_data_task.cancel()
await ctrl_writer.wait_closed() with contextlib.suppress(Exception):
except Exception: await active_data_task
pass
log.info("Agent stopped.") with contextlib.suppress(Exception):
ctrl_writer.close()
with contextlib.suppress(Exception):
await ctrl_writer.wait_closed()
log.info("Agent control session ended.")
async def agent_supervisor(
*,
port: str,
baudrate: int,
server: str,
serial: str,
password: str,
) -> None:
delay = RECONNECT_MIN_S
while True:
try:
await agent_run_once(
port=port,
baudrate=baudrate,
server=server,
serial=serial,
password=password,
)
delay = RECONNECT_MIN_S
except asyncio.CancelledError:
raise
except Exception as e:
log.exception("Agent crashed: %s", e)
log.info("Reconnect in %.1f sec...", delay)
await asyncio.sleep(delay)
delay = min(RECONNECT_MAX_S, delay * 1.7)
async def main_async( async def main_async(
@@ -188,7 +256,7 @@ async def main_async(
serial: str, serial: str,
password: str, password: str,
) -> None: ) -> None:
await agent_run( await agent_supervisor(
port=port, port=port,
baudrate=baudrate, baudrate=baudrate,
server=server, server=server,
@@ -224,4 +292,3 @@ def cli() -> None:
if __name__ == "__main__": if __name__ == "__main__":
cli() cli()
+74
View File
@@ -0,0 +1,74 @@
import asyncio
from carbus_async import CarBusDevice, PeriodicCanSender
from carbus_async.device import CanTiming
async def main(is_debug=False):
dev = await CarBusDevice.open("COM6")
# await dev.open_can_channel_custom(
# channel=1,
#
# nominal_timing=CanTiming(
# prescaler=15,
# tq_seg1=12,
# tq_seg2=3,
# sjw=1
# ),
# data_timing=CanTiming(
# prescaler=6,
# tq_seg1=7,
# tq_seg2=2,
# sjw=1
# ),
# fd=True,
# brs=True,
# )
await dev.open_can_channel(
channel=1,
nominal_bitrate=500_000,
data_bitrate=2_000_000,
fd=True,
brs=True,
)
await dev.ensure_terminator(channel=1, enabled=True)
sender = PeriodicCanSender(dev)
def mod(tick, data):
b = bytearray(data)
b[0] = tick & 0xFF
return bytes(b)
sender.add(
"cnt",
channel=1,
fd=True,
brs=True,
can_id=0x100,
data=b"\x00" * 8,
period_s=0.5,
modify=mod
)
sender.add(
"heartbeat",
channel=1,
can_id=0x123,
data=b"\x01\x02\x03\x04\x05\x06\x07\x08",
period_s=0.05,
)
try:
await asyncio.Event().wait()
finally:
await sender.stop_all()
await dev.close()
return
asyncio.run(main())
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
import argparse
import asyncio
import logging
from typing import Optional
from carbus_async.device import CarBusDevice
# подстрой импорт под свой проект
from carbus_async.remote.client import open_remote_device
log = logging.getLogger("carbus.can_bridge.simple")
def _ensure_hooks(dev: CarBusDevice) -> None:
# фикс для твоей ошибки: где-то в CarBusDevice используется _can_hooks,
# но он не создаётся в open/open_stream
if not hasattr(dev, "_can_hooks"):
dev._can_hooks = [] # type: ignore[attr-defined]
async def _open_local(port: str, baudrate: int) -> CarBusDevice:
dev = await CarBusDevice.open(port, baudrate=baudrate, use_can=True, use_lin=False)
_ensure_hooks(dev)
return dev
async def _open_remote(relay: str, serial: int, password: str) -> CarBusDevice:
if ":" not in relay:
raise ValueError("relay must be host:port")
host, p = relay.rsplit(":", 1)
dev = await open_remote_device(host, int(p), serial=serial, password=password)
_ensure_hooks(dev)
return dev
async def _setup_can_500k(dev: CarBusDevice, channel: int) -> None:
await dev.open_can_channel(channel=channel, nominal_bitrate=500_000, fd=False)
await dev.set_std_id_filter(
channel=1,
index=0,
can_id=0x700,
mask=0x700,
)
await dev.ensure_terminator(channel=1, enabled=True)
async def _pump(
name: str,
src: CarBusDevice,
src_ch: int,
dst: CarBusDevice,
dst_ch: int,
stop: asyncio.Event,
) -> None:
try:
while not stop.is_set():
ch, msg = await src.receive_can()
if ch != src_ch:
continue
await dst.send_can(msg, channel=dst_ch, confirm=False, echo=False)
except asyncio.CancelledError:
raise
except Exception as e:
log.exception("[%s] stopped by error: %r", name, e)
finally:
stop.set()
async def main_async() -> None:
ap = argparse.ArgumentParser(description="Simple CAN bridge local<->remote via relay (no filters)")
# defaults чтобы запускать из IDE
ap.add_argument("--local-port", default="COM6")
ap.add_argument("--local-baudrate", type=int, default=115200)
ap.add_argument("--local-channel", type=int, default=1)
ap.add_argument("--relay", default="185.42.26.80:9000")
ap.add_argument("--serial", type=int, default=5957)
ap.add_argument("--password", default="1234")
ap.add_argument("--remote-channel", type=int, default=1)
ap.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
args = ap.parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
stop = asyncio.Event()
local: Optional[CarBusDevice] = None
remote: Optional[CarBusDevice] = None
try:
log.info("Open local device %s ...", args.local_port)
local = await _open_local(args.local_port, args.local_baudrate)
await _setup_can_500k(local, args.local_channel)
log.info("Local CAN ready: ch=%d @ 500k", args.local_channel)
log.info("Open remote device via relay %s (serial=%d) ...", args.relay, args.serial)
remote = await _open_remote(args.relay, args.serial, args.password)
await _setup_can_500k(remote, args.remote_channel)
log.info("Remote CAN ready: ch=%d @ 500k", args.remote_channel)
t1 = asyncio.create_task(
_pump("local->remote", local, args.local_channel, remote, args.remote_channel, stop),
name="pump_local_remote",
)
t2 = asyncio.create_task(
_pump("remote->local", remote, args.remote_channel, local, args.local_channel, stop),
name="pump_remote_local",
)
log.info("CAN bridge running. Ctrl+C to stop.")
await asyncio.Event().wait()
except KeyboardInterrupt:
pass
finally:
stop.set()
# аккуратно гасим
for dev in (remote, local):
if dev is None:
continue
try:
await dev.close()
except Exception:
pass
log.info("CAN bridge stopped.")
if __name__ == "__main__":
asyncio.run(main_async())
+2 -2
View File
@@ -25,7 +25,7 @@ async def main(is_debug=False):
channel=1, channel=1,
can_id=0x100, can_id=0x100,
data=b"\x00" * 8, data=b"\x00" * 8,
period_s=0.001, period_s=0.1,
modify=mod) modify=mod)
sender.add( sender.add(
@@ -33,7 +33,7 @@ async def main(is_debug=False):
channel=1, channel=1,
can_id=0x123, can_id=0x123,
data=b"\x01\x02\x03\x04\x05\x06\x07\x08", data=b"\x01\x02\x03\x04\x05\x06\x07\x08",
period_s=0.03, period_s=0.05,
) )
try: try:
+9 -3
View File
@@ -28,6 +28,7 @@ class IsoTpChannel:
st_min_ms: int = 0 st_min_ms: int = 0
fc_timeout: float = 1.0 fc_timeout: float = 1.0
cf_timeout: float = 1.0 cf_timeout: float = 1.0
tx_gap_s = 0.001
async def send_pdu(self, data: bytes) -> None: async def send_pdu(self, data: bytes) -> None:
length = len(data) length = len(data)
@@ -88,7 +89,6 @@ class IsoTpChannel:
raise RuntimeError(f"ISO-TP: unsupported FlowStatus=0x{fs:02X}") raise RuntimeError(f"ISO-TP: unsupported FlowStatus=0x{fs:02X}")
if bs == 0x00: if bs == 0x00:
# 0 => "unlimited"
bs = 0 bs = 0
st_min = _st_min_to_seconds(st_min_raw) st_min = _st_min_to_seconds(st_min_raw)
@@ -139,14 +139,17 @@ class IsoTpChannel:
await self.can.send(msg) await self.can.send(msg)
seq_num = (seq_num + 1) & 0x0F seq_num = (seq_num + 1) & 0x0F
if seq_num == 0: # if seq_num == 0:
seq_num = 0x1 # seq_num = 0x1
frames_in_block += 1 frames_in_block += 1
if st_min > 0: if st_min > 0:
await asyncio.sleep(st_min) await asyncio.sleep(st_min)
if self.tx_gap_s > 0:
await asyncio.sleep(self.tx_gap_s)
async def recv_pdu(self, timeout: float = 1.0) -> Optional[bytes]: async def recv_pdu(self, timeout: float = 1.0) -> Optional[bytes]:
first = await self.can.recv(timeout=timeout) first = await self.can.recv(timeout=timeout)
@@ -221,6 +224,9 @@ class IsoTpChannel:
if st_min > 0: if st_min > 0:
await asyncio.sleep(st_min) await asyncio.sleep(st_min)
if tx_gap_s > 0:
await asyncio.sleep(tx_gap_s)
return bytes(payload[:total_length]) return bytes(payload[:total_length])
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "carbus-lib" name = "carbus-lib"
version = "0.1.8" version = "0.1.9"
description = "Async CAN / ISO-TP / UDS library for Car Bus Analyzer" description = "Async CAN / ISO-TP / UDS library for Car Bus Analyzer"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+54 -12
View File
@@ -15,28 +15,29 @@ class UdsClient:
async def _request(self, payload: bytes) -> bytes: async def _request(self, payload: bytes) -> bytes:
await self.isotp.send_pdu(payload) await self.isotp.send_pdu(payload)
resp = await self.isotp.recv_pdu(timeout=self.p2_timeout)
if resp is None: while True:
raise TimeoutError("UDS response timeout") resp = await self.isotp.recv_pdu(timeout=self.p2_timeout)
if resp is None:
raise TimeoutError("UDS response timeout")
if resp[0] == 0x7F and resp[2] == 0x78:
continue
break
if resp[0] == 0x7F: if resp[0] == 0x7F:
if len(resp) < 3: if len(resp) < 3:
raise UdsError("Malformed UDS negative response") raise UdsError("Malformed UDS negative response")
sid = resp[1] sid = resp[1]
nrc = resp[2] nrc = resp[2]
raise UdsNegativeResponse(req_sid=sid, nrc=nrc) raise UdsNegativeResponse(req_sid=sid, nrc=nrc)
return resp return resp
async def diagnostic_session_control(self, session: int) -> bytes:
req = bytes([0x10, session & 0xFF])
resp = await self._request(req)
if resp[0] != 0x50:
raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for DSC")
return resp
async def tester_present(self, suppress_response: bool = False) -> Optional[bytes]: async def tester_present(self, suppress_response: bool = False) -> Optional[bytes]:
sub = 0x80 if suppress_response else 0x00 sub = 0x80 if suppress_response else 0x00
req = bytes([0x3E, sub]) req = bytes([0x3E, sub])
@@ -45,19 +46,60 @@ class UdsClient:
resp = await self._request(req) resp = await self._request(req)
except TimeoutError: except TimeoutError:
return None return None
return resp return resp
resp = await self._request(req) resp = await self._request(req)
if resp[0] != 0x7E: if resp[0] != 0x7E:
raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for TesterPresent") raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for TesterPresent")
return resp
async def diagnostic_session_control(self, session: int) -> bytes:
req = bytes([0x10, session & 0xFF])
resp = await self._request(req)
if resp[0] != 0x50:
raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for DSC")
return resp return resp
async def read_data_by_identifier(self, did: int) -> bytes: async def read_data_by_identifier(self, did: int) -> bytes:
req = bytes([0x22, (did >> 8) & 0xFF, did & 0xFF]) req = bytes([0x22, (did >> 8) & 0xFF, did & 0xFF])
resp = await self._request(req) resp = await self._request(req)
if resp[0] != 0x62: if resp[0] != 0x62:
raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for RDBI") raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for RDBI")
if len(resp) < 3: if len(resp) < 3:
raise UdsError("Malformed RDBI response") raise UdsError("Malformed RDBI response")
return resp[3:] return resp[3:]
async def write_data_by_identifier(self, did: int, data: bytes) -> bytes:
req = bytes([0x2E, (did >> 8) & 0xFF, did & 0xFF, *data])
resp = await self._request(req)
if resp[0] != 0x6E:
raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for WDBI")
return resp
async def security_access_get_seed(self, level: int, data: bytes | None = None) -> bytes:
req = bytes([0x27, level & 0xFF]) + (data or b"")
resp = await self._request(req)
if resp[0] != 0x67:
raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for SAGS")
return resp[2:]
async def security_access_send_key(self, level: int, key: bytes) -> bytes:
req = bytes([0x27, level & 0xFF]) + key
resp = await self._request(req)
if resp[0] != 0x67:
raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for SASK")
return resp