Extract reusable device protocols, ports and logic analyzers from SETGUI

This commit is contained in:
2026-09-23 20:11:37 +03:00
parent 80ba17d77d
commit 795a1279b1
63 changed files with 10181 additions and 6 deletions

View File

@@ -0,0 +1,623 @@
"""@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