"""@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)(? 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 `` прошивки.""" 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