# Команды и кадры USB-моста CAN485 DevBoard_V1. Преобразование между представлением адаптера и # CAN отделено от последовательного порта, чтобы один контракт использовался в GUI и проверках # без устройства. """@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 from protocan import protocan as pc from protocan.gas_167 import REGISTERS #: Распространённые скорости 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)(? 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() if re.match(r"\d{4}-\d{2}-\d{2}[T ]", text): from datetime import datetime return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp() 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] source_index = _column_index(header, "Канал", "source", "dir", "direction", "Направление") if header else None source = row[source_index].strip() if source_index is not None and source_index < len(row) else "SD" return BoardFrame(stamp, extended, identifier, rtr, dlc, data, source=source), 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 `` прошивки.""" 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), # Assigned UMP map, simulated to demonstrate the common GAS decoder. (True, pc.ProtoCanId.build(1, 1, 3, 5, pc.MsgType.GENERAL_ADDRESS_SPACE, REGISTERS[124]['ump1_id'])), (True, pc.ProtoCanId.build(1, 1, 3, 6, pc.MsgType.GENERAL_ADDRESS_SPACE, REGISTERS[124]['ump2_id'])), ) 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