add CanIdRouter and examle

This commit is contained in:
controllerzz 2025-12-16 12:34:37 +03:00
parent 3032d674c8
commit e016892428
10 changed files with 344 additions and 17 deletions

View File

@ -1,6 +1,7 @@
from .device import CarBusDevice from .device import CarBusDevice
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
__all__ = [ __all__ = [
"CarBusDevice", "CarBusDevice",
@ -9,4 +10,6 @@ __all__ = [
"CarBusError", "CarBusError",
"CommandError", "CommandError",
"SyncError", "SyncError",
"CanIdRouter",
"RoutedCarBusCanTransport",
] ]

View File

@ -0,0 +1,71 @@
import asyncio
import contextlib
from typing import Dict, Optional, Tuple
from carbus_async import CanMessage
from isotp_async.iface import CanTransport
class CanIdRouter:
def __init__(self, dev, channel: int, queue_size: int = 256):
self._dev = dev
self._channel = channel
self._queues: Dict[int, asyncio.Queue] = {}
self._queue_size = queue_size
self._task: Optional[asyncio.Task] = None
self._stop = asyncio.Event()
def get_queue(self, can_id: int) -> asyncio.Queue:
q = self._queues.get(can_id)
if q is None:
q = asyncio.Queue(maxsize=self._queue_size)
self._queues[can_id] = q
return q
async def start(self):
if self._task is None:
self._task = asyncio.create_task(self._run())
async def stop(self):
self._stop.set()
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
self._task = None
async def _run(self):
while not self._stop.is_set():
ch, msg = await self._dev.receive_can()
if ch != self._channel:
continue
q = self._queues.get(msg.can_id)
if q is None:
# никто не подписан на этот CAN-ID — просто игнор
continue
# если очередь забита — можно дропать самый старый или новый
if q.full():
_ = q.get_nowait()
q.put_nowait(msg)
class RoutedCarBusCanTransport(CanTransport):
def __init__(self, dev, channel: int, rx_id: int, router: CanIdRouter) -> None:
self._dev = dev
self._channel = channel
self._rx_id = rx_id
self._router = router
self._queue = router.get_queue(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]:
try:
if timeout is None:
return await self._queue.get()
return await asyncio.wait_for(self._queue.get(), timeout=timeout)
except asyncio.TimeoutError:
return None

View File

@ -0,0 +1,205 @@
import asyncio
import pickle
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict
from carbus_async import CanIdRouter, RoutedCarBusCanTransport
from carbus_async.device import CarBusDevice
from isotp_async import open_isotp, IsoTpConnection
from uds_async import UdsServer
from uds_async.exceptions import UdsNegativeResponse, UdsError
# import logging
#
# logging.basicConfig(
# level=logging.DEBUG,
# format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
# )
TESTER_ID = 0x740
ECU_ID = 0x760
DEFAULT_PORT = "COM6"
DEFAULT_BAUD = 115200
DEFAULT_CAN_CH = 1
DEFAULT_CAN_BITRATE = 500_000
CHANNEL = 1
IN_FILE_ECU = Path("ecu_uds22_params.pkl")
IN_FILE_ABS = Path("uds22_params.pkl")
def save_dict_pickle(path: str | Path, data: dict[int, bytes]) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("wb") as f:
pickle.dump(data, f, protocol=pickle.HIGHEST_PROTOCOL)
def load_dict_pickle(path: str | Path) -> dict[int, bytes]:
path = Path(path)
with path.open("rb") as f:
obj = pickle.load(f)
if not isinstance(obj, dict):
raise TypeError(f"Expected dict in pickle file, got {type(obj).__name__}")
return obj
async def setup_device(
port: str = DEFAULT_PORT,
baudrate: int = DEFAULT_BAUD,
can_channel: int = DEFAULT_CAN_CH,
nominal_bitrate: int = DEFAULT_CAN_BITRATE,
) -> CarBusDevice:
dev = await CarBusDevice.open(port, baudrate=baudrate)
await dev.open_can_channel(channel=can_channel, nominal_bitrate=nominal_bitrate)
await dev.set_terminator(channel=can_channel, enabled=True)
await dev.clear_all_filters(can_channel)
await dev.set_std_id_filter(channel=can_channel, index=0, can_id=0x700, mask=0x700)
return dev
async def setup_uds(dev: CarBusDevice, can_channel: int = DEFAULT_CAN_CH) -> UdsServer:
ecu_isotp = await open_isotp(dev, channel=can_channel, tx_id=ECU_ID, rx_id=TESTER_ID)
ecu_uds = UdsServer(ecu_isotp)
return ecu_uds
NRC_INCORRECT_LENGTH = 0x13
NRC_REQUEST_OUT_OF_RANGE = 0x31
@dataclass
class UdsServiceState:
did_store: Dict[int, bytes]
def _require_len(req: bytes, n: int, sid: int) -> None:
if len(req) < n:
raise UdsNegativeResponse(sid, NRC_INCORRECT_LENGTH)
def _did_from_req(req: bytes, sid: int) -> int:
_require_len(req, 3, sid)
return (req[1] << 8) | req[2]
def _rdbi_positive(req: bytes, payload: bytes) -> bytes:
return bytes((0x62, req[1], req[2])) + payload
def _wdbi_positive(req: bytes) -> bytes:
return bytes((0x6E, req[1], req[2]))
async def services_init(uds, *, in_file: str | Path) -> UdsServiceState:
did_store: Dict[int, bytes] = load_dict_pickle(path=in_file)
state = UdsServiceState(did_store=did_store)
@uds.service(0x10)
async def handle_session_control(req: bytes) -> bytes:
print(f"UDS OpenSession {hex(req[1])}")
_require_len(req, 2, 0x10)
session = req[1]
return bytes((0x50, session, 0x00, 0x32, 0x01, 0xF4))
@uds.service(0x11)
async def handle_session_control(req: bytes) -> bytes:
print(f"UDS ECU Reset {hex(req[1])}")
return bytes((0x51, req[1]))
@uds.service(0x14)
async def handle_session_control(req: bytes) -> bytes:
return bytes((0x54, 0xFF, 0xFF, 0xFF, 0xFF))
@uds.service(0x19)
async def handle_session_control(req: bytes) -> bytes:
return bytes((0x59, 0x00))
@uds.service(0x22)
async def handle_rdbi(req: bytes) -> bytes:
did = _did_from_req(req, 0x22)
payload = state.did_store.get(did)
if payload is None:
raise UdsNegativeResponse(0x22, NRC_REQUEST_OUT_OF_RANGE)
print(f"UDS Read Param {hex(did)}: {payload.hex()}")
return _rdbi_positive(req, payload)
@uds.service(0x27)
async def handle_security_access(req: bytes) -> bytes:
_require_len(req, 2, 0x27)
sub = req[1]
is_seed_request = bool(sub & 0x01)
if is_seed_request:
return bytes((0x67, sub, 0x11, 0x22, 0x33, 0x44))
_require_len(req, 6, 0x27)
return bytes((0x67, sub))
@uds.service(0x2E)
async def handle_wdbi(req: bytes) -> bytes:
_require_len(req, 4, 0x2E)
did = (req[1] << 8) | req[2]
state.did_store[did] = bytes(req[3:])
print(f"UDS Write Param {hex(did)}: {bytes(req[3:]).hex()}")
return _wdbi_positive(req)
@uds.service(0x31)
async def handle_routine_control(req: bytes) -> bytes:
_require_len(req, 4, 0x31)
print(f"UDS Routine Control {hex(req[1])} {hex(req[2])} {hex(req[3])}")
return bytes((0x71, req[1], req[2], req[3]))
@uds.service(0x3E)
async def handle_tester_present(req: bytes) -> bytes:
sub = req[1] if len(req) > 1 else 0x00
return bytes((0x7E, sub))
return state
async def main() -> None:
dev: CarBusDevice | None = None
try:
dev = await setup_device()
router = CanIdRouter(dev, channel=CHANNEL)
await router.start()
# ECU #1
tr_engine = RoutedCarBusCanTransport(dev, CHANNEL, rx_id=0x7E0, router=router)
isotp_engine = IsoTpConnection(can=tr_engine, tx_id=0x7E8, rx_id=0x7E0)
uds_engine = UdsServer(isotp_engine)
await services_init(uds_engine, in_file=IN_FILE_ECU)
# ECU #2
tr_abs = RoutedCarBusCanTransport(dev, CHANNEL, rx_id=0x740, router=router)
isotp_abs = IsoTpConnection(can=tr_abs, tx_id=0x760, rx_id=0x740)
uds_abs = UdsServer(isotp_abs)
await services_init(uds_abs, in_file=IN_FILE_ABS)
print("UDS servers running")
await asyncio.gather(
uds_engine.serve_forever(),
uds_abs.serve_forever(),
)
finally:
if dev is not None:
await dev.close()
if __name__ == "__main__":
asyncio.run(main())

Binary file not shown.

View File

@ -36,7 +36,6 @@ async def setup_device(cfg: CanScanConfig) -> CarBusDevice:
mask=0x700, mask=0x700,
) )
await dev.clear_receive_buffer()
return dev return dev

View File

@ -10,16 +10,16 @@ from uds_async import UdsClient
from uds_async.exceptions import UdsNegativeResponse, UdsError from uds_async.exceptions import UdsNegativeResponse, UdsError
TESTER_ID = 0x740 TESTER_ID = 0x7E0
ECU_ID = 0x760 ECU_ID = 0x7E8
DEFAULT_PORT = "COM6" DEFAULT_PORT = "COM6"
DEFAULT_BAUD = 115200 DEFAULT_BAUD = 115200
DEFAULT_CAN_CH = 1 DEFAULT_CAN_CH = 1
DEFAULT_CAN_BITRATE = 500_000 DEFAULT_CAN_BITRATE = 500_000
DID_RANGE = range(0x10000) DID_RANGE = range(0x0000, 0x10000)
OUT_FILE = Path("uds22_params.pkl") OUT_FILE = Path("ecu_uds22_params.pkl")
def save_dict_pickle(path: str | Path, data: dict[int, bytes]) -> None: def save_dict_pickle(path: str | Path, data: dict[int, bytes]) -> None:
@ -47,11 +47,12 @@ async def setup_device(
dev = await CarBusDevice.open(port, baudrate=baudrate) dev = await CarBusDevice.open(port, baudrate=baudrate)
await dev.open_can_channel(channel=can_channel, nominal_bitrate=nominal_bitrate) await dev.open_can_channel(channel=can_channel, nominal_bitrate=nominal_bitrate)
await dev.set_terminator(channel=can_channel, enabled=True)
await dev.clear_all_filters(can_channel) # await dev.clear_all_filters(can_channel)
await dev.set_std_id_filter(channel=can_channel, index=0, can_id=0x700, mask=0x700) await dev.set_std_id_filter(channel=can_channel, index=0, can_id=0x700, mask=0x700)
await dev.set_terminator(channel=can_channel, enabled=True)
return dev return dev

View File

@ -1,9 +1,10 @@
from .carbus_iface import CarBusCanTransport from .carbus_iface import CarBusCanTransport
from .transport import IsoTpChannel from .transport import IsoTpChannel, IsoTpConnection
from .api import open_isotp from .api import open_isotp
__all__ = [ __all__ = [
"CarBusCanTransport", "CarBusCanTransport",
"IsoTpChannel", "IsoTpChannel",
"IsoTpConnection",
"open_isotp", "open_isotp",
] ]

View File

@ -3,7 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from isotp_async import IsoTpChannel, CarBusCanTransport # from carbus_async import CanIdRouter, RoutedCarBusCanTransport
from isotp_async import IsoTpChannel, CarBusCanTransport, IsoTpConnection
@dataclass(frozen=True) @dataclass(frozen=True)
@ -13,9 +14,16 @@ class IsoTpEndpoint:
channel: int = 1 channel: int = 1
async def open_isotp(dev: Any, *, endpoint: IsoTpEndpoint | None = None, async def open_isotp(
channel: int = 1, tx_id: int | None = None, rx_id: int | None = None, dev: Any,
**channel_kwargs) -> IsoTpChannel: *,
endpoint: IsoTpEndpoint | None = None,
channel: int = 1,
tx_id: int | None = None,
rx_id: int | None = None,
router: Any | None = None,
**channel_kwargs,
) -> IsoTpChannel:
if endpoint is not None: if endpoint is not None:
channel = endpoint.channel channel = endpoint.channel
@ -25,5 +33,30 @@ async def open_isotp(dev: Any, *, endpoint: IsoTpEndpoint | None = None,
if tx_id is None or rx_id is None: if tx_id is None or rx_id is None:
raise ValueError("tx_id and rx_id are required (or pass endpoint=...)") raise ValueError("tx_id and rx_id are required (or pass endpoint=...)")
can_tr = CarBusCanTransport(dev, channel=channel, rx_id=rx_id) # ЛЕНИВЫЕ ИМПОРТЫ, чтобы не было circular import:
if router is None:
from isotp_async.carbus_iface import CarBusCanTransport # <-- подстрой путь под твой проект
can_tr = CarBusCanTransport(dev, channel=channel, rx_id=rx_id)
else:
from carbus_async.can_router import RoutedCarBusCanTransport # <-- подстрой путь
can_tr = RoutedCarBusCanTransport(dev, channel=channel, rx_id=rx_id, router=router)
return IsoTpChannel(can_tr, tx_id=tx_id, rx_id=rx_id, **channel_kwargs) return IsoTpChannel(can_tr, tx_id=tx_id, rx_id=rx_id, **channel_kwargs)
@dataclass(frozen=True)
class IsoTpCanEndpoint:
rx_id: int # что слушаем (request -> ECU)
tx_id: int # куда отвечаем (ECU -> tester)
class IsoTpNetwork:
def __init__(self, base_send, router: CanIdRouter):
self._send = base_send
self._router = router
def endpoint(self, ep: IsoTpCanEndpoint) -> IsoTpConnection:
transport = RoutedCarBusCanTransport(
dev=..., channel=..., rx_id=ep.rx_id, router=self._router
)
return IsoTpConnection(transport=transport, tx_id=ep.tx_id)

View File

@ -24,7 +24,7 @@ class IsoTpChannel:
tx_id: int tx_id: int
rx_id: int rx_id: int
block_size: int = 8 block_size: int = 0
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
@ -214,10 +214,24 @@ class IsoTpChannel:
payload.extend(cf.data[1:]) payload.extend(cf.data[1:])
expected_sn = (expected_sn + 1) & 0x0F expected_sn = (expected_sn + 1) & 0x0F
if expected_sn == 0: # надо подумать, гдето нужно так((((
expected_sn = 1 # if expected_sn == 0:
# expected_sn = 1
if st_min > 0: if st_min > 0:
await asyncio.sleep(st_min) await asyncio.sleep(st_min)
return bytes(payload[:total_length]) return bytes(payload[:total_length])
class IsoTpConnection(IsoTpChannel):
async def send(self, payload: bytes) -> None:
await self.send_pdu(payload)
async def recv(self, timeout: float = 1.0) -> Optional[bytes]:
return await self.recv_pdu(timeout=timeout)
async def request(self, payload: bytes, timeout: float = 1.0) -> Optional[bytes]:
await self.send_pdu(payload)
return await self.recv_pdu(timeout=timeout)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "carbus-lib" name = "carbus-lib"
version = "0.1.2" version = "0.1.3"
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"