624 lines
26 KiB
Python
624 lines
26 KiB
Python
"""@file can485_board.py
|
||
@brief Плата WeAct CAN485 DevBoard V1 (ESP32): её текстовый вывод и пакеты RS485.
|
||
|
||
Прошивка ``CAN_Parser`` печатает в USB-UART разобранные кадры CAN и RS485
|
||
строками, а CAN-кадры также выкладывает в RS485 пакетами по 17 байт. Здесь
|
||
разбор обоих форматов, сборка команд управления платой и генератор демо-трафика.
|
||
|
||
Перенесено из отдельного tkinter-приложения ``Tools/CanMonitorGUI``
|
||
репозитория платы. Модуль не импортирует Qt: разбор переносим и проверяется
|
||
host-тестами.
|
||
|
||
Строка кадра::
|
||
|
||
[ 12.345] EXT 0x18FEE000 DLC=8 DATA=01 02 03 04 05 06 07 08 |........|
|
||
|
||
Пакет RS485::
|
||
|
||
AA 55 | FLAGS | ID[4] big-endian | DLC | DATA[8] | CRC8
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import random
|
||
import re
|
||
import time
|
||
from collections import deque
|
||
from dataclasses import dataclass, field
|
||
from itertools import chain
|
||
from typing import Iterable
|
||
|
||
#: Распространённые скорости UART для подключения платы и RS485-приёмника.
|
||
UART_BAUDS = (
|
||
"1200", "2400", "4800", "9600", "14400", "19200", "38400", "57600",
|
||
"115200", "128000", "230400", "256000", "460800", "500000", "512000",
|
||
"576000", "921600", "1000000", "1152000", "1500000", "2000000",
|
||
"3000000", "4000000",
|
||
)
|
||
#: Скорости USB-UART платы и скорость по умолчанию для ESP32.
|
||
USB_BAUDS = UART_BAUDS[8:]
|
||
DEFAULT_USB_BAUD = "921600"
|
||
#: Скорости шины CAN, кбит/с. Список закрытый: прошивка проверяет значение.
|
||
CAN_BITRATES = ["25", "50", "100", "125", "250", "500", "800", "1000"]
|
||
#: Режимы контроллера: имя и код команды ``M``.
|
||
MODES = (("normal", 0), ("listen-only", 1), ("no-ack", 2))
|
||
#: Маршруты прошивки 1.6: бит 0 — CAN→RS485, бит 1 — RS485→CAN.
|
||
ROUTES = (("Выкл", 0), ("CAN→RS485", 1), ("RS485→CAN", 2), ("Оба", 3))
|
||
#: Скорости встроенного UART RS485 платы и значение по умолчанию.
|
||
RS485_BAUDS = UART_BAUDS
|
||
DEFAULT_RS485_BAUD = "512000"
|
||
|
||
#: Синхробайты и длина пакета RS485.
|
||
RS485_SYNC = b"\xAA\x55"
|
||
RS485_LEN = 17
|
||
|
||
#: Строка кадра: время, тип идентификатора, сам идентификатор и хвост.
|
||
FRAME_RE = re.compile(
|
||
r"^\[\s*(\d+)\.(\d{3})\]\s+(?:(RS485)\s+)?"
|
||
r"(EXT|STD)\s+0x([0-9A-Fa-f]+)\s+(.*)$"
|
||
)
|
||
RTR_RE = re.compile(r"^RTR\s+DLC=(\d)")
|
||
DATA_RE = re.compile(r"^DLC=(\d)\s*(?:DATA=([0-9A-Fa-f ]*))?")
|
||
HEX_BYTE_RE = re.compile(r"(?i)(?<![0-9a-f])[0-9a-f]{2}(?![0-9a-f])")
|
||
|
||
|
||
def crc8(data: bytes) -> int:
|
||
"""@brief CRC-8, полином 0x07, начальное значение 0 — как в прошивке."""
|
||
crc = 0
|
||
for byte in data:
|
||
crc ^= byte
|
||
for _ in range(8):
|
||
crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
|
||
return crc
|
||
|
||
|
||
def to_ascii(data: bytes) -> str:
|
||
"""Печатные байты как есть, остальные точкой."""
|
||
return "".join(chr(byte) if 0x20 <= byte < 0x7F else "." for byte in data)
|
||
|
||
|
||
def j1939(can_id: int) -> str:
|
||
"""@brief Короткий разбор 29-битного идентификатора по J1939.
|
||
|
||
@param can_id Идентификатор кадра.
|
||
@return Приоритет, PGN и адрес источника одной строкой.
|
||
"""
|
||
priority = (can_id >> 26) & 0x07
|
||
pdu_format = (can_id >> 16) & 0xFF
|
||
source = can_id & 0xFF
|
||
pgn = (can_id >> 8) & 0x3FFFF
|
||
if pdu_format < 240:
|
||
# PDU1: младший байт PGN — адрес получателя, а не часть номера группы.
|
||
pgn &= 0x3FF00
|
||
return "P%d PGN %05X SA %02X" % (priority, pgn, source)
|
||
|
||
|
||
@dataclass
|
||
class BoardFrame:
|
||
"""@brief Один кадр шины, пришедший от платы."""
|
||
|
||
timestamp: float
|
||
extended: bool
|
||
identifier: int
|
||
rtr: bool
|
||
dlc: int
|
||
data: bytes
|
||
source: str = "CAN"
|
||
|
||
@property
|
||
def key(self) -> tuple[bool, int]:
|
||
"""Ключ агрегирования в таблице «по ID»."""
|
||
return (self.extended, self.identifier)
|
||
|
||
def id_str(self) -> str:
|
||
return ("0x%08X" if self.extended else "0x%03X") % self.identifier
|
||
|
||
def data_str(self) -> str:
|
||
return "RTR" if self.rtr else " ".join("%02X" % byte for byte in self.data)
|
||
|
||
def ascii_str(self) -> str:
|
||
return "" if self.rtr else to_ascii(self.data)
|
||
|
||
def rs485_packet(self) -> bytes:
|
||
"""@brief Пакет RS485, каким его выложила бы плата.
|
||
|
||
Нужен и для показа сырых байт, и для демо-потока: генератор отдаёт
|
||
те же байты, что придут с железа, поэтому проверяется весь разбор.
|
||
"""
|
||
flags = (1 if self.extended else 0) | (2 if self.rtr else 0)
|
||
body = (bytes([flags])
|
||
+ (self.identifier & 0x1FFFFFFF).to_bytes(4, "big")
|
||
+ bytes([self.dlc])
|
||
+ self.data.ljust(8, b"\x00")[:8])
|
||
return RS485_SYNC + body + bytes([crc8(body)])
|
||
|
||
|
||
def parse_frame_line(line: str, timestamp: float) -> BoardFrame | None:
|
||
"""@brief Разбирает строку кадра из USB-вывода прошивки.
|
||
|
||
@param line Строка без завершающего перевода строки.
|
||
@param timestamp Время приёма на стороне ПК.
|
||
@return Кадр либо ``None``, если строка не про кадр.
|
||
"""
|
||
match = FRAME_RE.match(line)
|
||
if not match:
|
||
return None
|
||
source = "RS485" if match.group(3) else "CAN"
|
||
extended = match.group(4) == "EXT"
|
||
identifier = int(match.group(5), 16)
|
||
rest = match.group(6)
|
||
|
||
remote = RTR_RE.match(rest)
|
||
if remote:
|
||
return BoardFrame(timestamp, extended, identifier, True,
|
||
int(remote.group(1)), b"", source=source)
|
||
|
||
payload = DATA_RE.match(rest)
|
||
if not payload:
|
||
return None
|
||
dlc = int(payload.group(1))
|
||
# Хвост строки может нести ASCII-колонку в вертикальных чертах.
|
||
hex_part = (payload.group(2) or "").split("|")[0]
|
||
try:
|
||
data = bytes(int(item, 16) for item in hex_part.split())
|
||
except ValueError:
|
||
return None
|
||
return BoardFrame(timestamp, extended, identifier, False, dlc, data[:8],
|
||
source=source)
|
||
|
||
|
||
@dataclass
|
||
class SdLogImport:
|
||
"""Результат безопасного импорта журнала, снятого с TF/SD-карты."""
|
||
|
||
frames: list[BoardFrame]
|
||
rows_seen: int
|
||
skipped_rows: int
|
||
truncated: bool
|
||
format_name: str
|
||
|
||
|
||
def _header_key(value: str) -> str:
|
||
"""Нормализует русские и английские заголовки CSV для сопоставления."""
|
||
return re.sub(r"[^a-zа-я0-9]+", "", value.strip().lower().lstrip("\ufeff"))
|
||
|
||
|
||
def _column_index(header: list[str], *aliases: str) -> int | None:
|
||
keys = [_header_key(value) for value in header]
|
||
wanted = {_header_key(alias) for alias in aliases}
|
||
return next((index for index, key in enumerate(keys) if key in wanted), None)
|
||
|
||
|
||
def _parse_log_timestamp(value: str, reference: float, milliseconds: bool) -> float:
|
||
"""Читает epoch, millis() либо часы из CSV без требования даты в файле."""
|
||
text = value.strip()
|
||
clock = re.fullmatch(r"(\d{1,2}):(\d{2}):(\d{2})(?:[.,](\d{1,6}))?", text)
|
||
if clock:
|
||
fraction = float("0." + (clock.group(4) or "0"))
|
||
local = time.localtime(reference)
|
||
midnight = time.mktime((local.tm_year, local.tm_mon, local.tm_mday,
|
||
0, 0, 0, local.tm_wday, local.tm_yday,
|
||
local.tm_isdst))
|
||
return midnight + int(clock.group(1)) * 3600 + int(clock.group(2)) * 60 \
|
||
+ int(clock.group(3)) + fraction
|
||
numeric = float(text.replace(",", "."))
|
||
if numeric >= 100_000_000_000:
|
||
return numeric / 1000.0
|
||
if numeric >= 1_000_000_000:
|
||
return numeric
|
||
return numeric / 1000.0 if milliseconds else numeric
|
||
|
||
|
||
def _parse_bool(value: str) -> bool:
|
||
return value.strip().lower() in {"1", "true", "yes", "ext", "e", "29"}
|
||
|
||
|
||
def _parse_csv_frame(
|
||
row: list[str], header: list[str] | None, reference: float,
|
||
) -> tuple[BoardFrame, bool]:
|
||
"""Разбирает одну строку SD CSV; второй результат отмечает относительное время."""
|
||
if header is None:
|
||
if len(row) < 5:
|
||
raise ValueError("слишком мало колонок")
|
||
time_index, type_index, id_index, dlc_index, data_index = 0, 1, 2, 3, 4
|
||
rtr_index = None
|
||
time_key = "ms"
|
||
else:
|
||
time_index = _column_index(header, "Время", "time", "timestamp", "ms",
|
||
"millis", "time_ms", "timestamp_ms",
|
||
"uptime_ms", "elapsed_ms", "t_ms")
|
||
type_index = _column_index(header, "Тип", "type", "format", "fmt", "ide", "ext")
|
||
id_index = _column_index(header, "ID", "identifier", "can_id", "canid",
|
||
"ID (29 бит)")
|
||
dlc_index = _column_index(header, "DLC", "len", "length")
|
||
data_index = _column_index(header, "Данные (hex)", "data", "payload",
|
||
"data_hex", "bytes", "DATA (8 байт)")
|
||
rtr_index = _column_index(header, "RTR", "remote")
|
||
if None in (time_index, id_index, dlc_index, data_index):
|
||
raise ValueError("нет обязательных колонок")
|
||
time_key = _header_key(header[time_index])
|
||
|
||
required = [time_index, id_index, dlc_index, data_index]
|
||
if any(index is None or index >= len(row) for index in required):
|
||
raise ValueError("короткая строка")
|
||
type_text = row[type_index].strip() if type_index is not None and type_index < len(row) else ""
|
||
id_text = row[id_index].strip().lower().replace("0x", "")
|
||
identifier = int(id_text, 16)
|
||
type_upper = type_text.upper()
|
||
if type_upper in {"EXT", "E", "29", "TRUE", "YES", "1"}:
|
||
extended = True
|
||
elif type_upper in {"STD", "S", "11", "FALSE", "NO", "0"}:
|
||
extended = False
|
||
else:
|
||
extended = identifier > 0x7FF
|
||
if not 0 <= identifier <= (0x1FFFFFFF if extended else 0x7FF):
|
||
raise ValueError("ID вне диапазона")
|
||
|
||
dlc_text = row[dlc_index].strip()
|
||
dlc = int(dlc_text, 16 if dlc_text.lower().startswith("0x") else 10)
|
||
if not 0 <= dlc <= 8:
|
||
raise ValueError("DLC вне диапазона")
|
||
rtr = (type_text.upper() == "RTR" or row[data_index].strip().upper() == "RTR"
|
||
or (rtr_index is not None and rtr_index < len(row) and _parse_bool(row[rtr_index])))
|
||
|
||
data = b""
|
||
if not rtr:
|
||
# У SD-прошивки байты могут идти одной колонкой или отдельными полями.
|
||
joined = (row[data_index] if header is not None
|
||
else " ".join(row[data_index:]))
|
||
compact = re.sub(r"(?i)0x|[^0-9a-f]", "", joined)
|
||
if compact and len(compact) % 2 == 0:
|
||
data = bytes.fromhex(compact)
|
||
else:
|
||
data = bytes(int(value, 16) for value in HEX_BYTE_RE.findall(joined))
|
||
if len(data) < dlc:
|
||
raise ValueError("данных меньше DLC")
|
||
data = data[:dlc]
|
||
|
||
milliseconds = time_key in {
|
||
"ms", "millis", "timems", "timestampms", "uptimems", "elapsedms",
|
||
"tms",
|
||
}
|
||
stamp = _parse_log_timestamp(row[time_index], reference, milliseconds)
|
||
relative = stamp < 1_000_000_000 and not ":" in row[time_index]
|
||
return BoardFrame(stamp, extended, identifier, rtr, dlc, data, source="SD"), relative
|
||
|
||
|
||
def parse_sd_log(
|
||
lines: Iterable[str], reference_timestamp: float | None = None,
|
||
limit: int = 5000,
|
||
) -> SdLogImport:
|
||
"""Разбирает SD-журнал CAN485 без загрузки неограниченного файла в память.
|
||
|
||
Поддерживаются текстовые строки ``CAN_Parser``, его компактный CSV
|
||
``ms,type,id,dlc,data`` и CSV, сохранённый самим SETGUI. В память попадают
|
||
только последние ``limit`` корректных кадров.
|
||
"""
|
||
if limit <= 0:
|
||
raise ValueError("limit должен быть положительным")
|
||
reference = time.time() if reference_timestamp is None else reference_timestamp
|
||
source_lines = iter(lines)
|
||
first = ""
|
||
found_data = False
|
||
for candidate in source_lines:
|
||
first = candidate.strip("\r\n\ufeff")
|
||
if first.strip() and not first.lstrip().startswith("#"):
|
||
found_data = True
|
||
break
|
||
if not found_data:
|
||
return SdLogImport([], 0, 0, False, "empty")
|
||
|
||
rows_seen = 0
|
||
skipped = 0
|
||
total_frames = 0
|
||
relative_flags: deque[bool] = deque(maxlen=limit)
|
||
frames: deque[BoardFrame] = deque(maxlen=limit)
|
||
|
||
def add(frame: BoardFrame | None, relative: bool = False) -> None:
|
||
nonlocal skipped, total_frames
|
||
if frame is None:
|
||
skipped += 1
|
||
return
|
||
frames.append(frame)
|
||
relative_flags.append(relative)
|
||
total_frames += 1
|
||
|
||
if first.lstrip().startswith("["):
|
||
format_name = "CAN_Parser text"
|
||
for line in chain((first,), source_lines):
|
||
stripped = line.strip()
|
||
if not stripped or stripped.startswith("#"):
|
||
continue
|
||
rows_seen += 1
|
||
match = FRAME_RE.match(stripped)
|
||
relative = False
|
||
stamp = reference
|
||
if match:
|
||
stamp = int(match.group(1)) + int(match.group(2)) / 1000.0
|
||
relative = True
|
||
add(parse_frame_line(stripped, stamp), relative)
|
||
else:
|
||
delimiter = ";" if first.count(";") >= first.count(",") else ","
|
||
reader = csv.reader(chain((first,), source_lines), delimiter=delimiter)
|
||
first_row = next(reader, [])
|
||
id_column = _column_index(first_row, "ID", "identifier", "can_id", "canid",
|
||
"ID (29 бит)")
|
||
header = first_row if id_column is not None else None
|
||
format_name = "SETGUI CSV" if header and _column_index(
|
||
header, "Данные (hex)") is not None else "CAN_Parser CSV"
|
||
data_rows = reader if header is not None else chain((first_row,), reader)
|
||
for row in data_rows:
|
||
if not row or not any(value.strip() for value in row):
|
||
continue
|
||
rows_seen += 1
|
||
try:
|
||
frame, relative = _parse_csv_frame(row, header, reference)
|
||
except (ValueError, TypeError, IndexError):
|
||
add(None)
|
||
else:
|
||
add(frame, relative)
|
||
|
||
imported = list(frames)
|
||
flags = list(relative_flags)
|
||
relative_stamps = [frame.timestamp for frame, relative in zip(imported, flags) if relative]
|
||
if relative_stamps:
|
||
# millis()/время от старта не имеет календаря: конец журнала привязываем
|
||
# ко времени изменения файла на карте, сохраняя интервалы между кадрами.
|
||
shift = reference - max(relative_stamps)
|
||
imported = [BoardFrame(frame.timestamp + shift if relative else frame.timestamp,
|
||
frame.extended, frame.identifier, frame.rtr,
|
||
frame.dlc, frame.data, frame.source)
|
||
for frame, relative in zip(imported, flags)]
|
||
return SdLogImport(imported, rows_seen, skipped, total_frames > limit, format_name)
|
||
|
||
|
||
def parse_status(line: str) -> dict[str, str]:
|
||
"""@brief Разбирает поля ``#STAT`` или ``#OK`` в словарь «ключ — значение»."""
|
||
values: dict[str, str] = {}
|
||
for item in line.split()[1:]:
|
||
if "=" in item:
|
||
key, value = item.split("=", 1)
|
||
values[key] = value
|
||
return values
|
||
|
||
|
||
class LineAssembler:
|
||
"""@brief Собирает строки из кусков потока USB-UART.
|
||
|
||
Куски приходят как попало — QSerialPort отдаёт то, что успело прийти,
|
||
поэтому остаток без перевода строки хранится до следующего вызова.
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._buffer = bytearray()
|
||
|
||
def feed(self, chunk: bytes) -> list[str]:
|
||
"""@brief Добавляет кусок и возвращает завершённые строки."""
|
||
self._buffer += chunk
|
||
lines: list[str] = []
|
||
while True:
|
||
end = self._buffer.find(b"\n")
|
||
if end < 0:
|
||
break
|
||
raw = bytes(self._buffer[:end])
|
||
del self._buffer[:end + 1]
|
||
lines.append(raw.decode("utf-8", "replace").rstrip("\r"))
|
||
return lines
|
||
|
||
def reset(self) -> None:
|
||
self._buffer.clear()
|
||
|
||
|
||
class Rs485PacketParser:
|
||
"""@brief Выделяет из потока RS485 пакеты по 17 байт и проверяет CRC."""
|
||
|
||
def __init__(self) -> None:
|
||
self._buffer = bytearray()
|
||
#: Счётчики: принято пакетов и отброшено по несходимости CRC.
|
||
self.stats = {"packets": 0, "crc_errors": 0}
|
||
|
||
def feed(self, chunk: bytes, timestamp: float | None = None) -> list[BoardFrame]:
|
||
"""@brief Добавляет кусок потока и возвращает разобранные кадры."""
|
||
self._buffer += chunk
|
||
now = time.time() if timestamp is None else timestamp
|
||
frames: list[BoardFrame] = []
|
||
while len(self._buffer) >= RS485_LEN:
|
||
start = self._buffer.find(RS485_SYNC)
|
||
if start < 0:
|
||
# Синхробайт может быть разорван между кусками: последний байт
|
||
# оставляем, вдруг это первая половина AA 55.
|
||
del self._buffer[:-1]
|
||
break
|
||
if start:
|
||
del self._buffer[:start]
|
||
continue
|
||
packet = bytes(self._buffer[:RS485_LEN])
|
||
if crc8(packet[2:16]) != packet[16]:
|
||
self.stats["crc_errors"] += 1
|
||
del self._buffer[:2]
|
||
continue
|
||
del self._buffer[:RS485_LEN]
|
||
self.stats["packets"] += 1
|
||
flags = packet[2]
|
||
identifier = int.from_bytes(packet[3:7], "big") & 0x1FFFFFFF
|
||
dlc = min(packet[7], 8)
|
||
frames.append(BoardFrame(now, bool(flags & 1), identifier,
|
||
bool(flags & 2), dlc, packet[8:8 + dlc],
|
||
source="RS485"))
|
||
return frames
|
||
|
||
def reset(self) -> None:
|
||
self._buffer.clear()
|
||
self.stats.update(packets=0, crc_errors=0)
|
||
|
||
|
||
# ==========================================================================
|
||
# Команды прошивки
|
||
# ==========================================================================
|
||
|
||
def command_bitrate(kbps: str | int) -> str:
|
||
"""Скорость шины CAN, кбит/с."""
|
||
return "S%s" % kbps
|
||
|
||
|
||
def command_mode(mode: int) -> str:
|
||
"""Режим контроллера: 0 normal, 1 listen-only, 2 no-ack."""
|
||
return "M%d" % mode
|
||
|
||
|
||
def command_rs485(enabled: bool) -> str:
|
||
"""Только CAN→RS485; в прошивке 1.6 обратный маршрут не меняется."""
|
||
return "R1" if enabled else "R0"
|
||
|
||
|
||
def command_route(route: int) -> str:
|
||
"""Маршрут прошивки 1.6: 0 выкл, 1 CAN→RS485, 2 RS485→CAN, 3 оба."""
|
||
if route not in (0, 1, 2, 3):
|
||
raise ValueError("маршрут должен быть 0..3")
|
||
return "Q%d" % route
|
||
|
||
|
||
def command_rs485_baud(baud: str | int) -> str:
|
||
"""Скорость физического интерфейса RS485 на плате."""
|
||
value = int(baud)
|
||
if not 1200 <= value <= 3_000_000:
|
||
raise ValueError("скорость RS485 должна быть 1200..3000000 бод")
|
||
return "B%d" % value
|
||
|
||
|
||
def command_rs485_transmit(packet: bytes) -> str:
|
||
"""Передача готового 17-байтового пакета в RS485 через USB платы."""
|
||
if len(packet) != RS485_LEN or not packet.startswith(RS485_SYNC):
|
||
raise ValueError("ожидается 17-байтовый пакет RS485 AA 55")
|
||
if crc8(packet[2:16]) != packet[16]:
|
||
raise ValueError("неверный CRC8 пакета RS485")
|
||
return "X," + packet.hex().upper()
|
||
|
||
|
||
def command_sd_logging(enabled: bool) -> str:
|
||
"""Запуск или остановка записи CAN-кадров на TF-карту."""
|
||
return "L1" if enabled else "L0"
|
||
|
||
|
||
def command_sd_list() -> str:
|
||
"""Запрос списка файлов TF-карты через USB-UART платы."""
|
||
return "D"
|
||
|
||
|
||
def normalize_sd_log_name(name: str) -> str:
|
||
"""Разрешает загрузку только корневых CSV-журналов ``can_*``."""
|
||
value = name.strip()
|
||
if value.startswith("/"):
|
||
value = value[1:]
|
||
if not re.fullmatch(r"(?i)can_[a-z0-9_-]+\.csv", value):
|
||
raise ValueError("допустим только корневой журнал can_*.csv")
|
||
return "/" + value
|
||
|
||
|
||
def command_sd_download(name: str, offset: int = 0) -> str:
|
||
"""Запрос содержимого выбранного файла карты через текстовый протокол."""
|
||
if not 0 <= offset <= 0xFFFFFFFF:
|
||
raise ValueError("смещение SD-журнала вне uint32")
|
||
command = "G," + normalize_sd_log_name(name)
|
||
return command if offset == 0 else command + "," + str(offset)
|
||
|
||
|
||
def parse_sd_file_entry(line: str) -> tuple[str, int] | None:
|
||
"""Разбирает строку списка ``#SD file <name> <size>`` прошивки."""
|
||
match = re.fullmatch(r"#SD file\s+(\S+)\s+(\d+)\s*", line)
|
||
if match is None:
|
||
return None
|
||
return normalize_sd_log_name(match.group(1)), int(match.group(2))
|
||
|
||
|
||
def command_filter(identifier: str = "", mask: str = "") -> str:
|
||
"""@brief Аппаратный фильтр по идентификатору и маске (hex).
|
||
|
||
Без аргументов фильтр снимается: прошивка так и понимает голую ``F``.
|
||
"""
|
||
identifier = identifier.strip()
|
||
mask = mask.strip()
|
||
if not identifier and not mask:
|
||
return "F"
|
||
return "F%s,%s" % (identifier or "0", mask or "0")
|
||
|
||
|
||
def command_transmit(extended: bool, identifier: str, dlc: int, data: str) -> str:
|
||
"""@brief Передача кадра в шину.
|
||
|
||
@param extended 29-битный идентификатор вместо 11-битного.
|
||
@param identifier Идентификатор в hex, с ``0x`` или без.
|
||
@param dlc Число байт данных, 0..8.
|
||
@param data Байты данных в hex; пробелы игнорируются.
|
||
"""
|
||
clean_id = identifier.strip().replace("0x", "").replace("0X", "") or "0"
|
||
clean_data = "".join(data.split())
|
||
return "T,%s,%s,%d,%s" % ("E" if extended else "S", clean_id, dlc, clean_data)
|
||
|
||
|
||
#: Команда обнуления счётчиков прошивки.
|
||
COMMAND_CLEAR_COUNTERS = "Z"
|
||
#: Команда «представься»: версия, распиновка и текущее состояние.
|
||
COMMAND_VERSION = "V"
|
||
#: Запрос текущего маршрута (прошивка 1.6 и новее).
|
||
COMMAND_ROUTE = "Q"
|
||
#: Команда вывода списка команд.
|
||
COMMAND_HELP = "?"
|
||
|
||
|
||
# ==========================================================================
|
||
# Демонстрационный поток
|
||
# ==========================================================================
|
||
|
||
@dataclass
|
||
class DemoTraffic:
|
||
"""Порция демо-трафика: строки USB и пакеты RS485 за один такт."""
|
||
|
||
lines: list[str] = field(default_factory=list)
|
||
packets: list[bytes] = field(default_factory=list)
|
||
|
||
|
||
class BoardDemoSource:
|
||
"""@brief Генератор трафика платы: интерфейс проверяется без железа.
|
||
|
||
Строки собираются ровно в формате прошивки, пакеты RS485 — с настоящим
|
||
CRC, поэтому демо-режим проходит через тот же разбор, что и железо.
|
||
"""
|
||
|
||
#: Идентификаторы, по которым генератор идёт по кругу.
|
||
IDS = ((True, 0x18FEE000), (True, 0x0CF00400), (False, 0x123), (False, 0x7DF))
|
||
|
||
def __init__(self, rng: random.Random | None = None) -> None:
|
||
self._rng = rng or random.Random()
|
||
self._count = 0
|
||
self._start = time.time()
|
||
|
||
def next_traffic(self) -> DemoTraffic:
|
||
"""@brief Очередной такт: кадр в обоих видах, раз в 20 тактов — ``#STAT``."""
|
||
extended, identifier = self.IDS[self._count % len(self.IDS)]
|
||
dlc = 8 if extended else 3
|
||
data = bytes(self._rng.randrange(256) for _ in range(dlc))
|
||
elapsed_ms = int((time.time() - self._start) * 1000)
|
||
id_text = ("0x%08X" if extended else "0x%03X ") % identifier
|
||
line = "[%4d.%03d] %s %s DLC=%d DATA=%s" % (
|
||
elapsed_ms // 1000, elapsed_ms % 1000, "EXT" if extended else "STD",
|
||
id_text, dlc, " ".join("%02X" % byte for byte in data))
|
||
|
||
frame = BoardFrame(time.time(), extended, identifier, False, dlc, data,
|
||
source="RS485")
|
||
traffic = DemoTraffic(lines=[line], packets=[frame.rs485_packet()])
|
||
|
||
self._count += 1
|
||
if self._count % 20 == 0:
|
||
traffic.lines.append(
|
||
"#STAT state=RUNNING bitrate=500 mode=normal rx=%d shown=%d tx=0 "
|
||
"buserr=0 miss=0 txerr=0 rxerr=0 dropusb=0 droprs=0 rs485=on "
|
||
"route=3 rsrx=%d rscrc=0 rstx=%d dropcan=0 "
|
||
"baud=512000 vin=12180 up=%d sd=rec sdfile=/can_demo.csv "
|
||
"sdkb=1 sdlines=%d dropsd=0"
|
||
% (self._count, self._count, self._count, self._count,
|
||
int(time.time() - self._start),
|
||
self._count))
|
||
return traffic
|