save
This commit is contained in:
parent
0506c6393d
commit
957d825a3b
|
|
@ -205,3 +205,4 @@ cython_debug/
|
||||||
marimo/_static/
|
marimo/_static/
|
||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
*.xml
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
257
README.md
257
README.md
|
|
@ -1 +1,256 @@
|
||||||
# carbus_lib
|
# car-bus-lib (async CAN / ISO-TP / UDS stack)
|
||||||
|
|
||||||
|
Асинхронная библиотека на Python для работы с CAN-адаптером **CAN-Hacker / Car Bus Analyzer**:
|
||||||
|
|
||||||
|
- 📡 **`carbus_async`** – низкоуровневая работа с железкой (CAN/LIN, фильтры, терминаторы и т.д.)
|
||||||
|
- 📦 **`isotp_async`** – ISO-TP (ISO 15765-2) поверх CAN (single + multi-frame)
|
||||||
|
- 🩺 **`uds_async`** – UDS (ISO 14229) клиент и сервер (диагностика, чтение VIN и т.п.)
|
||||||
|
- 🌐 **TCP-bridge** – удалённое подключение к адаптеру через сеть (как будто он воткнут локально)
|
||||||
|
|
||||||
|
> Минимальные примеры, никаких «магических» зависимостей — всё на `asyncio`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Пока проект в разработке, можно ставить его как editable-модуль из репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your_name/carbus_lib.git
|
||||||
|
cd car_bus_lib
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
# car-bus-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-мост: удалённая работа с адаптером так, как будто он подключён локально
|
||||||
|
- Логирование всего протокольного трафика
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
````bat
|
||||||
|
pip install pyserial pyserial-asyncio
|
||||||
|
|
||||||
|
git clone https://github.com/your_name/carbus_lib.git
|
||||||
|
cd car-bus-lib
|
||||||
|
pip install -e .
|
||||||
|
````
|
||||||
|
|
||||||
|
## 1. Работа с CAN
|
||||||
|
|
||||||
|
Простейший пример: открыть устройство, настроить канал и отправить / принять кадр.
|
||||||
|
|
||||||
|
````python
|
||||||
|
import asyncio
|
||||||
|
from carbus_async.device import CarBusDevice
|
||||||
|
from carbus_async.messages import 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,
|
||||||
|
fd=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# отправка кадра 0x7E0 8 байт
|
||||||
|
msg = CanMessage(can_id=0x7E0, data=b"\x02\x3E\x00\x00\x00\x00\x00\x00")
|
||||||
|
await dev.send_can(msg, channel=1)
|
||||||
|
|
||||||
|
# приём любого сообщения
|
||||||
|
ch, rx = await dev.receive_can()
|
||||||
|
print("RX:", ch, rx)
|
||||||
|
|
||||||
|
await dev.close()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
````
|
||||||
|
|
||||||
|
|
||||||
|
## 2. Информация об устройстве и фильтры
|
||||||
|
|
||||||
|
Пример запроса DEVICE_INFO и настройки фильтров:
|
||||||
|
````python
|
||||||
|
info = await dev.get_device_info()
|
||||||
|
|
||||||
|
print("HW:", info.hardware_name)
|
||||||
|
print("FW:", info.firmware_version)
|
||||||
|
print("Serial:", info.serial_int)
|
||||||
|
|
||||||
|
# очистить все фильтры на канале 1
|
||||||
|
await dev.clear_all_filters(1)
|
||||||
|
|
||||||
|
# разрешить только ответы с ID 0x7E8 (11-битный стандартный ID)
|
||||||
|
await dev.set_std_id_filter(
|
||||||
|
channel=1,
|
||||||
|
index=0,
|
||||||
|
can_id=0x7E8,
|
||||||
|
mask=0x7FF,
|
||||||
|
)
|
||||||
|
|
||||||
|
# включить/выключить терминатор 120 Ω
|
||||||
|
await dev.set_terminator(channel=1, enabled=True)
|
||||||
|
````
|
||||||
|
|
||||||
|
## 3. ISO-TP (isotp_async)
|
||||||
|
ISO-TP канал строится поверх CarBusDevice:
|
||||||
|
````python
|
||||||
|
from isotp_async import IsoTpChannel
|
||||||
|
|
||||||
|
# предполагается, что dev уже открыт и канал CAN настроен
|
||||||
|
isotp = IsoTpChannel(
|
||||||
|
device=dev,
|
||||||
|
channel=1,
|
||||||
|
tx_id=0x7E0, # наш запрос
|
||||||
|
rx_id=0x7E8, # ответ ЭБУ
|
||||||
|
)
|
||||||
|
|
||||||
|
# отправить запрос ReadDataByIdentifier F190 (VIN)
|
||||||
|
await isotp.send(b"\x22\xF1\x90")
|
||||||
|
|
||||||
|
# получить полный ответ (single или multi-frame)
|
||||||
|
resp = await isotp.recv(timeout=1.0)
|
||||||
|
print("ISO-TP:", resp.hex())
|
||||||
|
````
|
||||||
|
|
||||||
|
## 4. UDS Client (uds_async.client)
|
||||||
|
|
||||||
|
Клиент UDS использует IsoTpChannel:
|
||||||
|
````python
|
||||||
|
from uds_async.client import UdsClient
|
||||||
|
from isotp_async import IsoTpChannel
|
||||||
|
|
||||||
|
isotp = IsoTpChannel(dev, channel=1, tx_id=0x7E0, rx_id=0x7E8)
|
||||||
|
uds = UdsClient(isotp_channel=isotp)
|
||||||
|
|
||||||
|
# переход в расширенную диагностическую сессию
|
||||||
|
await uds.diagnostic_session_control(0x03)
|
||||||
|
|
||||||
|
# чтение VIN (DID F190)
|
||||||
|
vin_bytes = await uds.read_data_by_identifier(0xF190)
|
||||||
|
print("VIN:", vin_bytes.decode(errors="ignore"))
|
||||||
|
````
|
||||||
|
|
||||||
|
## 5. UDS Server (эмулятор ЭБУ)
|
||||||
|
|
||||||
|
Простой UDS-сервер, который отвечает на запрос VIN:
|
||||||
|
````python
|
||||||
|
from uds_async.server import UdsServer, UdsRequest, UdsPositiveResponse
|
||||||
|
from isotp_async import IsoTpChannel
|
||||||
|
|
||||||
|
class MyEcuServer(UdsServer):
|
||||||
|
async def handle_read_data_by_identifier(self, req: UdsRequest):
|
||||||
|
if req.data_identifier == 0xF190:
|
||||||
|
# положительный ответ: 62 F1 90 + данные
|
||||||
|
return UdsPositiveResponse(b"\x62\xF1\x90DEMO-VIN-1234567")
|
||||||
|
# всё остальное обрабатывается базовой реализацией
|
||||||
|
return await super().handle_read_data_by_identifier(req)
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
dev = await CarBusDevice.open("COM6", baudrate=115200)
|
||||||
|
await dev.open_can_channel(channel=1, nominal_bitrate=500_000, fd=False)
|
||||||
|
|
||||||
|
isotp = IsoTpChannel(dev, channel=1, tx_id=0x7E8, rx_id=0x7E0)
|
||||||
|
server = MyEcuServer(isotp_channel=isotp)
|
||||||
|
await server.serve_forever()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
````
|
||||||
|
|
||||||
|
## 6. Удалённая работа через TCP (tcp_bridge)
|
||||||
|
|
||||||
|
### 6.1. Сервер (рядом с адаптером)
|
||||||
|
|
||||||
|
На машине, где физически подключён CAN-адаптер:
|
||||||
|
|
||||||
|
python -m carbus_async.tcp_bridge --serial COM6 --port 7000
|
||||||
|
|
||||||
|
Адаптер открывается локально, а поверх него поднимается TCP-мост.
|
||||||
|
|
||||||
|
### 6.2. Клиент (удалённая машина)
|
||||||
|
|
||||||
|
На другой машине можно использовать тот же API, как с локальным COM, но через `open_tcp`:
|
||||||
|
````python
|
||||||
|
import asyncio
|
||||||
|
from carbus_async.device import CarBusDevice
|
||||||
|
from carbus_async.messages import 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
await dev.close()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
````
|
||||||
|
|
||||||
|
## 7. Логирование
|
||||||
|
|
||||||
|
Для отладки удобно включить подробное логирование:
|
||||||
|
````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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Структура проекта
|
||||||
|
|
||||||
|
carbus_async/
|
||||||
|
device.py — асинхронный интерфейс к адаптеру (CAN/CAN-FD)
|
||||||
|
protocol.py — описания команд, флагов и структур протокола
|
||||||
|
messages.py — модель CanMessage и вспомогательные типы
|
||||||
|
tcp_bridge.py — TCP-мост (сервер для удалённой работы)
|
||||||
|
|
||||||
|
isotp_async/
|
||||||
|
__init__.py — IsoTpChannel и вспомогательные сущности
|
||||||
|
|
||||||
|
uds_async/
|
||||||
|
client.py — UdsClient (клиент UDS)
|
||||||
|
server.py — UdsServer (сервер / эмулятор ЭБУ)
|
||||||
|
types.py — структуры запросов/ответов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Лицензия
|
||||||
|
|
||||||
|
MIT (можно поменять под нужды проекта).
|
||||||
|
|
||||||
|
Pull Requests и предложения по улучшению приветствуются 🚗
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
from .device import CarBusDevice
|
||||||
|
from .messages import CanMessage, MessageDirection
|
||||||
|
from .exceptions import CarBusError, CommandError, SyncError
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CarBusDevice",
|
||||||
|
"CanMessage",
|
||||||
|
"MessageDirection",
|
||||||
|
"CarBusError",
|
||||||
|
"CommandError",
|
||||||
|
"SyncError",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,10 @@
|
||||||
|
class CarBusError(Exception):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class SyncError(CarBusError):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class CommandError(CarBusError):
|
||||||
|
...
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .protocol import BusMessageFlags
|
||||||
|
|
||||||
|
|
||||||
|
class MessageDirection(str, Enum):
|
||||||
|
RX = "rx"
|
||||||
|
TX = "tx"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CanMessage:
|
||||||
|
can_id: int
|
||||||
|
data: bytes = b""
|
||||||
|
extended: bool = False
|
||||||
|
rtr: bool = False
|
||||||
|
fd: bool = False
|
||||||
|
brs: bool = False
|
||||||
|
timestamp_us: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dlc(self) -> int:
|
||||||
|
return len(self.data)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bus_payload(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
flags: BusMessageFlags,
|
||||||
|
timestamp_us: int,
|
||||||
|
can_id: int,
|
||||||
|
dlc: int,
|
||||||
|
data: bytes,
|
||||||
|
) -> "CanMessage":
|
||||||
|
extended = bool(flags & BusMessageFlags.EXTID)
|
||||||
|
rtr = bool(flags & BusMessageFlags.RTR)
|
||||||
|
fd = bool(flags & BusMessageFlags.FDF)
|
||||||
|
brs = bool(flags & BusMessageFlags.BRS)
|
||||||
|
|
||||||
|
payload = data[:dlc]
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
can_id=can_id,
|
||||||
|
data=payload,
|
||||||
|
extended=extended,
|
||||||
|
rtr=rtr,
|
||||||
|
fd=fd,
|
||||||
|
brs=brs,
|
||||||
|
timestamp_us=timestamp_us,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import IntEnum, IntFlag
|
||||||
|
|
||||||
|
|
||||||
|
class Command(IntEnum):
|
||||||
|
SYNC = 0xA5
|
||||||
|
DEVICE_INFO = 0x06
|
||||||
|
DEVICE_OPEN = 0x08
|
||||||
|
DEVICE_CLOSE = 0x09
|
||||||
|
CHANNEL_CONFIG = 0x11
|
||||||
|
CHANNEL_OPEN = 0x18
|
||||||
|
|
||||||
|
FILTER_SET = 0x21 # COMMAND_FILTER_SET
|
||||||
|
FILTER_CLEAR = 0x22 # COMMAND_FILTER_CLEAR
|
||||||
|
|
||||||
|
MESSAGE = 0x40
|
||||||
|
BUS_ERROR = 0x48
|
||||||
|
|
||||||
|
ERROR = 0xFF
|
||||||
|
|
||||||
|
|
||||||
|
class FilterType(IntEnum):
|
||||||
|
STD_11BIT = 0x00
|
||||||
|
EXT_29BIT = 0x01
|
||||||
|
|
||||||
|
|
||||||
|
EXTENDED_HEADER_COMMANDS = {Command.MESSAGE, Command.BUS_ERROR}
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderFlags(IntFlag):
|
||||||
|
NONE = 0x0000
|
||||||
|
|
||||||
|
CHANNEL_1 = 0x2000
|
||||||
|
CHANNEL_2 = 0x4000
|
||||||
|
CHANNEL_3 = 0x6000
|
||||||
|
CHANNEL_4 = 0x8000
|
||||||
|
|
||||||
|
CONFIRM_REQUIRED = 0x0001
|
||||||
|
|
||||||
|
|
||||||
|
class BusMessageFlags(IntFlag):
|
||||||
|
NONE = 0x00000000
|
||||||
|
|
||||||
|
EXTID = 0x00000001
|
||||||
|
RTR = 0x00000002
|
||||||
|
FDF = 0x00000004
|
||||||
|
BRS = 0x00000008
|
||||||
|
ESI = 0x00000010
|
||||||
|
|
||||||
|
ERROR_FRAME = 0x01000000
|
||||||
|
RX = 0x10000000
|
||||||
|
TX = 0x20000000
|
||||||
|
BLOCK_TX = 0x30000000
|
||||||
|
|
||||||
|
|
||||||
|
CC_MULTIWORD = 0x80000000
|
||||||
|
|
||||||
|
DI_MULTIWORD = CC_MULTIWORD
|
||||||
|
|
||||||
|
DI_HARDWARE_ID = 0x01000000 # тип устройства (HWIdentifiers)
|
||||||
|
DI_FIRMWARE_VERSION = 0x02000000 # строка версии прошивки (ASCII, MULTIWORD)
|
||||||
|
DI_DEVICE_SERIAL = 0x03000000 # серийный номер (бинарно, MULTIWORD)
|
||||||
|
DI_FEATURES = 0x11000000 # битовая маска общих фич
|
||||||
|
DI_CHANNEL_MAP = 0x12000000 # карта каналов (тип канала)
|
||||||
|
DI_CHANNEL_FEATURES = 0x13000000 # опции по каналам (ALC, TERMINATOR, ...)
|
||||||
|
DI_FILTER = 0x14000000 # настройки фильтров по каналам
|
||||||
|
DI_GATEWAY = 0x15000000 # возможные пробросы между каналами
|
||||||
|
DI_CHANNEL_FREQUENCY = 0x16000000 # частота работы CAN/CAN-FD модуля
|
||||||
|
DI_ISOTP = 0x21000000 # размер ISO-TP буфера
|
||||||
|
DI_TX_BUFFER = 0x22000000 # размер буфера трейсера (в сообщениях)
|
||||||
|
DI_TX_TASK = 0x23000000 # количество задач периодической отправки
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CommandHeader:
|
||||||
|
command: int
|
||||||
|
sequence: int
|
||||||
|
flags: int
|
||||||
|
dsize: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> "CommandHeader":
|
||||||
|
if len(data) != 4:
|
||||||
|
raise ValueError("CommandHeader требует ровно 4 байта")
|
||||||
|
cmd, seq, flg, size = data
|
||||||
|
return cls(cmd, seq, flg, size)
|
||||||
|
|
||||||
|
def to_bytes(self) -> bytes:
|
||||||
|
return bytes(
|
||||||
|
(
|
||||||
|
self.command & 0xFF,
|
||||||
|
self.sequence & 0xFF,
|
||||||
|
self.flags & 0xFF,
|
||||||
|
self.dsize & 0xFF,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MsgCommandHeader:
|
||||||
|
command: int
|
||||||
|
sequence: int
|
||||||
|
flags: int
|
||||||
|
dsize: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, data: bytes) -> "MsgCommandHeader":
|
||||||
|
if len(data) != 6:
|
||||||
|
raise ValueError("MsgCommandHeader требует ровно 6 байт")
|
||||||
|
cmd = data[0]
|
||||||
|
seq = data[1]
|
||||||
|
flags = int.from_bytes(data[2:4], "little")
|
||||||
|
dsize = int.from_bytes(data[4:6], "little")
|
||||||
|
return cls(cmd, seq, flags, dsize)
|
||||||
|
|
||||||
|
def to_bytes(self) -> bytes:
|
||||||
|
return (
|
||||||
|
bytes(
|
||||||
|
(
|
||||||
|
self.command & 0xFF,
|
||||||
|
self.sequence & 0xFF,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ self.flags.to_bytes(2, "little")
|
||||||
|
+ self.dsize.to_bytes(2, "little")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_ack(cmd: int) -> bool:
|
||||||
|
return 0x80 <= cmd < 0xFF
|
||||||
|
|
||||||
|
|
||||||
|
def base_command_from_ack(cmd: int) -> int:
|
||||||
|
return cmd & 0x7F
|
||||||
|
|
||||||
|
|
||||||
|
def need_extended_header(command: int) -> bool:
|
||||||
|
try:
|
||||||
|
c = Command(command)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return c in EXTENDED_HEADER_COMMANDS
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import serial_asyncio
|
||||||
|
|
||||||
|
log = logging.getLogger("carbus_async.tcp_bridge")
|
||||||
|
|
||||||
|
|
||||||
|
async def _pump(
|
||||||
|
src: asyncio.StreamReader,
|
||||||
|
dst: asyncio.StreamWriter,
|
||||||
|
direction: str,
|
||||||
|
chunk_size: int = 4096,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await src.read(chunk_size)
|
||||||
|
if not data:
|
||||||
|
log.debug("%s: EOF", direction)
|
||||||
|
break
|
||||||
|
dst.write(data)
|
||||||
|
await dst.drain()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
log.debug("%s: cancelled", direction)
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("%s: exception: %s", direction, e)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
dst.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_client(
|
||||||
|
tcp_reader: asyncio.StreamReader,
|
||||||
|
tcp_writer: asyncio.StreamWriter,
|
||||||
|
*,
|
||||||
|
serial_port: str,
|
||||||
|
baudrate: int = 115200,
|
||||||
|
) -> None:
|
||||||
|
peer = tcp_writer.get_extra_info("peername")
|
||||||
|
log.info("Client connected: %s", peer)
|
||||||
|
|
||||||
|
try:
|
||||||
|
serial_reader, serial_writer = await serial_asyncio.open_serial_connection(
|
||||||
|
url=serial_port,
|
||||||
|
baudrate=baudrate,
|
||||||
|
)
|
||||||
|
log.info("Opened local serial %s @ %d for %s", serial_port, baudrate, peer)
|
||||||
|
|
||||||
|
pump_tcp_to_serial = asyncio.create_task(
|
||||||
|
_pump(tcp_reader, serial_writer, f"{peer} tcp->serial")
|
||||||
|
)
|
||||||
|
pump_serial_to_tcp = asyncio.create_task(
|
||||||
|
_pump(serial_reader, tcp_writer, f"{peer} serial->tcp")
|
||||||
|
)
|
||||||
|
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[pump_tcp_to_serial, pump_serial_to_tcp],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
with asyncio.SuppressCancelledError if hasattr(asyncio, "SuppressCancelledError") else nullcontext():
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error in handle_client %s: %s", peer, e)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
tcp_writer.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
log.info("Client disconnected: %s", peer)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_tcp_bridge(
|
||||||
|
*,
|
||||||
|
listen_host: str = "0.0.0.0",
|
||||||
|
listen_port: int = 7000,
|
||||||
|
serial_port: str = "COM6",
|
||||||
|
baudrate: int = 115200,
|
||||||
|
) -> None:
|
||||||
|
server = await asyncio.start_server(
|
||||||
|
lambda r, w: handle_client(r, w, serial_port=serial_port, baudrate=baudrate),
|
||||||
|
listen_host,
|
||||||
|
listen_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
addr = ", ".join(str(sock.getsockname()) for sock in server.sockets or [])
|
||||||
|
log.info(
|
||||||
|
"TCP bridge listening on %s -> serial %s @ %d",
|
||||||
|
addr,
|
||||||
|
serial_port,
|
||||||
|
baudrate,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with server:
|
||||||
|
await server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="TCP bridge for CarBus device (raw byte forwarder)."
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default="0.0.0.0", help="Listen host (default 0.0.0.0)")
|
||||||
|
parser.add_argument("--port", type=int, default=7000, help="Listen TCP port (default 7000)")
|
||||||
|
parser.add_argument("--serial", required=True, help="Local serial port (e.g. COM6, /dev/ttyACM0)")
|
||||||
|
parser.add_argument("--baudrate", type=int, default=115200, help="Serial baudrate (default 115200)")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
async def _main() -> None:
|
||||||
|
await run_tcp_bridge(
|
||||||
|
listen_host=args.host,
|
||||||
|
listen_port=args.port,
|
||||||
|
serial_port=args.serial,
|
||||||
|
baudrate=args.baudrate,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(_main())
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
from .carbus_iface import CarBusCanTransport
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CarBusCanTransport",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from carbus_async.device import CarBusDevice
|
||||||
|
from carbus_async.messages import CanMessage
|
||||||
|
|
||||||
|
|
||||||
|
from .iface import CanTransport
|
||||||
|
|
||||||
|
|
||||||
|
class CarBusCanTransport(CanTransport):
|
||||||
|
def __init__(self, dev: CarBusDevice, channel: int, rx_id: int) -> None:
|
||||||
|
self._dev = dev
|
||||||
|
self._channel = channel
|
||||||
|
self._rx_id = rx_id
|
||||||
|
|
||||||
|
async def send(self, msg: CanMessage) -> None:
|
||||||
|
await self._dev.send_can(
|
||||||
|
msg,
|
||||||
|
channel=self._channel,
|
||||||
|
confirm=False,
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def recv(self, timeout: Optional[float] = None) -> Optional[CanMessage]:
|
||||||
|
while True:
|
||||||
|
if timeout is None:
|
||||||
|
ch, msg = await self._dev.receive_can()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
ch, msg = await asyncio.wait_for(
|
||||||
|
self._dev.receive_can(),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if ch != self._channel:
|
||||||
|
continue
|
||||||
|
if msg.can_id != self._rx_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return msg
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, Optional
|
||||||
|
from carbus_async.messages import CanMessage
|
||||||
|
|
||||||
|
|
||||||
|
class CanTransport(Protocol):
|
||||||
|
|
||||||
|
async def send(self, msg: CanMessage) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def recv(self, timeout: Optional[float] = None) -> Optional[CanMessage]:
|
||||||
|
...
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from carbus_async.messages import CanMessage
|
||||||
|
from .iface import CanTransport
|
||||||
|
|
||||||
|
|
||||||
|
def _st_min_to_seconds(st_min: int) -> float:
|
||||||
|
|
||||||
|
if 0x00 <= st_min <= 0x7F:
|
||||||
|
return st_min / 1000.0
|
||||||
|
if 0xF1 <= st_min <= 0xF9:
|
||||||
|
return (st_min - 0xF0) * 100e-6 # 0xF1 -> 1*100us, ...
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IsoTpChannel:
|
||||||
|
|
||||||
|
can: CanTransport
|
||||||
|
tx_id: int
|
||||||
|
rx_id: int
|
||||||
|
|
||||||
|
block_size: int = 8
|
||||||
|
st_min_ms: int = 0
|
||||||
|
fc_timeout: float = 1.0
|
||||||
|
cf_timeout: float = 1.0
|
||||||
|
|
||||||
|
async def send_pdu(self, data: bytes) -> None:
|
||||||
|
length = len(data)
|
||||||
|
if length <= 7:
|
||||||
|
# Single Frame
|
||||||
|
pci = bytes([0x00 | (length & 0x0F)])
|
||||||
|
|
||||||
|
if len(data) < 7:
|
||||||
|
for _ in range(7-len(data)):
|
||||||
|
data = data + b"\xaa"
|
||||||
|
|
||||||
|
msg = CanMessage(
|
||||||
|
can_id=self.tx_id,
|
||||||
|
data=pci + data,
|
||||||
|
)
|
||||||
|
await self.can.send(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Multi-frame: First Frame ---
|
||||||
|
len_hi = (length >> 8) & 0x0F
|
||||||
|
len_lo = length & 0xFF
|
||||||
|
ff_pci0 = 0x10 | len_hi # high nibble = 0x1 (FF), low=high bits len
|
||||||
|
ff_pci1 = len_lo
|
||||||
|
|
||||||
|
first_data = data[:6]
|
||||||
|
ff_payload = bytes([ff_pci0, ff_pci1]) + first_data
|
||||||
|
msg = CanMessage(
|
||||||
|
can_id=self.tx_id,
|
||||||
|
data=ff_payload,
|
||||||
|
)
|
||||||
|
await self.can.send(msg)
|
||||||
|
|
||||||
|
offset = 6
|
||||||
|
|
||||||
|
# --- FlowControl от peer ---
|
||||||
|
fc_msg = await self.can.recv(timeout=self.fc_timeout)
|
||||||
|
if fc_msg is None:
|
||||||
|
raise asyncio.TimeoutError("ISO-TP: FlowControl timeout (N_Bs)")
|
||||||
|
|
||||||
|
if not fc_msg.data:
|
||||||
|
raise RuntimeError("ISO-TP: empty FlowControl frame")
|
||||||
|
|
||||||
|
fc_pci = fc_msg.data[0]
|
||||||
|
fc_type = fc_pci >> 4 # 3 = FC
|
||||||
|
|
||||||
|
if fc_type != 0x3:
|
||||||
|
raise RuntimeError(f"ISO-TP: expected FlowControl, got PCI=0x{fc_pci:02X}")
|
||||||
|
|
||||||
|
fs = fc_pci & 0x0F # FS (0=CTS, 1=WT, 2=OVFLW)
|
||||||
|
bs = fc_msg.data[1] if len(fc_msg.data) > 1 else 0
|
||||||
|
st_min_raw = fc_msg.data[2] if len(fc_msg.data) > 2 else self.st_min_ms
|
||||||
|
|
||||||
|
if fs == 0x2:
|
||||||
|
raise RuntimeError("ISO-TP: FlowControl OVFLW from peer")
|
||||||
|
|
||||||
|
# Для простоты: поддерживаем только FS=CTS (0x0)
|
||||||
|
if fs != 0x0:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# --- Consecutive Frames ---
|
||||||
|
seq_num = 1
|
||||||
|
frames_in_block = 0
|
||||||
|
|
||||||
|
while offset < length:
|
||||||
|
if bs != 0 and frames_in_block >= bs:
|
||||||
|
# BS, FC
|
||||||
|
fc_msg = await self.can.recv(timeout=self.fc_timeout)
|
||||||
|
if fc_msg is None:
|
||||||
|
raise asyncio.TimeoutError("ISO-TP: second FlowControl timeout (N_Bs)")
|
||||||
|
|
||||||
|
fc_pci = fc_msg.data[0]
|
||||||
|
fc_type = fc_pci >> 4
|
||||||
|
if fc_type != 0x3:
|
||||||
|
raise RuntimeError(f"ISO-TP: expected FlowControl, got PCI=0x{fc_pci:02X}")
|
||||||
|
fs = fc_pci & 0x0F
|
||||||
|
bs = fc_msg.data[1] if len(fc_msg.data) > 1 else 0
|
||||||
|
st_min_raw = fc_msg.data[2] if len(fc_msg.data) > 2 else self.st_min_ms
|
||||||
|
|
||||||
|
if fs == 0x2:
|
||||||
|
raise RuntimeError("ISO-TP: FlowControl OVFLW from peer")
|
||||||
|
if fs != 0x0:
|
||||||
|
raise RuntimeError(f"ISO-TP: unsupported FlowStatus=0x{fs:02X}")
|
||||||
|
|
||||||
|
if bs == 0x00:
|
||||||
|
bs = 0
|
||||||
|
st_min = _st_min_to_seconds(st_min_raw)
|
||||||
|
frames_in_block = 0
|
||||||
|
|
||||||
|
chunk = data[offset:offset + 7]
|
||||||
|
offset += len(chunk)
|
||||||
|
|
||||||
|
cf_pci = 0x20 | (seq_num & 0x0F) # high nibble = 0x2 (CF)
|
||||||
|
cf_payload = bytes([cf_pci]) + chunk
|
||||||
|
|
||||||
|
if len(cf_payload) < 8:
|
||||||
|
for _ in range(8 - len(cf_payload)):
|
||||||
|
cf_payload = cf_payload + b"\xaa"
|
||||||
|
|
||||||
|
msg = CanMessage(
|
||||||
|
can_id=self.tx_id,
|
||||||
|
data=cf_payload,
|
||||||
|
)
|
||||||
|
await self.can.send(msg)
|
||||||
|
|
||||||
|
seq_num = (seq_num + 1) & 0x0F
|
||||||
|
if seq_num == 0:
|
||||||
|
seq_num = 0x1
|
||||||
|
|
||||||
|
frames_in_block += 1
|
||||||
|
|
||||||
|
if st_min > 0:
|
||||||
|
await asyncio.sleep(st_min)
|
||||||
|
|
||||||
|
async def recv_pdu(self, timeout: float = 1.0) -> Optional[bytes]:
|
||||||
|
|
||||||
|
first = await self.can.recv(timeout=timeout)
|
||||||
|
if first is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = first.data
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pci = data[0]
|
||||||
|
frame_type = pci >> 4
|
||||||
|
|
||||||
|
# --- Single Frame ---
|
||||||
|
if frame_type == 0x0:
|
||||||
|
length = pci & 0x0F
|
||||||
|
return data[1:1 + length]
|
||||||
|
|
||||||
|
# --- First Frame ---
|
||||||
|
if frame_type != 0x1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# FF length
|
||||||
|
length_hi = pci & 0x0F
|
||||||
|
length_lo = data[1] if len(data) > 1 else 0
|
||||||
|
total_length = (length_hi << 8) | length_lo
|
||||||
|
|
||||||
|
payload = bytearray(data[2:])
|
||||||
|
|
||||||
|
bs = self.block_size
|
||||||
|
st_raw = self.st_min_ms
|
||||||
|
fc_pci = 0x30 # type=3 (FC), FS=0 (CTS)
|
||||||
|
fc_payload = bytes([fc_pci, bs & 0xFF, st_raw & 0xFF])
|
||||||
|
|
||||||
|
if len(fc_payload) < 8:
|
||||||
|
fc_payload = fc_payload + b"\x00" * (8 - len(fc_payload))
|
||||||
|
|
||||||
|
fc_frame = CanMessage(
|
||||||
|
can_id=self.tx_id,
|
||||||
|
data=fc_payload,
|
||||||
|
)
|
||||||
|
await self.can.send(fc_frame)
|
||||||
|
|
||||||
|
st_min = _st_min_to_seconds(st_raw)
|
||||||
|
|
||||||
|
expected_sn = 1
|
||||||
|
while len(payload) < total_length:
|
||||||
|
cf = await self.can.recv(timeout=self.cf_timeout)
|
||||||
|
if cf is None:
|
||||||
|
raise asyncio.TimeoutError("ISO-TP: CF timeout (N_Cr)")
|
||||||
|
|
||||||
|
if not cf.data:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cf_pci = cf.data[0]
|
||||||
|
cf_type = cf_pci >> 4
|
||||||
|
if cf_type != 0x2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sn = cf_pci & 0x0F
|
||||||
|
if sn != (expected_sn & 0x0F):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"ISO-TP: wrong sequence number: got {sn}, expected {expected_sn}"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload.extend(cf.data[1:])
|
||||||
|
expected_sn = (expected_sn + 1) & 0x0F
|
||||||
|
if expected_sn == 0:
|
||||||
|
expected_sn = 1
|
||||||
|
|
||||||
|
if st_min > 0:
|
||||||
|
await asyncio.sleep(st_min)
|
||||||
|
|
||||||
|
return bytes(payload[:total_length])
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from carbus_async.device import CarBusDevice
|
||||||
|
from isotp_async.carbus_iface import CarBusCanTransport
|
||||||
|
from isotp_async.transport import IsoTpChannel
|
||||||
|
from uds_async.client import UdsClient
|
||||||
|
|
||||||
|
# import logging
|
||||||
|
#
|
||||||
|
# logging.basicConfig(
|
||||||
|
# level=logging.DEBUG,
|
||||||
|
# format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# logging.getLogger("carbus_async.wire").setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
dev = await CarBusDevice.open("COM6")
|
||||||
|
|
||||||
|
await dev.open_can_channel(
|
||||||
|
channel=1,
|
||||||
|
nominal_bitrate=500_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
await dev.set_terminator(channel=1, enabled=True)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
info = await dev.get_device_info()
|
||||||
|
print("HW:", info.hardware_id, info.hardware_name)
|
||||||
|
print("FW:", info.firmware_version)
|
||||||
|
print("Serial #", info.serial_int)
|
||||||
|
|
||||||
|
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 "",
|
||||||
|
)
|
||||||
|
|
||||||
|
await dev.clear_all_filters(1)
|
||||||
|
await dev.set_std_id_filter(1, index=0, can_id=0x7E8, mask=0x7FF)
|
||||||
|
|
||||||
|
can_tr = CarBusCanTransport(dev, channel=1, rx_id=0x7E8)
|
||||||
|
isotp = IsoTpChannel(can_tr, tx_id=0x7E0, rx_id=0x7E8)
|
||||||
|
uds = UdsClient(isotp)
|
||||||
|
|
||||||
|
vin = await uds.read_data_by_identifier(0xF190)
|
||||||
|
print("VIN:", vin.decode(errors="ignore"))
|
||||||
|
|
||||||
|
await dev.close()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=64", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "carbus-lib"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Async CAN / ISO-TP / UDS library for Car Bus Analyzer"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [{ name = "Mike" }]
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"pyserial>=3.5",
|
||||||
|
"pyserial-asyncio>=0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/controllerzz/car_bus_lib"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "."}
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["carbus_async", "isotp_async", "uds_async"]
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
from .client import UdsClient
|
||||||
|
from .server import UdsServer
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .types import (
|
||||||
|
UdsRequest,
|
||||||
|
UdsResponse,
|
||||||
|
UdsPositiveResponse,
|
||||||
|
UdsNegativeResponse,
|
||||||
|
ResponseCode,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"UdsClient",
|
||||||
|
"UdsServer",
|
||||||
|
]
|
||||||
|
except ImportError:
|
||||||
|
# если types.py нет или переименован — хотя бы клиент и сервер доступны
|
||||||
|
__all__ = [
|
||||||
|
"UdsClient",
|
||||||
|
"UdsServer",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from isotp_async.transport import IsoTpChannel
|
||||||
|
from .exceptions import UdsError, UdsNegativeResponse
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UdsClient:
|
||||||
|
|
||||||
|
isotp: IsoTpChannel
|
||||||
|
p2_timeout: float = 1.0
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
if suppress_response:
|
||||||
|
try:
|
||||||
|
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 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:]
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
class UdsError(Exception):
|
||||||
|
...
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UdsNegativeResponse(UdsError):
|
||||||
|
|
||||||
|
req_sid: int
|
||||||
|
nrc: int
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"UDS NRC 0x{self.nrc:02X} for SID 0x{self.req_sid:02X}"
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Awaitable, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from isotp_async.transport import IsoTpChannel
|
||||||
|
from .exceptions import UdsNegativeResponse
|
||||||
|
|
||||||
|
UdsHandler = Callable[[bytes], Awaitable[Optional[bytes]]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UdsServer:
|
||||||
|
|
||||||
|
isotp: IsoTpChannel
|
||||||
|
p2_timeout: float = 1.0
|
||||||
|
handlers: Dict[int, UdsHandler] = field(default_factory=dict)
|
||||||
|
|
||||||
|
async def serve_forever(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
req = await self.isotp.recv_pdu(timeout=self.p2_timeout)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not req:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sid = req[0]
|
||||||
|
handler = self.handlers.get(sid)
|
||||||
|
if handler is None:
|
||||||
|
# ServiceNotSupported
|
||||||
|
await self._send_negative_response(sid, 0x11)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = await handler(req)
|
||||||
|
if resp is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
await self.isotp.send_pdu(resp)
|
||||||
|
|
||||||
|
except UdsNegativeResponse as e:
|
||||||
|
await self._send_negative_response(e.req_sid, e.nrc)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# General programming failure (0x72)
|
||||||
|
await self._send_negative_response(sid, 0x72)
|
||||||
|
|
||||||
|
async def _send_negative_response(self, sid: int, nrc: int) -> None:
|
||||||
|
payload = bytes([0x7F, sid & 0xFF, nrc & 0xFF])
|
||||||
|
await self.isotp.send_pdu(payload)
|
||||||
|
|
||||||
|
def add_handler(self, sid: int, handler: UdsHandler) -> None:
|
||||||
|
self.handlers[sid & 0xFF] = handler
|
||||||
|
|
||||||
|
def service(self, sid: int):
|
||||||
|
def decorator(func: UdsHandler) -> UdsHandler:
|
||||||
|
self.add_handler(sid, func)
|
||||||
|
return func
|
||||||
|
return decorator
|
||||||
Loading…
Reference in New Issue