diff --git a/.gitignore b/.gitignore index 336736f..d4cb734 100644 --- a/.gitignore +++ b/.gitignore @@ -206,3 +206,4 @@ marimo/_static/ marimo/_lsp/ __marimo__/ *.xml +/RELEASE.md diff --git a/README.md b/README.md index 5db8379..a408f0b 100644 --- a/README.md +++ b/README.md @@ -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, фильтры, терминаторы и т.д.) -- 📦 **`isotp_async`** – ISO-TP (ISO 15765-2) поверх CAN (single + multi-frame) -- 🩺 **`uds_async`** – UDS (ISO 14229) клиент и сервер (диагностика, чтение VIN и т.п.) -- 🌐 **`Remote/TCP-bridge`** – удалённое подключение к адаптеру через сеть (как будто он воткнут локально) +- 📡 **`carbus_async`** — работа с железом: CAN/CAN-FD, фильтры, терминатор 120 Ω, периодическая отправка, хуки +- 📦 **`isotp_async`** — ISO-TP (ISO 15765-2): single- и multi-frame +- 🩺 **`uds_async`** — UDS (ISO 14229): клиент и сервер (эмуляция ЭБУ) +- 🌐 **Remote / TCP-bridge** — удалённый доступ к адаптеру через сеть, как к локальному -> Python 3.10 и выше -> Никаких «магических» зависимостей — всё на `asyncio`. -> Поддерживаемые интерфейсы: https://canhacker.ru/shop/ -> _*Тестировалось на устройствах с Протоколом Версии 22_ +> Python ≥ 3.10 · зависимости: `pyserial`, `pyserial-asyncio` · целиком на `asyncio` +> Протестировано на устройствах с **Протоколом версии 22** +> Поддерживаемые устройства: https://canhacker.ru/shop/ + +--- + +## Содержание + +- [Возможности](#возможности) +- [Установка](#установка) +- [Быстрый старт](#быстрый-старт) +- [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 -````bash -python -m pip install carbus-lib -```` +Из PyPI: -Либо как editable-модуль из репозитория: +```bash +python -m pip install carbus-lib +``` + +Либо editable-режим из репозитория (нужно для разработки): ```bash git clone https://github.com/controllerzz/carbus_lib.git @@ -29,142 +69,137 @@ cd carbus_lib pip install -e . ``` -# carbus-lib - -Асинхронная библиотека для работы с CAN / CAN-FD, ISO-TP и UDS. -Поддерживает локальное подключение через USB CDC и удалённую работу через TCP-bridge. - --- -## Возможности +## Быстрый старт -- CAN / CAN-FD отправка и приём -- Настройка каналов, скоростей, режимов, BRS -- Фильтры ID, очистка фильтров, управление терминатором 120 Ω -- ISO-TP (single + multi-frame) -- UDS Client и UDS Server (эмуляция ЭБУ) -- TCP-мост: удалённая работа с адаптером так, как будто он подключён локально -- Логирование всего протокольного трафика +Открыть устройство, настроить классический CAN-канал, отправить и принять кадр. ---- - -## Работа с CAN - -Простейший пример: открыть устройство, настроить канал и отправить / принять кадр. - -````python +```python import asyncio -from carbus_async.device import CarBusDevice -from carbus_async.messages import CanMessage +from carbus_async import CarBusDevice, CanMessage + async def main(): dev = await CarBusDevice.open("COM6", baudrate=115200) - # классический CAN 500 kbit/s - await dev.open_can_channel( - channel=1, - nominal_bitrate=500_000, - ) + # классический CAN, 500 kbit/s, канал 1 + await dev.open_can_channel(channel=1, nominal_bitrate=500_000) - # включаем терминатор 120 Ω на канале 1 - await dev.set_terminator(channel=1, enabled=True) + # внутренний терминатор 120 Ω (если поддерживается устройством) + 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") await dev.send_can(msg, channel=1) - # приём любого сообщения + # приём любого кадра -> (channel, CanMessage) ch, rx = await dev.receive_can() - print("RX:", ch, rx) + print("RX:", ch, hex(rx.can_id), rx.data.hex()) await dev.close() -asyncio.run(main()) -```` -## Настройка канала через Bit Timing -Возможность конфигруации скорости CAN канала через Bit Timing -````python -# CANFD+BRS 500/2000 kbit/s -await dev.open_can_channel_custom( +asyncio.run(main()) +``` + +Классический CAN через `open_can_channel` поддерживает битрейты +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, - 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 - ), + nominal_bitrate=500_000, # арбитражная фаза + data_bitrate=2_000_000, # фаза данных (используется при BRS) fd=True, brs=True, ) -```` +``` -## Получение информации об устройстве: -Получение инфмормации об устройстве и его фичах -````python -info = await dev.get_device_info() +Поддерживаемые битрейты (CAN-модуль 120 МГц, точка выборки ~80%): -print("HW:", info.hardware_name) -print("FW:", info.firmware_version) -print("Serial:", info.serial_int) +- **nominal:** 125k / 250k / 500k / 1M +- **data:** 500k / 1M / 2M / 4M / 5M -print("Features:", - "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 "", - ) -```` +> На этих адаптерах FD-канал настраивается точными bit-timing, а не одиночным +> индексом скорости данных — иначе канал открывается, но FD/BRS-кадры не уходят +> в шину. `open_can_channel` делает это автоматически. Если тактовая модуля не +> 120 МГц, передайте её через `can_clock_hz=...`. -## Пример настройки фильтров: -11 bit фильтры имеют index от 0 до 27 включительно, -29 bit фильтры имеют index от 28 до 35 включительно -````python -# очистить все фильтры на канале 1 -await dev.clear_all_filters(1) +### 2. По точным bit-timing -# разрешить только ответы с ID 0x7E8 (11-битный стандартный ID) -await dev.set_std_id_filter( +Для нестандартных скоростей задайте тайминги напрямую: + +```python +from carbus_async import CanTiming + +await dev.open_can_channel_custom( channel=1, - index=0, - can_id=0x7E8, - mask=0x7FF, + nominal_timing=CanTiming(prescaler=15, tq_seg1=12, tq_seg2=3, sjw=1), # 500k @ 120 МГц + data_timing=CanTiming(prescaler=6, tq_seg1=7, tq_seg2=2, sjw=1), # 2M @ 120 МГц + fd=True, + brs=True, ) -```` +``` -## Управление терминатором 120 Ω: -Включаем терминатор на канале 1 и выключаем терминатор на канале 2 -````python -await dev.set_terminator(channel=1, enabled=True) -await dev.set_terminator(channel=2, enabled=False) -```` +### Отправка FD-кадра -Проверка наличия внутреннего терминатора у девайса -````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") -```` +Тип кадра задаётся флагами `fd` / `brs` в самом `CanMessage`: -Включаем терминатор на канале 1, если это поддерживает девайс -````python -await dev.ensure_terminator(channel=1, enabled=True) -```` +```python +# FD-кадр с переключением скорости (BRS), 16 байт данных +msg = CanMessage(can_id=0x100, data=bytes(range(16)), fd=True, brs=True) +await dev.send_can(msg, channel=1) +``` -## Отправка периодичных сообщений: -Отправка сообщений с статичными данными и периодом 100мс -````python +> Допустимые длины данных CAN-FD: 0–8, 12, 16, 20, 24, 32, 48, 64 байт. + +--- + +## Приём и хуки + +Способы приёма: + +```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 sender = PeriodicCanSender(dev) + +# статичные данные, период 100 мс (классический CAN) sender.add( "heartbeat", channel=1, @@ -172,14 +207,8 @@ sender.add( data=b"\x01\x02\x03\x04\x05\x06\x07\x08", period_s=0.1, ) -```` - -Отправка сообщений с модификацией данных и периодом 500мс -````python -from carbus_async import PeriodicCanSender - -sender = PeriodicCanSender(dev) +# CAN-FD + BRS со счётчиком в первом байте def mod(tick, data): b = bytearray(data) b[0] = tick & 0xFF @@ -191,160 +220,280 @@ sender.add( can_id=0x100, data=b"\x00" * 8, period_s=0.5, - 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", + fd=True, + brs=True, + modify=mod, ) -async def on_session_control(ch, msg): - print("SessionControl") -```` - -## ISO-TP (isotp_async) -ISO-TP канал строится поверх CarBusDevice: -````python + +# управление задачами +await sender.remove("cnt") # остановить и удалить одну задачу +await sender.stop_all() # остановить все +``` + +Параметры `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 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") -# получить полный ответ (single или multi-frame) +# полный ответ (single или multi-frame) resp = await isotp.recv_pdu(timeout=5.0) print("ISO-TP:", resp.hex()) -```` +``` -## UDS Client (uds_async.client) +--- -Клиент UDS использует IsoTpChannel: -````python +## UDS клиент + +```python from isotp_async import open_isotp from uds_async import UdsClient isotp = await open_isotp(dev, channel=1, tx_id=0x7E0, rx_id=0x7E8) uds = UdsClient(isotp) +await uds.diagnostic_session_control(0x03) # extended session vin = await uds.read_data_by_identifier(0xF190) print("VIN:", vin.decode(errors="ignore")) -```` +await uds.tester_present() +``` -## Удалённая работа через внешний сервер -### 0. Бесплатный Сервер/Relay -Можно воспользоваться бесплатным сервером: +Доступные сервисы: `diagnostic_session_control`, `read_data_by_identifier`, +`write_data_by_identifier`, `security_access_get_seed`, +`security_access_send_key`, `tester_present`. - IP: 185.42.26.80 - PORT: 9000 +--- -### 1. Сервер/Relay -На сервере устанавливаем библиотеку carbus-lib, далее запускаем сервер +## UDS сервер - 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 :9000 --serial 5957 --password 1234 -или - - python -m carbus_async.remote.agent --port COM6 --baudrate 115200 --server :9000 --serial 5957 --password 1234 +@server.service(0x22) # ReadDataByIdentifier +async def on_rdbi(req: bytes) -> bytes: + did = (req[1] << 8) | req[2] + if did == 0xF190: + return b"\x62\xF1\x90" + b"WVWZZZ1KZAW000001" + raise UdsNegativeResponse(req_sid=0x22, nrc=0x31) # requestOutOfRange -где +await server.serve_forever() +``` - --server :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 :9000 --serial 5957 --password 1234 +# или +python -m carbus_async.remote.agent --port COM6 --baudrate 115200 --server :9000 --serial 5957 --password 1234 +``` + +| параметр | назначение | +|---|---| +| `--server :9000` | адрес и порт relay-сервера | +| `--serial 5957` | серийный номер устройства | +| `--password 1234` | пароль для удалённого доступа | + +**Клиент** (удалённая машина): + +```python from carbus_async import open_remote_device -dev = await open_remote_device(, 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. Клиент (удалённая машина) - -На другой машине можно использовать тот же API, как с локальным COM, но через `open_tcp`: -````python +```python import asyncio -from carbus_async.device import CarBusDevice -from carbus_async.messages import CanMessage +from carbus_async import CarBusDevice, CanMessage + async def main(): dev = await CarBusDevice.open_tcp("192.168.1.10", 7000) - await dev.open_can_channel( - channel=1, - nominal_bitrate=500_000, - fd=False, - ) + await dev.open_can_channel(channel=1, nominal_bitrate=500_000) msg = CanMessage(can_id=0x321, data=b"\x01\x02\x03\x04") await dev.send_can(msg, channel=1) 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() + asyncio.run(main()) -```` +``` + +--- ## Логирование -Для отладки удобно включить подробное логирование: -````python +```python import logging logging.basicConfig( level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) -```` +``` + Логгеры: - `carbus_async.wire.*` — сырые кадры по USB/TCP (TX/RX) -- `carbus_async.device.*` — высокоуровневые события, ошибки, BUS_ERROR -- дополнительные логгеры в isotp_async / uds_async +- `carbus_async.device.*` — высокоуровневые события, ошибки, `BUS_ERROR` +- дополнительные логгеры в `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 и предложения по улучшению приветствуются 🚗 - - diff --git a/carbus_async/__init__.py b/carbus_async/__init__.py index 2bb3e29..d3507ff 100644 --- a/carbus_async/__init__.py +++ b/carbus_async/__init__.py @@ -1,4 +1,4 @@ -from .device import CarBusDevice +from .device import CarBusDevice, CanTiming from .messages import CanMessage, MessageDirection from .exceptions import CarBusError, CommandError, SyncError from .can_router import CanIdRouter, RoutedCarBusCanTransport @@ -7,6 +7,7 @@ from .remote.client import open_remote_device __all__ = [ "CarBusDevice", + "CanTiming", "CanMessage", "MessageDirection", "CarBusError", diff --git a/carbus_async/device.py b/carbus_async/device.py index 2f1b889..c053556 100644 --- a/carbus_async/device.py +++ b/carbus_async/device.py @@ -49,6 +49,40 @@ class CanTiming: 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 class DeviceInfoParam: header: int @@ -823,8 +857,33 @@ class CarBusDevice: non_iso: bool = False, nominal_index: Optional[int] = None, data_index: Optional[int] = None, + can_clock_hz: Optional[int] = 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: mode_val = 0x02 elif listen_only: diff --git a/carbus_async/periodic.py b/carbus_async/periodic.py index 0bbb573..441a2bc 100644 --- a/carbus_async/periodic.py +++ b/carbus_async/periodic.py @@ -72,6 +72,10 @@ class PeriodicJob: msg = CanMessage( can_id=self.can_id, data=out, + extended=self.extended, + rtr=self.rtr, + fd=self.fd, + brs=self.brs, ) await dev.send_can( msg, diff --git a/carbus_async/remote/agent.py b/carbus_async/remote/agent.py index 573ebb2..36eb696 100644 --- a/carbus_async/remote/agent.py +++ b/carbus_async/remote/agent.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse import asyncio +import contextlib import json import logging from typing import Optional @@ -10,6 +11,15 @@ import serial_asyncio 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]: if ":" not in s: @@ -38,10 +48,8 @@ async def pipe_bidirectional_streams( except Exception: pass finally: - try: + with contextlib.suppress(Exception): dst.close() - except Exception: - pass 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") @@ -49,32 +57,35 @@ async def pipe_bidirectional_streams( await asyncio.wait({t1, t2}, return_when=asyncio.FIRST_COMPLETED) for w in (a_writer, b_writer): - try: + with contextlib.suppress(Exception): w.close() - except Exception: - pass await asyncio.gather(t1, t2, return_exceptions=True) + for w in (a_writer, b_writer): - try: + with contextlib.suppress(Exception): 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 for i in range(attempts): try: return await serial_asyncio.open_serial_connection(url=port, baudrate=baudrate) except Exception as e: last = e - await asyncio.sleep(0.25 + 0.25 * i) + await asyncio.sleep(0.25 + 0.35 * i) assert last is not None raise last -async def agent_run( +async def agent_run_once( *, port: str, baudrate: int, @@ -82,11 +93,15 @@ async def agent_run( serial: str, password: str, ) -> None: + server_host, server_port = parse_hostport(server) session_lock = asyncio.Lock() + active_data_task: Optional[asyncio.Task] = None async def open_data_session(session: str) -> None: + nonlocal active_data_task + async with session_lock: net_r: Optional[asyncio.StreamReader] = None net_w: Optional[asyncio.StreamWriter] = None @@ -98,63 +113,71 @@ async def agent_run( net_r, net_w = await asyncio.wait_for( 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")) 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 "{}") if not resp.get("ok"): log.error("Data session refused %s: %s", session, resp) return + await asyncio.sleep(COM_REOPEN_DELAY_S) 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) await pipe_bidirectional_streams(dev_r, dev_w, net_r, net_w) log.info("Session %s finished.", session) + except asyncio.CancelledError: + log.info("Data session %s cancelled", session) + raise except Exception: log.exception("Data session %s crashed", session) - finally: if net_w is not None: - try: + with contextlib.suppress(Exception): net_w.close() + with contextlib.suppress(Exception): await net_w.wait_closed() - except Exception: - pass if dev_w is not None: - try: + with contextlib.suppress(Exception): dev_w.close() + with contextlib.suppress(Exception): await dev_w.wait_closed() - except Exception: - pass + # --- CONTROL CONNECT --- 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_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=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) + ctrl_reader, ctrl_writer = await asyncio.wait_for( + asyncio.open_connection(server_host, server_port), + timeout=CONNECT_TIMEOUT_S, + ) 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: line = await ctrl_reader.readline() if not line: @@ -168,17 +191,62 @@ async def agent_run( if msg.get("cmd") == "open_session": session = str(msg.get("session", "")).strip() - if session: - asyncio.create_task(open_data_session(session), name=f"agent_data_session_{session}") + if not 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: - try: - ctrl_writer.close() - await ctrl_writer.wait_closed() - except Exception: - pass + if active_data_task is not None and not active_data_task.done(): + active_data_task.cancel() + with contextlib.suppress(Exception): + await active_data_task - 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( @@ -188,7 +256,7 @@ async def main_async( serial: str, password: str, ) -> None: - await agent_run( + await agent_supervisor( port=port, baudrate=baudrate, server=server, @@ -224,4 +292,3 @@ def cli() -> None: if __name__ == "__main__": cli() - diff --git a/example/canfd_periodic_message.py b/example/canfd_periodic_message.py new file mode 100644 index 0000000..6a8b10f --- /dev/null +++ b/example/canfd_periodic_message.py @@ -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()) diff --git a/example/remote_can_bridge.py b/example/remote_can_bridge.py new file mode 100644 index 0000000..b641bd4 --- /dev/null +++ b/example/remote_can_bridge.py @@ -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()) diff --git a/example/remote_can_periodic_message.py b/example/remote_can_periodic_message.py index d111d18..e6b0b69 100644 --- a/example/remote_can_periodic_message.py +++ b/example/remote_can_periodic_message.py @@ -25,7 +25,7 @@ async def main(is_debug=False): channel=1, can_id=0x100, data=b"\x00" * 8, - period_s=0.001, + period_s=0.1, modify=mod) sender.add( @@ -33,7 +33,7 @@ async def main(is_debug=False): channel=1, can_id=0x123, data=b"\x01\x02\x03\x04\x05\x06\x07\x08", - period_s=0.03, + period_s=0.05, ) try: diff --git a/isotp_async/transport.py b/isotp_async/transport.py index b0fea0b..0247be7 100644 --- a/isotp_async/transport.py +++ b/isotp_async/transport.py @@ -28,6 +28,7 @@ class IsoTpChannel: st_min_ms: int = 0 fc_timeout: float = 1.0 cf_timeout: float = 1.0 + tx_gap_s = 0.001 async def send_pdu(self, data: bytes) -> None: length = len(data) @@ -88,7 +89,6 @@ class IsoTpChannel: raise RuntimeError(f"ISO-TP: unsupported FlowStatus=0x{fs:02X}") if bs == 0x00: - # 0 => "unlimited" bs = 0 st_min = _st_min_to_seconds(st_min_raw) @@ -139,14 +139,17 @@ class IsoTpChannel: await self.can.send(msg) seq_num = (seq_num + 1) & 0x0F - if seq_num == 0: - seq_num = 0x1 + # if seq_num == 0: + # seq_num = 0x1 frames_in_block += 1 if st_min > 0: 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]: first = await self.can.recv(timeout=timeout) @@ -221,6 +224,9 @@ class IsoTpChannel: if st_min > 0: await asyncio.sleep(st_min) + if tx_gap_s > 0: + await asyncio.sleep(tx_gap_s) + return bytes(payload[:total_length]) diff --git a/pyproject.toml b/pyproject.toml index a0c709d..e5f76f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "carbus-lib" -version = "0.1.8" +version = "0.1.9" description = "Async CAN / ISO-TP / UDS library for Car Bus Analyzer" readme = "README.md" requires-python = ">=3.10" diff --git a/uds_async/client.py b/uds_async/client.py index 491d552..23c54f3 100644 --- a/uds_async/client.py +++ b/uds_async/client.py @@ -15,28 +15,29 @@ class UdsClient: async def _request(self, payload: bytes) -> bytes: await self.isotp.send_pdu(payload) - resp = await self.isotp.recv_pdu(timeout=self.p2_timeout) - if resp is None: - raise TimeoutError("UDS response timeout") + + while True: + 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 len(resp) < 3: raise UdsError("Malformed UDS negative response") + sid = resp[1] nrc = resp[2] raise UdsNegativeResponse(req_sid=sid, nrc=nrc) 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]: - sub = 0x80 if suppress_response else 0x00 req = bytes([0x3E, sub]) @@ -45,19 +46,60 @@ class UdsClient: resp = await self._request(req) except TimeoutError: return None + return resp resp = await self._request(req) + if resp[0] != 0x7E: 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 async def read_data_by_identifier(self, did: int) -> bytes: - req = bytes([0x22, (did >> 8) & 0xFF, did & 0xFF]) resp = await self._request(req) + if resp[0] != 0x62: raise UdsError(f"Unexpected SID 0x{resp[0]:02X} for RDBI") + if len(resp) < 3: raise UdsError("Malformed RDBI response") + 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