Files
carbus_lib/carbus_async/device.py
T
controllerzz 99451aceb8 update 0.1.9
2026-06-17 20:44:18 +03:00

1441 lines
45 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import logging
import struct
from dataclasses import dataclass, field
from typing import Dict, Optional, Tuple, List, Awaitable, Callable
import serial_asyncio
from .exceptions import CarBusError, SyncError, CommandError
from .messages import CanMessage
NOMINAL_BITRATE_INDEX: Dict[int, int] = {
10_000: 0,
20_000: 1,
33_300: 2,
50_000: 3,
62_500: 4,
83_300: 5,
95_200: 6,
100_000: 7,
125_000: 8,
250_000: 9,
400_000: 10,
500_000: 11,
800_000: 12,
1_000_000: 13,
# 0xFF = detect,
}
DATA_BITRATE_INDEX: Dict[int, int] = {
500_000: 0,
1_000_000: 1,
2_000_000: 2,
4_000_000: 3,
5_000_000: 4,
}
@dataclass(frozen=True)
class CanTiming:
prescaler: int
tq_seg1: int
tq_seg2: int
sjw: int
DEFAULT_CAN_CLOCK_HZ = 120_000_000
# Проверенные bit-timing'и для CAN-модуля с тактовой 120 МГц (точка выборки ~80%).
# open_can_channel() для FD-канала задаёт скорость именно таймингами, а не
# одиночным индексом data-скорости (CC_BUS_SPEED_D): прошивка этих адаптеров
# применяет явные тайминги надёжно, а индекс data-скорости не отрабатывает —
# канал открывается, но FD/BRS-кадры не уходят в шину.
_NOMINAL_TIMINGS_120M: Dict[int, CanTiming] = {
1_000_000: CanTiming(prescaler=6, tq_seg1=15, tq_seg2=4, sjw=1), # 120/(6*20)
500_000: CanTiming(prescaler=15, tq_seg1=12, tq_seg2=3, sjw=1), # 120/(15*16)
250_000: CanTiming(prescaler=30, tq_seg1=12, tq_seg2=3, sjw=1), # 120/(30*16)
125_000: CanTiming(prescaler=60, tq_seg1=12, tq_seg2=3, sjw=1), # 120/(60*16)
}
_DATA_TIMINGS_120M: Dict[int, CanTiming] = {
5_000_000: CanTiming(prescaler=3, tq_seg1=5, tq_seg2=2, sjw=1), # 120/(3*8)
4_000_000: CanTiming(prescaler=3, tq_seg1=7, tq_seg2=2, sjw=1), # 120/(3*10)
2_000_000: CanTiming(prescaler=6, tq_seg1=7, tq_seg2=2, sjw=1), # 120/(6*10)
1_000_000: CanTiming(prescaler=12, tq_seg1=7, tq_seg2=2, sjw=1), # 120/(12*10)
500_000: CanTiming(prescaler=24, tq_seg1=7, tq_seg2=2, sjw=1), # 120/(24*10)
}
def _resolve_timing(
table: Dict[int, CanTiming], bitrate: int, clock_hz: int, kind: str
) -> CanTiming:
if clock_hz == DEFAULT_CAN_CLOCK_HZ and bitrate in table:
return table[bitrate]
raise ValueError(
f"Нет встроенного FD bit-timing для {kind}={bitrate} bps при тактовой "
f"{clock_hz} Гц. Доступно (для 120 МГц): {sorted(table)}. "
f"Задайте скорость явными таймингами через open_can_channel_custom(...)."
)
@dataclass
class DeviceInfoParam:
header: int
data: List[int]
from .protocol import (
Command,
CommandHeader,
MsgCommandHeader,
HeaderFlags,
BusMessageFlags,
CC_MULTIWORD,
DI_HARDWARE_ID,
DI_FIRMWARE_VERSION,
DI_DEVICE_SERIAL,
DI_FEATURES,
DI_CHANNEL_MAP,
DI_CHANNEL_FEATURES,
DI_FILTER,
DI_GATEWAY,
DI_CHANNEL_FREQUENCY,
DI_ISOTP,
DI_TX_BUFFER,
DI_TX_TASK,
is_ack,
base_command_from_ack,
need_extended_header,
)
HW_ID_NAMES: Dict[int, str] = {
0xFF: "HW_CH30",
0x02: "HW_ODB_OLD",
0x01: "HW_CH32",
0x04: "HW_ODB",
0x03: "HW_CHP",
0x11: "HW_CH33",
0x13: "HW_CHPM03",
0x14: "HW_ODB_FD",
0x06: "HW_FDL2_M02",
0x16: "HW_FDL2_M05",
}
# DI_FEATURES
DI_FEATURE_GATEWAY = 0x00000001
DI_FEATURE_ISOTP = 0x00000002
DI_FEATURE_TX_BUFFER = 0x00000004
DI_FEATURE_TX_TASK = 0x00000008
FLAG_CONFIG_TERMINATOR = 0x05
# ChannelType (DI_CHANNEL_MAP)
CHANNEL_TYPE_MAP: Dict[int, str] = {
0x00: "NONE", # CTE_None
0x01: "CAN", # CTE_CAN
0x02: "CANFD", # CTE_CANFD
0x10: "LIN", # CTE_LIN
}
# DI_CHANNEL_FEATURES
DI_CHANNEL_CHANNEL_MASK = 0x00FF0000
DI_CHANNEL_CHANNEL_SHIFT = 16
DI_CHANNEL_FEATURE_ALC = 0x00000001
DI_CHANNEL_FEATURE_TERMINATOR = 0x00000002
DI_CHANNEL_FEATURE_PULLUP = 0x00000004
DI_CHANNEL_FEATURE_CSD = 0x00000008
DI_CHANNEL_FEATURE_IDLE = 0x00000010
DI_CHANNEL_FEATURE_DSD = 0x00000020
DI_CHANNEL_FEATURE_NONISO = 0x00000040
# DI_FILTER
DI_FILTER_CHANNEL_MASK = 0x00FF0000
DI_FILTER_CHANNEL_SHIFT = 16
DI_FILTER_TYPE_MASK = 0x0000FF00
DI_FILTER_TYPE_SHIFT = 8
DI_FILTER_SIZE_MASK = 0x000000FF
DI_FILTER_SIZE_SHIFT = 0
DI_FILTER_TYPE_8BIT = 0x01
DI_FILTER_TYPE_11BIT = 0x02
DI_FILTER_TYPE_29BIT = 0x04
# DI_GATEWAY
DI_GATEWAY_SRC_MASK = 0x00FF0000
DI_GATEWAY_SRC_SHIFT = 16
DI_GATEWAY_DST_MASK = 0x0000FF00
DI_GATEWAY_DST_SHIFT = 8
DI_GATEWAY_FILTER_MASK = 0x000000FF
DI_GATEWAY_FILTER_SHIFT = 0
@dataclass
class DeviceInfo:
params: List[DeviceInfoParam]
raw_payload: bytes
@classmethod
def from_payload(cls, payload: bytes) -> "DeviceInfo":
if len(payload) % 4 != 0:
raise ValueError(
f"DEVICE_INFO payload length {len(payload)} is not multiple of 4"
)
words = [
int.from_bytes(payload[i: i + 4], "little")
for i in range(0, len(payload), 4)
]
params: List[DeviceInfoParam] = []
i = 0
n = len(words)
while i < n:
header = words[i]
i += 1
if header & CC_MULTIWORD:
length = (header >> 16) & 0xFF
data = words[i: i + length]
i += length
else:
data = []
params.append(DeviceInfoParam(header=header, data=data))
return cls(params=params, raw_payload=payload)
@staticmethod
def _param_code(value: int) -> int:
return value & 0x7F000000
def _find_first(self, base: int) -> Optional[DeviceInfoParam]:
base_code = self._param_code(base)
for p in self.params:
if self._param_code(p.header) == base_code:
return p
return None
def _find_all(self, base: int) -> List[DeviceInfoParam]:
base_code = self._param_code(base)
return [
p for p in self.params
if self._param_code(p.header) == base_code
]
def find_by_prefix(self, prefix: int) -> List[DeviceInfoParam]:
base_code = self._param_code(prefix)
return [
p for p in self.params
if self._param_code(p.header) == base_code
]
@property
def hardware_id(self) -> Optional[int]:
p = self._find_first(DI_HARDWARE_ID)
if not p:
return None
return p.header & 0xFF
@property
def hardware_name(self) -> Optional[str]:
hw_id = self.hardware_id
if hw_id is None:
return None
return HW_ID_NAMES.get(hw_id, f"0x{hw_id:02X}")
@property
def firmware_version(self) -> Optional[str]:
p = self._find_first(DI_FIRMWARE_VERSION)
if not p:
return None
if not p.data:
return ""
b = b"".join(w.to_bytes(4, "little") for w in p.data)
# Обрезаем по 0x00
if b"\x00" in b:
b = b.split(b"\x00", 1)[0]
try:
return b.decode("ascii", errors="ignore")
except Exception:
return b.hex()
@property
def serial_bytes(self) -> Optional[bytes]:
p = self._find_first(DI_DEVICE_SERIAL)
if not p or not p.data:
return None
return b"".join(w.to_bytes(4, "little") for w in p.data)
@property
def serial_int(self) -> Optional[int]:
b = self.serial_bytes
if b is None:
return None
return int.from_bytes(b, "big")
@property
def features_mask(self) -> int:
p = self._find_first(DI_FEATURES)
if not p:
return 0
return p.header & 0x00FFFFFF
@property
def feature_gateway(self) -> bool:
return bool(self.features_mask & DI_FEATURE_GATEWAY)
@property
def feature_isotp(self) -> bool:
return bool(self.features_mask & DI_FEATURE_ISOTP)
@property
def feature_tx_buffer(self) -> bool:
return bool(self.features_mask & DI_FEATURE_TX_BUFFER)
@property
def feature_tx_task(self) -> bool:
return bool(self.features_mask & DI_FEATURE_TX_TASK)
@property
def channel_types(self) -> Dict[int, str]:
p = self._find_first(DI_CHANNEL_MAP)
if not p:
return {}
value = p.header
b0 = (value >> 0) & 0xFF
b1 = (value >> 8) & 0xFF
b2 = (value >> 16) & 0xFF
out: Dict[int, str] = {}
for idx, v in enumerate((b0, b1, b2), start=1):
out[idx] = CHANNEL_TYPE_MAP.get(v, f"0x{v:02X}")
return out
@property
def channel_features(self) -> Dict[int, Dict[str, bool]]:
res: Dict[int, Dict[str, bool]] = {}
for p in self._find_all(DI_CHANNEL_FEATURES):
ch = (p.header & DI_CHANNEL_CHANNEL_MASK) >> DI_CHANNEL_CHANNEL_SHIFT
mask = p.header & 0x0000FFFF
if ch == 0:
continue
res[ch] = {
"raw_mask": mask,
"alc": bool(mask & DI_CHANNEL_FEATURE_ALC),
"terminator": bool(mask & DI_CHANNEL_FEATURE_TERMINATOR),
"pullup": bool(mask & DI_CHANNEL_FEATURE_PULLUP),
"csd": bool(mask & DI_CHANNEL_FEATURE_CSD),
"idle": bool(mask & DI_CHANNEL_FEATURE_IDLE),
"dsd": bool(mask & DI_CHANNEL_FEATURE_DSD),
"noniso": bool(mask & DI_CHANNEL_FEATURE_NONISO),
}
return res
@property
def filters_info(self) -> List[Dict[str, int]]:
out: List[Dict[str, int]] = []
for p in self._find_all(DI_FILTER):
h = p.header
ch = (h & DI_FILTER_CHANNEL_MASK) >> DI_FILTER_CHANNEL_SHIFT
type_mask = (h & DI_FILTER_TYPE_MASK) >> DI_FILTER_TYPE_SHIFT
size = (h & DI_FILTER_SIZE_MASK) >> DI_FILTER_SIZE_SHIFT
if ch == 0:
continue
info = {
"channel": ch,
"type_mask": type_mask,
"size": size,
"has_8bit": bool(type_mask & DI_FILTER_TYPE_8BIT),
"has_11bit": bool(type_mask & DI_FILTER_TYPE_11BIT),
"has_29bit": bool(type_mask & DI_FILTER_TYPE_29BIT),
}
out.append(info)
return out
@property
def gateway_info(self) -> List[Dict[str, int]]:
out: List[Dict[str, int]] = []
for p in self._find_all(DI_GATEWAY):
h = p.header
src = (h & DI_GATEWAY_SRC_MASK) >> DI_GATEWAY_SRC_SHIFT
dst = (h & DI_GATEWAY_DST_MASK) >> DI_GATEWAY_DST_SHIFT
flt = (h & DI_GATEWAY_FILTER_MASK) >> DI_GATEWAY_FILTER_SHIFT
if src == 0 or dst == 0:
continue
out.append({"src": src, "dst": dst, "filters": flt})
return out
@property
def channel_frequencies(self) -> Dict[int, int]:
out: Dict[int, int] = {}
for p in self._find_all(DI_CHANNEL_FREQUENCY):
h = p.header
ch = (h & DI_GATEWAY_SRC_MASK) >> DI_GATEWAY_SRC_SHIFT # по доке тот же формат
freq = h & 0x0000FFFF # в примере 0x15010078 → 0x0078 → 120 (МГц)
if ch == 0:
continue
# по доке пример: 0x78 (120МГц) — домножаем до Hz
out[ch] = freq * 1_000_000
return out
@property
def isotp_buffer_size(self) -> Optional[int]:
p = self._find_first(DI_ISOTP)
if not p:
return None
return p.header & 0x00FFFFFF
@property
def tx_buffer_size(self) -> Optional[int]:
p = self._find_first(DI_TX_BUFFER)
if not p:
return None
return p.header & 0x00FFFFFF
@property
def tx_task_count(self) -> Optional[int]:
p = self._find_first(DI_TX_TASK)
if not p:
return None
return p.header & 0x00FFFFFF
def find_by_prefix(self, prefix: int) -> List[DeviceInfoParam]:
return [
p for p in self.params
if (p.header & 0xFF000000) == (prefix & 0xFF000000)
]
def find_by_prefix(self, prefix: int) -> List[DeviceInfoParam]:
"""
Вернуть все параметры, у которых старший байт совпадает с prefix & 0xFF000000.
Например, prefix=0x01000000 вернёт все DI_*, начинающиеся с 0x01.
"""
return [
p
for p in self.params
if (p.header & 0xFF000000) == (prefix & 0xFF000000)
]
@dataclass
class _PendingRequest:
future: asyncio.Future
command: int
CanHook = Callable[[int, CanMessage], Awaitable[None]]
CanPred = Callable[[int, CanMessage], bool]
@dataclass(frozen=True)
class _CanHookRule:
can_id: int | None # None => любой ID
value: bytes | None # None => матч только по ID/predicate
mask: bytes | None
offset: int
handler: CanHook
predicate: CanPred | None = None
def _match_masked(data: bytes, *, offset: int, value: bytes, mask: bytes) -> bool:
if len(value) != len(mask):
raise ValueError("mask and value must have same length")
end = offset + len(value)
if offset < 0 or len(data) < end:
return False
for i in range(len(value)):
if (data[offset + i] & mask[i]) != (value[i] & mask[i]):
return False
return True
@dataclass
class CarBusDevice:
port: str
baudrate: int = 115200
loop: Optional[asyncio.AbstractEventLoop] = None
_reader: asyncio.StreamReader = field(init=False, repr=False)
_writer: asyncio.StreamWriter = field(init=False, repr=False)
_rx_queue: "asyncio.Queue[tuple[int, CanMessage]]" = field(init=False, repr=False)
_rx_channel_queues: Dict[int, "asyncio.Queue[CanMessage]"] = field(init=False, repr=False)
_pending: Dict[int, _PendingRequest] = field(init=False, repr=False)
_seq_counter: int = field(init=False, default=0, repr=False)
_reader_task: Optional[asyncio.Task] = field(init=False, default=None, repr=False)
_closed: bool = field(init=False, default=False, repr=False)
_can_hooks: List[_CanHookRule] = field(init=False, repr=False)
_can_hook_sem: asyncio.Semaphore = field(init=False, repr=False)
_log: logging.Logger = field(init=False, repr=False)
_wire_log: logging.Logger = field(init=False, repr=False)
def _ensure_channel_queue(self, channel: int) -> "asyncio.Queue[CanMessage]":
q = self._rx_channel_queues.get(channel)
if q is None:
q = asyncio.Queue()
self._rx_channel_queues[channel] = q
return q
@classmethod
async def open(
cls,
port: str,
baudrate: int = 115200,
*,
loop: Optional[asyncio.AbstractEventLoop] = None,
use_can: bool = True,
use_lin: bool = False,
) -> "CarBusDevice":
self = cls(port=port, baudrate=baudrate, loop=loop)
await self._connect()
await self.sync()
self._start_reader()
await self.device_open(use_can=use_can, use_lin=use_lin)
return self
@classmethod
async def open_tcp(cls, host: str, port: int, **kwargs) -> "CarBusDevice":
return await cls.open(f"socket://{host}:{port}", **kwargs)
@classmethod
async def open_stream(
cls,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
*,
logical_port: str = "stream://remote",
baudrate: int = 115200,
loop: Optional[asyncio.AbstractEventLoop] = None,
use_can: bool = True,
use_lin: bool = False,
) -> "CarBusDevice":
self = cls(port=logical_port, baudrate=baudrate, loop=loop)
self._log = logging.getLogger(f"carbus_async.device.{logical_port}")
self._wire_log = logging.getLogger(f"carbus_async.wire.{logical_port}")
self._reader = reader
self._writer = writer
self._rx_queue = asyncio.Queue()
self._rx_channel_queues = {}
self._pending = {}
self._seq_counter = 0
self._reader_task = None
self._closed = False
await self.sync()
self._start_reader()
await self.device_open(use_can=use_can, use_lin=use_lin)
return self
async def _connect(self) -> None:
loop = self.loop or asyncio.get_running_loop()
self._log = logging.getLogger(f"carbus_async.device.{self.port}")
self._wire_log = logging.getLogger(f"carbus_async.wire.{self.port}")
if self.port.startswith("socket://"):
addr = self.port[len("socket://") :]
if ":" not in addr:
raise ValueError(
f"Invalid socket URL '{self.port}'. Expected 'socket://host:port'"
)
host, port_str = addr.rsplit(":", 1)
try:
tcp_port = int(port_str)
except ValueError as e:
raise ValueError(
f"Invalid TCP port in '{self.port}': {port_str!r}"
) from e
self._log.debug(
"Connecting via TCP to %s:%d (tcp_bridge)", host, tcp_port
)
self._reader, self._writer = await asyncio.open_connection(
host=host,
port=tcp_port,
)
self._log.debug(
"Connected to TCP %s:%d (logical port=%s)",
host,
tcp_port,
self.port,
)
else:
self._log.debug(
"Connecting to serial port %s @ %d using serial_asyncio",
self.port,
self.baudrate,
)
self._reader, self._writer = await serial_asyncio.open_serial_connection(
loop=loop,
url=self.port,
baudrate=self.baudrate,
)
self._log.debug("Connected to %s @ %d", self.port, self.baudrate)
self._rx_queue = asyncio.Queue()
self._rx_channel_queues = {}
self._pending = {}
self._seq_counter = 0
self._reader_task = None
self._closed = False
self._can_hooks = []
self._can_hook_sem = asyncio.Semaphore(200)
async def close(self) -> None:
if self._closed:
return
try:
try:
await self.device_close()
except Exception:
self._log.debug("DEVICE_CLOSE failed, ignoring", exc_info=True)
if self._reader_task:
self._reader_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._reader_task
self._writer.close()
await self._writer.wait_closed()
finally:
self._closed = True
for pending in list(self._pending.values()):
if not pending.future.done():
pending.future.set_exception(
CarBusError("Device closed before response was received")
)
self._pending.clear()
def on_can_id(self, can_id: int, *, predicate: CanPred | None = None):
"""Хук на каждый принятый CAN кадр с данным can_id."""
def deco(fn: CanHook) -> CanHook:
self._can_hooks.append(_CanHookRule(
can_id=can_id,
value=None, mask=None, offset=0,
handler=fn,
predicate=predicate,
))
return fn
return deco
def on_can_match(
self,
*,
can_id: int | None = None,
value: bytes,
mask: bytes | None = None,
offset: int = 0,
predicate: CanPred | None = None,
):
"""
Хук по CAN-ID (или любой) + совпадение по маске.
Проверка: (data[offset+i] & mask[i]) == (value[i] & mask[i])
"""
if mask is None:
mask = bytes([0xFF]) * len(value)
def deco(fn: CanHook) -> CanHook:
self._can_hooks.append(_CanHookRule(
can_id=can_id,
value=value,
mask=mask,
offset=offset,
handler=fn,
predicate=predicate,
))
return fn
return deco
def _fire_can_hooks(self, channel: int, msg: CanMessage) -> None:
if not self._can_hooks:
return
data = bytes(msg.data)
for rule in self._can_hooks:
if rule.can_id is not None and rule.can_id != msg.can_id:
continue
if rule.predicate is not None and not rule.predicate(channel, msg):
continue
if rule.value is not None:
if not _match_masked(data, offset=rule.offset, value=rule.value, mask=rule.mask or b""):
continue
asyncio.create_task(self._run_can_hook(rule.handler, channel, msg))
async def _run_can_hook(self, fn: CanHook, channel: int, msg: CanMessage) -> None:
async with self._can_hook_sem:
try:
await fn(channel, msg)
except Exception:
self._log.exception("CAN hook failed (ch=%s id=0x%X)", channel, msg.can_id)
def _start_reader(self) -> None:
if self._reader_task is None or self._reader_task.done():
self._reader_task = asyncio.create_task(
self._read_loop(),
name=f"carbus_read_loop_{self.port}",
)
def _next_seq(self) -> int:
self._seq_counter = (self._seq_counter + 1) & 0xFF
if self._seq_counter == 0:
self._seq_counter = 1
return self._seq_counter
async def sync(self) -> None:
frame = bytes((Command.SYNC, 0x00, Command.SYNC, 0x00))
self._wire_log.debug("TX SYNC: %s", frame.hex(" "))
self._writer.write(frame)
await self._writer.drain()
resp = await self._reader.readexactly(4)
self._wire_log.debug("RX SYNC: %s", resp.hex(" "))
if resp != bytes((0x5A, 0x00, 0x5A, 0x00)):
raise SyncError(f"Unexpected SYNC response: {resp!r}")
async def _send_raw(
self,
command: int,
*,
header_flags: int = 0,
payload: bytes = b"",
expect_response: bool = True,
) -> Tuple[int, int, bytes]:
if self._closed:
raise CarBusError("Device is closed")
seq = self._next_seq()
dsize = len(payload)
if need_extended_header(command):
header = MsgCommandHeader(
command=command,
sequence=seq,
flags=header_flags,
dsize=dsize,
)
else:
if dsize > 0xFF:
raise ValueError(
f"dsize={dsize} too big for CommandHeader (max 255)"
)
header = CommandHeader(
command=command,
sequence=seq,
flags=header_flags,
dsize=dsize,
)
header_bytes = header.to_bytes()
frame = header_bytes + payload
fut: asyncio.Future = asyncio.get_running_loop().create_future()
if expect_response:
self._pending[seq] = _PendingRequest(future=fut, command=command)
self._wire_log.debug(
"TX cmd=0x%02X seq=%d flags=0x%04X dsize=%d :: %s",
command,
seq,
header_flags,
dsize,
frame.hex(" "),
)
self._writer.write(frame)
await self._writer.drain()
if not expect_response:
return 0, 0, b""
cmd_resp, flags_resp, payload_resp = await fut
return cmd_resp, flags_resp, payload_resp
async def get_device_info(self) -> DeviceInfo:
cmd, flags, payload = await self._send_raw(
Command.DEVICE_INFO,
header_flags=0,
payload=b"",
expect_response=True,
)
if cmd != Command.DEVICE_INFO:
raise CommandError(
f"Unexpected DEVICE_INFO response: cmd=0x{cmd:02X}, flags=0x{flags:04X}"
)
return DeviceInfo.from_payload(payload)
async def has_terminator(self, channel=1) -> bool:
info = await self.get_device_info()
feat = info.channel_features.get(channel)
return bool(feat and feat.get("terminator", False))
async def ensure_terminator(self, channel: int = 1, enabled: bool = True):
if not await self.has_terminator(channel):
return False
await self.set_terminator(channel, enabled=enabled)
async def get_serial(self) -> bool:
info = await self.get_device_info()
return info.serial_int;
async def device_open(self, *, use_can: bool = True, use_lin: bool = False) -> None:
if use_can and use_lin:
mode_val = 0x00 # FULL
elif use_can and not use_lin:
mode_val = 0x01 # CAN only
elif not use_can and use_lin:
mode_val = 0x02 # LIN only
else:
mode_val = 0x00 # FULL по умолчанию
dc_mode = 0x01000000 | mode_val
payload = dc_mode.to_bytes(4, "little")
cmd, flags, resp_payload = await self._send_raw(
Command.DEVICE_OPEN,
header_flags=0,
payload=payload,
expect_response=True,
)
if not is_ack(cmd) or base_command_from_ack(cmd) != Command.DEVICE_OPEN:
raise CommandError(
f"Unexpected DEVICE_OPEN response: cmd=0x{cmd:02X}, flags=0x{flags:04X}"
)
async def device_close(self) -> None:
cmd, flags, _ = await self._send_raw(
Command.DEVICE_CLOSE,
header_flags=0,
payload=b"",
expect_response=True,
)
if not is_ack(cmd) or base_command_from_ack(cmd) != Command.DEVICE_CLOSE:
raise CommandError(
f"Unexpected DEVICE_CLOSE response: cmd=0x{cmd:02X}, flags=0x{flags:04X}"
)
async def open_can_channel(
self,
channel: int = 1,
*,
nominal_bitrate: int = 500_000,
fd: bool = False,
data_bitrate: Optional[int] = None,
brs: bool = False,
listen_only: bool = False,
loopback: bool = False,
auto_detect: bool = False,
retransmit: bool = False,
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:
mode_val = 0x01
else:
mode_val = 0x00
cc_can_mode = 0x11000000 | mode_val
if not fd:
frame_mode = 0x00
else:
frame_mode = 0x02 if brs else 0x01
cc_can_frame = 0x12000000 | frame_mode
if auto_detect:
n_index = 0xFF
elif nominal_index is not None:
n_index = nominal_index & 0xFF
else:
try:
n_index = NOMINAL_BITRATE_INDEX[nominal_bitrate]
except KeyError:
raise ValueError(
f"Unsupported nominal bitrate {nominal_bitrate}. "
f"Known: {sorted(NOMINAL_BITRATE_INDEX.keys())}"
) from None
cc_bus_speed_n = 0x01000000 | (n_index & 0xFF)
params: List[int] = [cc_can_mode, cc_can_frame, cc_bus_speed_n]
if fd:
if data_index is not None:
d_index = data_index & 0xFF
else:
if data_bitrate is None:
raise ValueError("fd=True требует указать data_bitrate или data_index")
try:
d_index = DATA_BITRATE_INDEX[data_bitrate]
except KeyError:
raise ValueError(
f"Unsupported data bitrate {data_bitrate}. "
f"Known: {sorted(DATA_BITRATE_INDEX.keys())}"
) from None
cc_bus_speed_d = 0x02000000 | (d_index & 0xFF)
params.append(cc_bus_speed_d)
if retransmit:
params.append(0x13000001)
if fd and non_iso:
params.append(0x14000001)
payload = b"".join(p.to_bytes(4, "little") for p in params)
header_flags = (channel & 0x0F) * 0x20
cmd, flags, resp_payload = await self._send_raw(
Command.CHANNEL_OPEN,
header_flags=header_flags,
payload=payload,
expect_response=True,
)
if not is_ack(cmd) or base_command_from_ack(cmd) != Command.CHANNEL_OPEN:
raise CommandError(
f"Unexpected CHANNEL_OPEN response: cmd=0x{cmd:02X}, flags=0x{flags:04X}"
)
async def open_can_channel_custom(
self,
channel: int = 1,
*,
nominal_timing: CanTiming | None,
data_timing: CanTiming | None = None,
fd: bool = False,
brs: bool = False,
listen_only: bool = False,
loopback: bool = False,
retransmit: bool = False,
non_iso: bool = False,
) -> None:
def _build_bus_custom_baudrate_words(
base_cc: int,
prescaler: int,
seg1: int,
seg2: int,
sjw: int,
) -> list[int]:
packed = struct.pack("<HHHH", prescaler, seg1, seg2, sjw)
word1 = int.from_bytes(packed[0:4], "little")
word2 = int.from_bytes(packed[4:8], "little")
length_words = 2
header = base_cc | CC_MULTIWORD | ((length_words & 0xFF) << 16)
return [header, word1, word2]
# 1) CAN mode
if loopback:
mode_val = 0x02
elif listen_only:
mode_val = 0x01
else:
mode_val = 0x00
cc_can_mode = 0x11000000 | mode_val
# 2) CAN frame (classic/FD/BRS)
if not fd:
frame_mode = 0x00 # classic
else:
frame_mode = 0x02 if brs else 0x01
cc_can_frame = 0x12000000 | frame_mode
params: list[int] = [cc_can_mode, cc_can_frame]
# 3) Nominal custom bitrate (CC_BUS_SPEED_N)
if nominal_timing is not None:
presc=nominal_timing.prescaler
seg1=nominal_timing.tq_seg1
seg2=nominal_timing.tq_seg2
sjw=nominal_timing.sjw
header_n = 0x01000000 | CC_MULTIWORD | (2 << 16)
params.append(header_n)
b = struct.pack("<HHHH", presc, seg1, seg2, sjw) # BusCustomBaudRate
params.append(int.from_bytes(b[0:4], "little"))
params.append(int.from_bytes(b[4:8], "little"))
# 4) Data custom bitrate (CC_BUS_SPEED_D)
if fd and data_timing is not None:
presc=data_timing.prescaler
seg1=data_timing.tq_seg1
seg2=data_timing.tq_seg2
sjw=data_timing.sjw
header_d = 0x02000000 | CC_MULTIWORD | (2 << 16)
params.append(header_d)
b = struct.pack("<HHHH", presc, seg1, seg2, sjw)
params.append(int.from_bytes(b[0:4], "little"))
params.append(int.from_bytes(b[4:8], "little"))
# доп. опции
if retransmit:
params.append(0x13000001)
if non_iso:
params.append(0x14000001)
payload = b"".join(p.to_bytes(4, "little") for p in params)
header_flags = (channel & 0x0F) * 0x20
cmd, flags, resp_payload = await self._send_raw(
Command.CHANNEL_OPEN,
header_flags=header_flags,
payload=payload,
expect_response=True,
)
if not is_ack(cmd) or base_command_from_ack(cmd) != Command.CHANNEL_OPEN:
raise CommandError(
f"Unexpected CHANNEL_OPEN response: cmd=0x{cmd:02X}, flags=0x{flags:04X}"
)
async def set_can_filter(
self,
channel: int,
index: int,
*,
can_id: int,
mask: int | None = None,
extended: bool = False,
) -> None:
if index < 0:
raise ValueError("filter index must be >= 0")
filter_type = 0x01 if extended else 0x00
if mask is None:
mask = 0x1FFFFFFF if extended else 0x7FF
payload = struct.pack("<IIII", index, filter_type, can_id, mask)
header_flags = (channel & 0x0F) * 0x20
cmd, flags, resp_payload = await self._send_raw(
Command.FILTER_SET,
header_flags=header_flags,
payload=payload,
expect_response=True,
)
if not is_ack(cmd) or base_command_from_ack(cmd) != Command.FILTER_SET:
raise CommandError(
f"Unexpected FILTER_SET response: cmd=0x{cmd:02X}, flags=0x{flags:04X}"
)
async def set_std_id_filter(
self,
channel: int,
index: int,
can_id: int,
mask: int = 0x7FF,
) -> None:
if can_id < 0 or can_id > 0x7FF:
raise ValueError("can_id for standard ID must be in 0x000..0x7FF")
await self.set_can_filter(
channel=channel,
index=index,
can_id=can_id,
mask=mask,
extended=False,
)
async def set_ext_id_filter(
self,
channel: int,
index: int,
can_id: int,
mask: int = 0x1FFFFFFF,
) -> None:
if can_id < 0 or can_id > 0x1FFFFFFF:
raise ValueError("can_id for extended ID must be in 0x00000000..0x1FFFFFFF")
await self.set_can_filter(
channel=channel,
index=index,
can_id=can_id,
mask=mask,
extended=True,
)
async def clear_all_filters(
self,
channel: int,
*,
max_filters: int = 64,
stop_on_error: bool = True,
) -> int:
cleared = 0
for idx in range(max_filters):
try:
await self.clear_can_filter(channel=channel, index=idx)
cleared += 1
except CommandError as e:
if stop_on_error:
self._log.debug(
"Stop clearing filters on channel %d at index %d due to error: %s",
channel,
idx,
e,
)
break
else:
self._log.warning(
"Error while clearing filter %d on channel %d: %s",
idx,
channel,
e,
)
continue
return cleared
async def clear_can_filter(
self,
channel: int,
index: int,
) -> None:
if index < 0:
raise ValueError("filter index must be >= 0")
payload = struct.pack("<I", index)
header_flags = (channel & 0x0F) * 0x20
cmd, flags, resp_payload = await self._send_raw(
Command.FILTER_CLEAR,
header_flags=header_flags,
payload=payload,
expect_response=True,
)
if not is_ack(cmd) or base_command_from_ack(cmd) != Command.FILTER_CLEAR:
raise CommandError(
f"Unexpected FILTER_CLEAR response: cmd=0x{cmd:02X}, flags=0x{flags:04X}"
)
async def set_terminator(self, channel: int, enabled: bool) -> None:
state = 0x01 if enabled else 0x00
channel_flag = (channel & 0x0F) * 0x20
header_flags = channel_flag | FLAG_CONFIG_TERMINATOR
payload = bytes((state,))
cmd, flags, resp_payload = await self._send_raw(
Command.CHANNEL_CONFIG,
header_flags=header_flags,
payload=payload,
expect_response=True,
)
if not is_ack(cmd) or base_command_from_ack(cmd) != Command.CHANNEL_CONFIG:
raise CommandError(
f"Unexpected TERMINATOR response: cmd=0x{cmd:02X}, flags=0x{flags:04X}"
)
async def send_can(
self,
msg: CanMessage,
*,
channel: int,
confirm: bool = False,
echo: bool = False,
) -> None:
if channel == 1:
hflags = int(HeaderFlags.CHANNEL_1)
elif channel == 2:
hflags = int(HeaderFlags.CHANNEL_2)
elif channel == 3:
hflags = int(HeaderFlags.CHANNEL_3)
elif channel == 4:
hflags = int(HeaderFlags.CHANNEL_4)
else:
hflags = (channel & 0x0F) * 0x20
if confirm:
hflags |= int(HeaderFlags.CONFIRM_REQUIRED)
mflags = BusMessageFlags.NONE
if msg.extended:
mflags |= BusMessageFlags.EXTID
if msg.rtr:
mflags |= BusMessageFlags.RTR
if msg.fd:
mflags |= BusMessageFlags.FDF
if msg.brs:
mflags |= BusMessageFlags.BRS
if not echo:
mflags |= BusMessageFlags.BLOCK_TX
if msg.extended:
raw_id = msg.can_id & 0x1FFFFFFF
else:
raw_id = (msg.can_id & 0x7FF) #<< 16
timestamp = 0
dlc = len(msg.data) & 0xFF
header_struct = struct.pack(
"<IIII",
int(mflags),
int(timestamp),
int(raw_id),
int(dlc),
)
payload = header_struct + msg.data
await self._send_raw(
Command.MESSAGE,
header_flags=hflags,
payload=payload,
expect_response=confirm,
)
async def receive_can(self) -> tuple[int, CanMessage]:
return await self._rx_queue.get()
async def receive_can_on(self, channel: int = 1) -> CanMessage:
q = self._ensure_channel_queue(channel)
return await q.get()
async def receive_can_on_timeout(
self,
channel: int = 1,
timeout: float = 1.0,
) -> CanMessage | None:
try:
return await asyncio.wait_for(self.receive_can_on(channel), timeout=timeout)
except asyncio.TimeoutError:
return None
async def _read_loop(self) -> None:
try:
while not self._closed:
cmd_bytes = await self._reader.readexactly(1)
if not cmd_bytes:
break
cmd = cmd_bytes[0]
if need_extended_header(cmd):
header_rest = await self._reader.readexactly(5)
header = MsgCommandHeader.from_bytes(cmd_bytes + header_rest)
flags = header.flags
dsize = header.dsize
seq = header.sequence
else:
header_rest = await self._reader.readexactly(3)
header = CommandHeader.from_bytes(cmd_bytes + header_rest)
flags = header.flags
dsize = header.dsize
seq = header.sequence
payload = b""
if dsize:
payload = await self._reader.readexactly(dsize)
full_frame = cmd_bytes + header_rest + payload
self._wire_log.debug(
"RX cmd=0x%02X seq=%d flags=0x%04X dsize=%d :: %s",
cmd,
seq,
flags,
dsize,
full_frame.hex(" "),
)
if cmd == Command.ERROR:
pending = self._pending.pop(seq, None)
if pending is not None and not pending.future.done():
pending.future.set_exception(
CommandError(
f"Device ERROR for seq={seq}, "
f"flags=0x{flags:04X}, payload={payload.hex()}"
)
)
continue
if is_ack(cmd):
base_cmd = base_command_from_ack(cmd)
pending = self._pending.pop(seq, None)
if pending is not None and not pending.future.done():
pending.future.set_result((cmd, flags, payload))
continue
if seq in self._pending:
pending = self._pending.pop(seq)
if not pending.future.done():
pending.future.set_result((cmd, flags, payload))
continue
if cmd == Command.MESSAGE:
await self._handle_bus_message(flags, payload)
elif cmd == Command.BUS_ERROR:
await self._handle_bus_error(flags, payload)
else:
self._log.debug(
"Unhandled async command: cmd=0x%02X, flags=0x%04X, payload=%s",
cmd,
flags,
payload.hex(" "),
)
continue
except asyncio.IncompleteReadError:
self._closed = True
except Exception as e:
self._closed = True
self._log.exception("Read loop exception: %s", e)
finally:
for pending in list(self._pending.values()):
if not pending.future.done():
pending.future.set_exception(
CarBusError("Read loop terminated before response was received")
)
self._pending.clear()
async def _handle_bus_message(self, header_flags: int, payload: bytes) -> None:
if len(payload) < 16:
return
flags_val, timestamp_us, _reserved, id_raw, dlc = struct.unpack_from("<IIIII", payload, 0)
data = payload[20:20 + dlc]
bus_flags = BusMessageFlags(flags_val)
if bus_flags & BusMessageFlags.EXTID:
can_id = id_raw & 0x1FFFFFFF
else:
can_id = (id_raw) & 0x7FF
if header_flags & int(HeaderFlags.CHANNEL_1):
channel = 1
elif header_flags & int(HeaderFlags.CHANNEL_2):
channel = 2
elif header_flags & int(HeaderFlags.CHANNEL_3):
channel = 3
elif header_flags & int(HeaderFlags.CHANNEL_4):
channel = 4
else:
channel = 0
msg = CanMessage.from_bus_payload(
flags=bus_flags,
timestamp_us=timestamp_us,
can_id=can_id,
dlc=dlc,
data=data,
)
self._fire_can_hooks(channel, msg)
await self._rx_queue.put((channel, msg))
if channel != 0:
q = self._rx_channel_queues.get(channel)
if q is not None:
await q.put(msg)
async def _handle_bus_error(self, header_flags: int, payload: bytes) -> None:
self._log.warning(
"BUS_ERROR: flags=0x%04X, payload=%s", header_flags, payload.hex(" ")
)
async def _example() -> None:
dev = await CarBusDevice.open("COM6", baudrate=115200)
info = await dev.get_device_info()
print("DEVICE_INFO raw:", info.raw_payload.hex(" "))
for p in info.params:
print(f"DI header=0x{p.header:08X}, data={[f'0x{w:08X}' for w in p.data]}")
await dev.open_can_channel(channel=1, nominal_bitrate=500_000, fd=False)
msg = CanMessage(can_id=0x123, data=b"\x01\x02\x03\x04", dlc=4, channel=1)
await dev.send_can(msg, confirm=False, echo=True)
rx = await dev.receive_can()
print("RX CAN:", rx)
await dev.close()
if __name__ == "__main__":
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(_example())