Files
templates/python/set_devices/can485_board.py

624 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""@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