Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
1
python/set_devices/__init__.py
Normal file
1
python/set_devices/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable device libraries from setcorp/templates."""
|
||||
51
python/set_devices/balsam_can.py
Normal file
51
python/set_devices/balsam_can.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""GUI-neutral adapter for the shared templates Balsam 167 CAN decoder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from protocan import balsam
|
||||
|
||||
from protocan import protocan as pc
|
||||
from .shared_protocol import get_shared_protocol
|
||||
|
||||
|
||||
def decode(raw_id: int, data: bytes, *, ide: bool = True,
|
||||
rtr: bool = False) -> pc.Decoded | None:
|
||||
if not ide or rtr or not balsam.is_balsam_id(raw_id):
|
||||
return None
|
||||
try:
|
||||
frame = balsam.decode(raw_id, data, native=get_shared_protocol())
|
||||
except ValueError as error:
|
||||
result = pc.Decoded(pc.ProtoCanId.parse(raw_id), len(data), bytes(data),
|
||||
ide=ide, rtr=rtr, protocol="BALZAM 167")
|
||||
result.summary = "BALZAM: неверный кадр"
|
||||
result.fields = [("Протокол", "BALZAM 167"), ("Ошибка", str(error))]
|
||||
result.warnings.append(str(error))
|
||||
return result
|
||||
if frame is None:
|
||||
return None
|
||||
|
||||
pid = pc.ProtoCanId(
|
||||
raw=frame.can_id, body=frame.start_address, msg_type=0,
|
||||
device=frame.device, device_type=0,
|
||||
pm=1 if frame.from_device else 0, priority=0,
|
||||
)
|
||||
result = pc.Decoded(pid, len(data), bytes(data), ide=ide, rtr=rtr,
|
||||
protocol="BALZAM 167", device_label=frame.device_name,
|
||||
message_label="BALZAM регистры")
|
||||
result.summary = frame.summary
|
||||
result.fields = [
|
||||
("Протокол", "BALZAM 167 legacy eCAN"),
|
||||
("Узел", "%d — %s" % (frame.device, frame.device_name)),
|
||||
("Направление", "от устройства" if frame.from_device else "к устройству"),
|
||||
("Начальный адрес", "0x%04X" % frame.start_address),
|
||||
("Маска трёх слов", "0b%s" % format(frame.present_mask, "03b")),
|
||||
]
|
||||
result.registers = [(item.address, item.value) for item in frame.registers]
|
||||
for item in frame.registers:
|
||||
result.fields.append((
|
||||
"0x%04X %s" % (item.address, item.name or "регистр"),
|
||||
"0x%04X (%d)" % (item.value, item.signed_value),
|
||||
))
|
||||
if frame.present_mask == 0:
|
||||
result.warnings.append("В адресном слове не отмечен ни один регистр")
|
||||
return result
|
||||
79
python/set_devices/bus_demo.py
Normal file
79
python/set_devices/bus_demo.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""@file bus_demo.py
|
||||
@brief Генератор трафика полевой шины: разбор проверяется без железа.
|
||||
|
||||
Повторяет то, что мост выдаёт в RS485: готовые транспортные кадры со
|
||||
всеми типами сообщений ProtoCAN. Модуль не импортирует Qt — планировщик
|
||||
живёт во вкладке, а сам генератор проверяется host-тестами.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from . import can_transport as tr
|
||||
from protocan import protocan as pc
|
||||
|
||||
#: Типы сообщений, которые генератор выдаёт по кругу случайно.
|
||||
KINDS = ("pulse", "analog", "modbus", "gas", "status", "error", "discrete")
|
||||
|
||||
|
||||
class BusDemoSource:
|
||||
"""@brief Источник демонстрационных кадров шины.
|
||||
|
||||
@param rng Источник случайности; передаётся в тестах для повторяемости.
|
||||
"""
|
||||
|
||||
def __init__(self, rng: random.Random | None = None) -> None:
|
||||
self._rng = rng or random.Random()
|
||||
self._seq = 0
|
||||
|
||||
def next_frame(self) -> bytes:
|
||||
"""@brief Собирает очередной кадр так же, как его отдал бы мост.
|
||||
|
||||
@return Готовые байты транспортного кадра, включая SOF и CRC.
|
||||
"""
|
||||
self._seq = (self._seq + 1) & 0xFF
|
||||
kind = self._rng.choice(KINDS)
|
||||
dev_type, dev_id = 1, 2
|
||||
if kind == "pulse":
|
||||
cid = pc.ProtoCanId.build(1, 1, dev_type, dev_id, pc.MsgType.PULSE, 0)
|
||||
data = bytes([self._seq])
|
||||
elif kind == "analog":
|
||||
sid = self._rng.randint(1, 40)
|
||||
atype = self._rng.choice([pc.AnalogType.U, pc.AnalogType.I, pc.AnalogType.T])
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.ANALOG,
|
||||
pc.merge_analog(atype, sid))
|
||||
tag = {pc.AnalogType.U: b"US", pc.AnalogType.I: b"IS",
|
||||
pc.AnalogType.T: b"TS"}[atype]
|
||||
data = tag + ("%04d" % sid).encode()
|
||||
elif kind == "modbus":
|
||||
adr, cnt = self._rng.randint(0, 0x200), self._rng.randint(1, 4)
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.MODBUS_HOLDING,
|
||||
pc.merge_modbus(adr, cnt))
|
||||
data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little")
|
||||
for _ in range(cnt))
|
||||
elif kind == "gas":
|
||||
adr = self._rng.randint(0, 0x400)
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id,
|
||||
pc.MsgType.GENERAL_ADDRESS_SPACE, adr)
|
||||
data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little")
|
||||
for _ in range(self._rng.randint(1, 4)))
|
||||
elif kind == "status":
|
||||
now = datetime.now()
|
||||
cid = pc.ProtoCanId.build(1, 1, dev_type, dev_id, pc.MsgType.BROADCAST,
|
||||
pc.merge_broadcast(pc.BroadcastType.STATUS, 0))
|
||||
data = bytes([now.hour, now.minute, now.second, now.year % 100,
|
||||
now.month, now.day, now.isoweekday() % 7])
|
||||
elif kind == "error":
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.ERROR,
|
||||
pc.merge_error(self._rng.randint(0, 5), 0xFF))
|
||||
data = b""
|
||||
else:
|
||||
dtype = self._rng.choice(list(pc.DiscreteType)[:7])
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.DISCRETE,
|
||||
pc.merge_discrete(dtype, self._rng.randint(0, 20)))
|
||||
data = bytes(self._rng.randint(0, 255)
|
||||
for _ in range(self._rng.randint(1, 8)))
|
||||
return tr.Frame(seq=self._seq, flags=tr.FLAG_IDE, can_id=cid,
|
||||
data=data).encode()
|
||||
623
python/set_devices/can485_board.py
Normal file
623
python/set_devices/can485_board.py
Normal 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
|
||||
102
python/set_devices/can_bridge.py
Normal file
102
python/set_devices/can_bridge.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Служебный уровень моста CAN <-> RS485: его кадры и его регистры.
|
||||
|
||||
Мост пересылает кадры шины прозрачно, но два вида трафика принадлежат
|
||||
ему самому: служебный кадр с ``FLAGS.ERR`` (состояние CAN и счётчики
|
||||
потерь) и блок регистров ``0xFF00`` в общем адресном пространстве.
|
||||
Разбор обоих собран здесь, чтобы вкладка шины не знала подробностей.
|
||||
|
||||
Модуль не импортирует Qt и проверяется host-тестами.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import can_transport as tr
|
||||
from protocan import protocan as pc
|
||||
from . import balsam_can
|
||||
|
||||
#: Расшифровка поля LEC регистра CAN_ESR.
|
||||
CAN_ESR_LEC = {
|
||||
0: "нет ошибок",
|
||||
1: "stuff error",
|
||||
2: "form error",
|
||||
3: "acknowledgment error",
|
||||
4: "bit recessive error",
|
||||
5: "bit dominant error",
|
||||
6: "CRC error",
|
||||
7: "флаг сброшен ПО",
|
||||
}
|
||||
|
||||
#: Имена диагностических регистров моста, см. CAN_to_RS485/fw/Inc/bridge.h.
|
||||
GAS_NAMES = {
|
||||
0xFF00: "версия транспорта",
|
||||
0xFF01: "время работы, с",
|
||||
0xFF02: "CAN_ESR, младшие 16 бит",
|
||||
0xFF03: "CAN TEC — ошибки передачи",
|
||||
0xFF04: "CAN REC — ошибки приёма",
|
||||
0xFF05: "принято из CAN (младшие)",
|
||||
0xFF06: "принято из CAN (старшие)",
|
||||
0xFF07: "передано в CAN (младшие)",
|
||||
0xFF08: "передано в CAN (старшие)",
|
||||
0xFF09: "потеряно кадров CAN",
|
||||
0xFF0A: "кадров принято с RS485",
|
||||
0xFF0B: "ошибок CRC на RS485",
|
||||
0xFF0C: "кадров не отдано в RS485",
|
||||
0xFF0D: "кадров принято с UART GUI",
|
||||
0xFF0E: "битрейт CAN, кбит/с",
|
||||
0xFF0F: "скорость UART / 100",
|
||||
}
|
||||
|
||||
#: Сколько регистров GAS помещается в один кадр шины (8 байт данных).
|
||||
GAS_REGS_PER_FRAME = 4
|
||||
|
||||
|
||||
def decode_frame(frame: tr.Frame) -> pc.Decoded:
|
||||
"""@brief Разбирает кадр: служебные кадры моста — отдельно от ProtoCAN.
|
||||
|
||||
@param frame Кадр, снятый с полевого канала моста.
|
||||
@return Разбор прикладного уровня либо расшифровка служебного кадра.
|
||||
"""
|
||||
if not frame.is_error:
|
||||
decoded_balsam = balsam_can.decode(
|
||||
frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
if decoded_balsam is not None:
|
||||
return decoded_balsam
|
||||
return pc.decode(frame.can_id, frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
|
||||
dec = pc.Decoded(id=pc.ProtoCanId.parse(frame.can_id), dlc=len(frame.data),
|
||||
data=frame.data, ide=frame.ide, rtr=frame.rtr)
|
||||
d = frame.data
|
||||
if len(d) >= 7:
|
||||
esr, tec, rec = d[0], d[1], d[2]
|
||||
can_ovr = d[3] | (d[4] << 8)
|
||||
rs_err = d[5] | (d[6] << 8)
|
||||
lec = (esr >> 4) & 0x7
|
||||
dec.summary = ("МОСТ: TEC=%d REC=%d, %s%s%s, потери CAN=%d, ошибки RS485=%d"
|
||||
% (tec, rec, CAN_ESR_LEC.get(lec, "?"),
|
||||
", BUS-OFF" if esr & 0x4 else "",
|
||||
", passive" if esr & 0x2 else "",
|
||||
can_ovr, rs_err))
|
||||
dec.fields = [
|
||||
("Источник", "служебный кадр моста (FLAGS.ERR)"),
|
||||
("CAN_ESR[7:0]", "0x%02X" % esr),
|
||||
(" Последняя ошибка", CAN_ESR_LEC.get(lec, "?")),
|
||||
(" EWGF / EPVF / BOFF", "%d / %d / %d" % (esr & 1, (esr >> 1) & 1,
|
||||
(esr >> 2) & 1)),
|
||||
("Счётчик ошибок передачи (TEC)", str(tec)),
|
||||
("Счётчик ошибок приёма (REC)", str(rec)),
|
||||
("Потеряно кадров CAN", str(can_ovr)),
|
||||
("Ошибок/потерь RS485", str(rs_err)),
|
||||
]
|
||||
if esr & 0x4:
|
||||
dec.warnings.append("Шина CAN в состоянии BUS-OFF")
|
||||
elif esr & 0x2:
|
||||
dec.warnings.append("Шина CAN в состоянии error passive")
|
||||
if can_ovr:
|
||||
dec.warnings.append("Мост потерял %d кадр(ов) CAN из-за переполнения" % can_ovr)
|
||||
if rs_err:
|
||||
dec.warnings.append("На стороне RS485 %d ошибок/потерянных байт" % rs_err)
|
||||
else:
|
||||
dec.summary = "МОСТ: служебный кадр, %d байт" % len(d)
|
||||
dec.fields = [("Источник", "служебный кадр моста (FLAGS.ERR)"),
|
||||
("Данные", tr.hex_str(d))]
|
||||
return dec
|
||||
61
python/set_devices/can_log_trends.py
Normal file
61
python/set_devices/can_log_trends.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Convert ESP CAN485 logs into bounded, independently timestamped curves."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
from protocan.legacycan import PROJECTS, LegacyCanFormat, decode
|
||||
|
||||
from .can485_board import BoardFrame, SdLogImport, parse_sd_log
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanLogChannel:
|
||||
name: str
|
||||
address: int
|
||||
points: deque
|
||||
|
||||
|
||||
def _values(frame: BoardFrame):
|
||||
if frame.rtr:
|
||||
return
|
||||
prefix = f"{'EXT' if frame.extended else 'STD'} {frame.identifier:08X}"
|
||||
if frame.extended:
|
||||
for project in PROJECTS:
|
||||
if project.format is not LegacyCanFormat.ROTATING_THREE_WORDS:
|
||||
continue
|
||||
node = project.node_for(frame.identifier)
|
||||
if node is None:
|
||||
continue
|
||||
packet = decode(project.format, node, frame.identifier, frame.data)
|
||||
if packet is not None:
|
||||
for address, value in packet.present_values:
|
||||
# Keep both CAN directions and nodes in separate curves.
|
||||
yield (f"{prefix} · {node.name} · R{address:04X}", address, value)
|
||||
return
|
||||
# Unknown protocols stay explicitly raw; never guess a register layout.
|
||||
for offset in range(0, len(frame.data), 2):
|
||||
part = frame.data[offset:offset + 2]
|
||||
label = f"BE16 [{offset}:{offset + 1}]" if len(part) == 2 else f"байт [{offset}]"
|
||||
yield (f"{prefix} · {label}", offset, int.from_bytes(part, "big"))
|
||||
|
||||
|
||||
def read_can_log(lines, *, reference_timestamp: float, capacity: int,
|
||||
max_total_points: int = 1_000_000,
|
||||
frame_limit: int = 100_000) -> tuple[list[CanLogChannel], SdLogImport]:
|
||||
imported = parse_sd_log(lines, reference_timestamp, limit=frame_limit)
|
||||
channels = {}
|
||||
for frame in imported.frames:
|
||||
for name, address, _value in _values(frame):
|
||||
channels.setdefault(name, address)
|
||||
if len(channels) > 4096:
|
||||
raise ValueError("CAN CSV: слишком много каналов (максимум 4096)")
|
||||
if not channels:
|
||||
raise ValueError("CAN CSV: нет данных для графиков")
|
||||
capacity = max(1, min(capacity, max_total_points // len(channels)))
|
||||
result = {name: CanLogChannel(name, address, deque(maxlen=capacity))
|
||||
for name, address in channels.items()}
|
||||
for frame in imported.frames:
|
||||
for name, _address, value in _values(frame):
|
||||
result[name].points.append((frame.timestamp * 1000, value))
|
||||
return list(result.values()), imported
|
||||
79
python/set_devices/can_ping.py
Normal file
79
python/set_devices/can_ping.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Addressed SETProtocol v2 PING exchange over segmented classic CAN."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
|
||||
from setprotocol.can import CanAddress, CanFrame, CanReassembler, segment
|
||||
from setprotocol.core import (
|
||||
Frame as SetFrame,
|
||||
FrameFlag,
|
||||
MessageType,
|
||||
SetProtocolError,
|
||||
Status,
|
||||
build_frame,
|
||||
decode_datagram,
|
||||
decode_response,
|
||||
)
|
||||
|
||||
from . import can_transport as tr
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CanPingReply:
|
||||
node_id: int
|
||||
uptime_ms: int
|
||||
protocol_version: int = 2
|
||||
|
||||
|
||||
class CanPingExchange:
|
||||
def __init__(self, node_id: int, sequence: int = 1, channel: int = 1) -> None:
|
||||
if not 0 <= node_id <= 0xFF:
|
||||
raise ValueError("SETP node ID вне диапазона 0..255")
|
||||
self.node_id = node_id
|
||||
self.sequence = sequence & 0xFFFF or 1
|
||||
self.channel = channel
|
||||
self._reassembler = CanReassembler()
|
||||
|
||||
def request(self) -> list[tr.Frame]:
|
||||
packet = build_frame(
|
||||
SetFrame(
|
||||
MessageType.PING,
|
||||
self.sequence,
|
||||
flags=FrameFlag.ACK_REQUIRED | FrameFlag.PRIORITY,
|
||||
source=0,
|
||||
destination=self.node_id,
|
||||
)
|
||||
)
|
||||
return [
|
||||
tr.build_frame(item.can_id, item.data, to_can=True)
|
||||
for item in segment(packet, CanAddress(self.node_id, 0, 1, self.channel))
|
||||
]
|
||||
|
||||
def feed(self, frame: tr.Frame) -> CanPingReply | None:
|
||||
if frame.to_can or not frame.ide:
|
||||
return None
|
||||
try:
|
||||
address = CanAddress.unpack(frame.can_id)
|
||||
if address.source != self.node_id or address.destination != 0:
|
||||
return None
|
||||
packet = self._reassembler.feed(
|
||||
CanFrame(frame.can_id, frame.data), int(perf_counter() * 1000)
|
||||
)
|
||||
if packet is None:
|
||||
return None
|
||||
response = decode_datagram(packet)
|
||||
status, body = decode_response(response)
|
||||
except SetProtocolError:
|
||||
return None
|
||||
if (
|
||||
response.message_type != MessageType.PING
|
||||
or response.sequence != self.sequence
|
||||
or response.source != self.node_id
|
||||
or response.destination != 0
|
||||
or status != Status.OK
|
||||
or len(body) != 4
|
||||
):
|
||||
return None
|
||||
return CanPingReply(self.node_id, int.from_bytes(body, "little"))
|
||||
257
python/set_devices/can_transport.py
Normal file
257
python/set_devices/can_transport.py
Normal file
@@ -0,0 +1,257 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Транспортный кадр моста CAN <-> RS485.
|
||||
|
||||
Это не протокол SETGUI (``A5 5A``) из ``protocol.py``, а кадр полевого
|
||||
канала моста: им приходят кадры самой шины CAN.
|
||||
|
||||
Формат описан в CAN_to_RS485/docs/PROTOCOL.md и реализован в прошивке
|
||||
моста (`templates/c/set-protocol/src/pcan_frame.c`):
|
||||
|
||||
AA 55 | LEN | SEQ | FLAGS | ID0..ID3 | DATA[0..8] | CRC_L CRC_H
|
||||
|
||||
LEN = 6 + DLC (длина участка SEQ..DATA)
|
||||
CRC = CRC-16/CCITT-FALSE по байтам LEN..DATA включительно, little-endian
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterator, List, Optional
|
||||
|
||||
from .shared_protocol import get_shared_protocol
|
||||
|
||||
_SHARED_PROTOCOL = get_shared_protocol()
|
||||
|
||||
SOF0 = 0xAA
|
||||
SOF1 = 0x55
|
||||
|
||||
FLAG_IDE = 0x01
|
||||
FLAG_RTR = 0x02
|
||||
FLAG_DIR = 0x04 # 0: CAN -> RS485, 1: RS485 -> CAN
|
||||
FLAG_ERR = 0x08
|
||||
|
||||
STANDARD_ID_MAX = 0x7FF
|
||||
EXTENDED_ID_MAX = 0x1FFFFFFF
|
||||
|
||||
MIN_LEN = 6
|
||||
MAX_LEN = 14
|
||||
FRAME_OVERHEAD = 5 # SOF(2) + LEN(1) + CRC(2)
|
||||
|
||||
|
||||
def crc16_ccitt(data: bytes, crc: int = 0xFFFF) -> int:
|
||||
"""CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, без рефлексии."""
|
||||
if _SHARED_PROTOCOL is not None and crc == 0xFFFF:
|
||||
return _SHARED_PROTOCOL.crc16(data)
|
||||
for byte in data:
|
||||
crc ^= byte << 8
|
||||
for _ in range(8):
|
||||
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
|
||||
return crc
|
||||
|
||||
|
||||
@dataclass
|
||||
class Frame:
|
||||
"""Один транспортный кадр."""
|
||||
|
||||
seq: int
|
||||
flags: int
|
||||
can_id: int
|
||||
data: bytes
|
||||
|
||||
@property
|
||||
def ide(self) -> bool:
|
||||
return bool(self.flags & FLAG_IDE)
|
||||
|
||||
@property
|
||||
def rtr(self) -> bool:
|
||||
return bool(self.flags & FLAG_RTR)
|
||||
|
||||
@property
|
||||
def to_can(self) -> bool:
|
||||
"""True — кадр адресован в CAN (передан хостом)."""
|
||||
return bool(self.flags & FLAG_DIR)
|
||||
|
||||
@property
|
||||
def is_error(self) -> bool:
|
||||
return bool(self.flags & FLAG_ERR)
|
||||
|
||||
@property
|
||||
def direction(self) -> str:
|
||||
if self.is_error:
|
||||
return "ERR"
|
||||
return "TX" if self.to_can else "RX"
|
||||
|
||||
def encode(self) -> bytes:
|
||||
validate_can_id(self.can_id, self.ide)
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
return _SHARED_PROTOCOL.encode(
|
||||
self.seq & 0xFF, self.flags & 0xFF, self.can_id, self.data)
|
||||
payload = bytearray()
|
||||
payload.append(MIN_LEN + len(self.data))
|
||||
payload.append(self.seq & 0xFF)
|
||||
payload.append(self.flags & 0xFF)
|
||||
payload += self.can_id.to_bytes(4, "little")
|
||||
payload += self.data
|
||||
crc = crc16_ccitt(bytes(payload))
|
||||
return bytes([SOF0, SOF1]) + bytes(payload) + crc.to_bytes(2, "little")
|
||||
|
||||
|
||||
def build_frame(can_id: int, data: bytes, seq: int = 0, ide: bool = True,
|
||||
rtr: bool = False, to_can: bool = True) -> Frame:
|
||||
"""Собирает кадр для передачи в мост."""
|
||||
validate_can_id(can_id, ide)
|
||||
if len(data) > 8:
|
||||
raise ValueError("DLC не может превышать 8 байт")
|
||||
flags = 0
|
||||
if ide:
|
||||
flags |= FLAG_IDE
|
||||
if rtr:
|
||||
flags |= FLAG_RTR
|
||||
if to_can:
|
||||
flags |= FLAG_DIR
|
||||
return Frame(seq=seq & 0xFF, flags=flags, can_id=can_id, data=bytes(data))
|
||||
|
||||
|
||||
def validate_can_id(can_id: int, ide: bool = True) -> None:
|
||||
"""Проверяет диапазон стандартного или расширенного CAN ID.
|
||||
|
||||
ProtoCAN из SETCAN использует ``ide=True`` и полный 29-битный диапазон.
|
||||
Явная проверка не даёт ошибочному ID незаметно обрезаться при упаковке.
|
||||
"""
|
||||
if not isinstance(can_id, int):
|
||||
raise TypeError("CAN ID должен быть целым числом")
|
||||
maximum = EXTENDED_ID_MAX if ide else STANDARD_ID_MAX
|
||||
if not 0 <= can_id <= maximum:
|
||||
kind = "расширенный (29 бит)" if ide else "стандартный (11 бит)"
|
||||
raise ValueError(
|
||||
"%s CAN ID должен быть в диапазоне 0x0..0x%X" % (kind, maximum)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParseError:
|
||||
"""Отброшенный участок потока."""
|
||||
|
||||
reason: str
|
||||
raw: bytes
|
||||
|
||||
|
||||
class FrameParser:
|
||||
"""Потоковый разборщик: накапливает байты и отдаёт готовые кадры.
|
||||
|
||||
Ресинхронизация — сдвигом на один байт от неудачной сигнатуры,
|
||||
поэтому мусор в линии стоит не больше одного пропущенного кадра.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buf = bytearray()
|
||||
self._native = (_SHARED_PROTOCOL.parser()
|
||||
if _SHARED_PROTOCOL is not None else None)
|
||||
self.stats = {"frames": 0, "crc_errors": 0, "resync_bytes": 0, "seq_lost": 0}
|
||||
self._last_seq: Optional[int] = None
|
||||
self.errors: List[ParseError] = []
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buf.clear()
|
||||
self._last_seq = None
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
self._native = _SHARED_PROTOCOL.parser()
|
||||
self.stats = {"frames": 0, "crc_errors": 0, "resync_bytes": 0, "seq_lost": 0}
|
||||
|
||||
def feed(self, chunk: bytes) -> List[Frame]:
|
||||
"""Добавляет байты в буфер и возвращает все разобранные кадры."""
|
||||
if self._native is not None:
|
||||
frames = [Frame(item.sequence, item.flags, item.can_id, item.data)
|
||||
for item in self._native.feed(chunk)]
|
||||
for frame in frames:
|
||||
if self._last_seq is not None:
|
||||
self.stats["seq_lost"] += (frame.seq - self._last_seq - 1) & 0xFF
|
||||
self._last_seq = frame.seq
|
||||
native_stats = self._native.stats()
|
||||
self.stats["frames"] = native_stats["frames"]
|
||||
self.stats["crc_errors"] = native_stats["crc_errors"]
|
||||
self.stats["resync_bytes"] = (
|
||||
native_stats["stray_bytes"] + native_stats["bad_len"])
|
||||
return frames
|
||||
self._buf += chunk
|
||||
return list(self._drain())
|
||||
|
||||
def _drain(self) -> Iterator[Frame]:
|
||||
buf = self._buf
|
||||
while True:
|
||||
# 1. Ищем сигнатуру
|
||||
start = -1
|
||||
for i in range(len(buf) - 1):
|
||||
if buf[i] == SOF0 and buf[i + 1] == SOF1:
|
||||
start = i
|
||||
break
|
||||
if start < 0:
|
||||
# Сигнатуры нет: оставляем последний байт (вдруг это 0xAA)
|
||||
drop = max(0, len(buf) - 1)
|
||||
if drop:
|
||||
self.stats["resync_bytes"] += drop
|
||||
del buf[:drop]
|
||||
return
|
||||
if start:
|
||||
self.stats["resync_bytes"] += start
|
||||
del buf[:start]
|
||||
|
||||
# 2. Ждём LEN
|
||||
if len(buf) < 3:
|
||||
return
|
||||
length = buf[2]
|
||||
if not (MIN_LEN <= length <= MAX_LEN):
|
||||
self.errors.append(ParseError("LEN=%d вне 6..14" % length, bytes(buf[:3])))
|
||||
self.stats["resync_bytes"] += 1
|
||||
del buf[:1]
|
||||
continue
|
||||
|
||||
total = 2 + 1 + length + 2
|
||||
if len(buf) < total:
|
||||
return
|
||||
|
||||
payload = bytes(buf[2:2 + 1 + length])
|
||||
got = buf[2 + 1 + length] | (buf[3 + 1 + length] << 8)
|
||||
want = crc16_ccitt(payload)
|
||||
if got != want:
|
||||
self.stats["crc_errors"] += 1
|
||||
self.errors.append(ParseError(
|
||||
"CRC 0x%04X, ожидалось 0x%04X" % (got, want), bytes(buf[:total])))
|
||||
self.stats["resync_bytes"] += 1
|
||||
del buf[:1]
|
||||
continue
|
||||
|
||||
frame = Frame(
|
||||
seq=payload[1],
|
||||
flags=payload[2],
|
||||
can_id=int.from_bytes(payload[3:7], "little") & 0x1FFFFFFF,
|
||||
data=payload[7:],
|
||||
)
|
||||
del buf[:total]
|
||||
self.stats["frames"] += 1
|
||||
if self._last_seq is not None:
|
||||
gap = (frame.seq - self._last_seq - 1) & 0xFF
|
||||
if gap:
|
||||
self.stats["seq_lost"] += gap
|
||||
self._last_seq = frame.seq
|
||||
yield frame
|
||||
|
||||
|
||||
def parse_hex(text: str) -> bytes:
|
||||
"""Разбирает строку вида 'AA 55 08' / 'aa5508' / '0xAA,0x55' в байты."""
|
||||
cleaned = (text.replace("0x", " ").replace("0X", " ")
|
||||
.replace(",", " ").replace(";", " ")
|
||||
.replace("\r", " ").replace("\n", " ").replace("\t", " "))
|
||||
tokens = cleaned.split()
|
||||
if not tokens:
|
||||
return b""
|
||||
if all(len(t) <= 2 for t in tokens):
|
||||
return bytes(int(t, 16) for t in tokens)
|
||||
joined = "".join(tokens)
|
||||
if len(joined) % 2:
|
||||
raise ValueError("нечётное число hex-символов")
|
||||
return bytes.fromhex(joined)
|
||||
|
||||
|
||||
def hex_str(data: bytes) -> str:
|
||||
return " ".join("%02X" % b for b in data)
|
||||
448
python/set_devices/ds18b20.py
Normal file
448
python/set_devices/ds18b20.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""@file ds18b20.py
|
||||
@brief Модель и кодеки датчиков DS18B20 на шине 1-Wire STM32F103C8T6.
|
||||
|
||||
Содержит определения структур данных для работы с температурными датчиками,
|
||||
кодеки для кодирования/декодирования информации о датчиках, и вспомогательные
|
||||
функции для проверки целостности и форматирования данных.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import IntFlag
|
||||
|
||||
from set_devices.protocol import ProtocolError, crc8_maxim
|
||||
|
||||
ROM_SIZE = 8
|
||||
SENSOR_RECORD_SIZE = 14
|
||||
FAMILY_DS18B20 = 0x28
|
||||
MAX_SENSORS = 8
|
||||
MIN_RESOLUTION_BITS = 9
|
||||
MAX_RESOLUTION_BITS = 12
|
||||
RAW_STEP_C = 1.0 / 16.0
|
||||
|
||||
#: Время преобразования в миллисекундах для 9..12 бит.
|
||||
CONVERSION_TIME_MS = {9: 94, 10: 188, 11: 375, 12: 750}
|
||||
|
||||
|
||||
class SensorStatus(IntFlag):
|
||||
"""@brief Биты байта статуса записи ``SENSOR_DATA``.
|
||||
|
||||
Флаги независимы: датчик может ответить presence-импульсом и всё равно
|
||||
вернуть scratchpad с неверным CRC8.
|
||||
"""
|
||||
|
||||
VALID = 0x01
|
||||
CRC_ERROR = 0x02
|
||||
NO_PRESENCE = 0x04
|
||||
PARASITE_POWER = 0x08
|
||||
|
||||
|
||||
def format_rom(rom: bytes) -> str:
|
||||
"""@brief Приводит 64-битный ROM к виду ``28-FF-64-1E-0C-3D-2A-91``.
|
||||
|
||||
@param rom Восемь байт в порядке передачи по шине.
|
||||
@return Строка верхнего регистра для журнала и таблицы.
|
||||
"""
|
||||
return "-".join(f"{byte:02X}" for byte in rom)
|
||||
|
||||
|
||||
def parse_rom(text: str) -> bytes:
|
||||
"""@brief Разбирает ROM из строки с любыми разделителями.
|
||||
|
||||
@param text Текст вида ``28-FF-64-...`` либо непрерывный HEX.
|
||||
@return Ровно восемь байт ROM.
|
||||
@raises ProtocolError При недопустимых символах или длине.
|
||||
"""
|
||||
cleaned = "".join(char for char in text if char.isalnum())
|
||||
try:
|
||||
rom = bytes.fromhex(cleaned)
|
||||
except ValueError as error:
|
||||
raise ProtocolError(f"ROM содержит не HEX-символы: {text}") from error
|
||||
if len(rom) != ROM_SIZE:
|
||||
raise ProtocolError("ROM должен содержать ровно 8 байт")
|
||||
return rom
|
||||
|
||||
|
||||
def resolution_from_config(config: int) -> int:
|
||||
"""@brief Извлекает разрешение из байта конфигурации scratchpad.
|
||||
|
||||
@param config Байт 4 scratchpad, значащие биты 6:5.
|
||||
@return Разрешение в битах от 9 до 12.
|
||||
"""
|
||||
return MIN_RESOLUTION_BITS + ((config >> 5) & 0x03)
|
||||
|
||||
|
||||
def config_from_resolution(bits: int) -> int:
|
||||
"""@brief Собирает байт конфигурации из требуемого разрешения.
|
||||
|
||||
@param bits Разрешение 9..12 бит.
|
||||
@return Байт конфигурации с обязательными единицами резервных бит.
|
||||
@raises ProtocolError При разрешении вне диапазона датчика.
|
||||
"""
|
||||
if not MIN_RESOLUTION_BITS <= bits <= MAX_RESOLUTION_BITS:
|
||||
raise ProtocolError("Разрешение DS18B20 задаётся в диапазоне 9..12 бит")
|
||||
return 0x1F | ((bits - MIN_RESOLUTION_BITS) << 5)
|
||||
|
||||
|
||||
def raw_to_celsius(raw: int, bits: int) -> float:
|
||||
"""@brief Переводит код scratchpad в °C с учётом неопределённых бит.
|
||||
|
||||
При разрешении ниже 12 бит младшие биты кода не определены датчиком и
|
||||
маскируются до перевода в градусы.
|
||||
|
||||
@param raw Знаковый 16-битный код из байтов 0 и 1 scratchpad.
|
||||
@param bits Текущее разрешение датчика 9..12 бит.
|
||||
@return Температура в градусах Цельсия с шагом 1/16 °C.
|
||||
"""
|
||||
limited = max(MIN_RESOLUTION_BITS, min(bits, MAX_RESOLUTION_BITS))
|
||||
undefined = MAX_RESOLUTION_BITS - limited
|
||||
mask = -1 << undefined
|
||||
return (raw & mask) * RAW_STEP_C
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensorReading:
|
||||
"""@brief Одна запись ``SENSOR_DATA`` без привязки к Qt.
|
||||
|
||||
@param rom 64-битный уникальный идентификатор датчика.
|
||||
@param raw Знаковый код температуры из scratchpad.
|
||||
@param user_byte1 Пользовательский байт 1 (регистр TH).
|
||||
@param user_byte2 Пользовательский байт 2 (регистр TL).
|
||||
@param config Байт конфигурации с разрешением преобразования.
|
||||
@param status Битовая маска ``SensorStatus``.
|
||||
"""
|
||||
|
||||
rom: bytes
|
||||
raw: int
|
||||
user_byte1: int
|
||||
user_byte2: int
|
||||
config: int
|
||||
status: int
|
||||
|
||||
@property
|
||||
def rom_hex(self) -> str:
|
||||
"""Идентификатор датчика в виде разделённого HEX."""
|
||||
return format_rom(self.rom)
|
||||
|
||||
@property
|
||||
def family(self) -> int:
|
||||
"""Код семейства 1-Wire; у DS18B20 это ``0x28``."""
|
||||
return self.rom[0]
|
||||
|
||||
@property
|
||||
def serial(self) -> str:
|
||||
"""48-битный серийный номер без кода семейства и CRC8."""
|
||||
return "".join(f"{byte:02X}" for byte in self.rom[1:7])
|
||||
|
||||
@property
|
||||
def rom_crc_valid(self) -> bool:
|
||||
"""Проверяет CRC8 самого ROM, а не принятых данных."""
|
||||
return crc8_maxim(self.rom[:7]) == self.rom[7]
|
||||
|
||||
@property
|
||||
def resolution(self) -> int:
|
||||
"""Текущее разрешение датчика в битах."""
|
||||
return resolution_from_config(self.config)
|
||||
|
||||
@property
|
||||
def conversion_time_ms(self) -> int:
|
||||
"""Время преобразования, соответствующее разрешению."""
|
||||
return CONVERSION_TIME_MS[self.resolution]
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
"""Признак того, что температуру можно показывать оператору."""
|
||||
return bool(self.status & SensorStatus.VALID)
|
||||
|
||||
@property
|
||||
def temperature(self) -> float | None:
|
||||
"""Температура в °C либо ``None`` для недостоверного измерения."""
|
||||
if not self.is_valid:
|
||||
return None
|
||||
return raw_to_celsius(self.raw, self.resolution)
|
||||
|
||||
@property
|
||||
def user_byte1_signed(self) -> int:
|
||||
"""Пользовательский байт 1 как знаковое значение TH в °C."""
|
||||
return self.user_byte1 - 256 if self.user_byte1 > 127 else self.user_byte1
|
||||
|
||||
@property
|
||||
def user_byte2_signed(self) -> int:
|
||||
"""Пользовательский байт 2 как знаковое значение TL в °C."""
|
||||
return self.user_byte2 - 256 if self.user_byte2 > 127 else self.user_byte2
|
||||
|
||||
def status_text(self) -> str:
|
||||
"""@brief Собирает читаемое описание битов статуса.
|
||||
|
||||
@return Перечисление активных признаков через запятую.
|
||||
"""
|
||||
if self.status & SensorStatus.NO_PRESENCE:
|
||||
parts = ["нет ответа"]
|
||||
elif self.status & SensorStatus.CRC_ERROR:
|
||||
parts = ["ошибка CRC8"]
|
||||
elif self.is_valid:
|
||||
parts = ["норма"]
|
||||
else:
|
||||
parts = ["нет данных"]
|
||||
if self.status & SensorStatus.PARASITE_POWER:
|
||||
parts.append("паразитное питание")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def decode_sensor_list(payload: bytes) -> list[bytes]:
|
||||
"""@brief Разбирает ответ ``SENSOR_LIST`` в список ROM.
|
||||
|
||||
@param payload Полезная нагрузка кадра без заголовка.
|
||||
@return Список 8-байтовых идентификаторов в порядке обхода шины.
|
||||
@raises ProtocolError При неверной длине или несогласованном счётчике.
|
||||
"""
|
||||
if not payload:
|
||||
raise ProtocolError("SENSOR_LIST не содержит счётчик датчиков")
|
||||
count = payload[0]
|
||||
if len(payload) != 1 + count * ROM_SIZE:
|
||||
raise ProtocolError("SENSOR_LIST содержит неверное число ROM")
|
||||
return [
|
||||
payload[1 + index * ROM_SIZE : 1 + (index + 1) * ROM_SIZE]
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
|
||||
def decode_sensor_data(payload: bytes) -> list[SensorReading]:
|
||||
"""@brief Разбирает ответ или push-кадр ``SENSOR_DATA``.
|
||||
|
||||
@param payload Полезная нагрузка кадра без заголовка.
|
||||
@return Список измерений в том же порядке, что и ``SENSOR_LIST``.
|
||||
@raises ProtocolError При неверной длине записи или счётчике.
|
||||
"""
|
||||
if not payload:
|
||||
raise ProtocolError("SENSOR_DATA не содержит счётчик датчиков")
|
||||
count = payload[0]
|
||||
if len(payload) != 1 + count * SENSOR_RECORD_SIZE:
|
||||
raise ProtocolError("SENSOR_DATA содержит повреждённые записи")
|
||||
readings: list[SensorReading] = []
|
||||
for index in range(count):
|
||||
base = 1 + index * SENSOR_RECORD_SIZE
|
||||
record = payload[base : base + SENSOR_RECORD_SIZE]
|
||||
readings.append(
|
||||
SensorReading(
|
||||
rom=record[0:ROM_SIZE],
|
||||
raw=int.from_bytes(record[8:10], "little", signed=True),
|
||||
user_byte1=record[10],
|
||||
user_byte2=record[11],
|
||||
config=record[12],
|
||||
status=record[13],
|
||||
)
|
||||
)
|
||||
return readings
|
||||
|
||||
|
||||
def encode_sensor_reading(reading: SensorReading) -> bytes:
|
||||
"""@brief Кодирует запись обратно в wire-формат.
|
||||
|
||||
Общая реализация формата записи для тестов и mock-источника исключает
|
||||
расхождение кодера и декодера.
|
||||
|
||||
@param reading Проверенное измерение.
|
||||
@return Ровно ``SENSOR_RECORD_SIZE`` байт.
|
||||
"""
|
||||
return (
|
||||
reading.rom
|
||||
+ int(reading.raw).to_bytes(2, "little", signed=True)
|
||||
+ bytes(
|
||||
(
|
||||
reading.user_byte1 & 0xFF,
|
||||
reading.user_byte2 & 0xFF,
|
||||
reading.config & 0xFF,
|
||||
reading.status & 0xFF,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def encode_sensor_data(readings: list[SensorReading]) -> bytes:
|
||||
"""@brief Собирает payload ``SENSOR_DATA`` из списка измерений.
|
||||
|
||||
@param readings Не более ``MAX_SENSORS`` записей.
|
||||
@return Счётчик и последовательность записей.
|
||||
@raises ProtocolError Если записей больше, чем помещается в кадр.
|
||||
"""
|
||||
if len(readings) > MAX_SENSORS:
|
||||
raise ProtocolError(f"На шину рассчитано не более {MAX_SENSORS} датчиков")
|
||||
return bytes((len(readings),)) + b"".join(
|
||||
encode_sensor_reading(reading) for reading in readings
|
||||
)
|
||||
|
||||
|
||||
def encode_user_bytes(
|
||||
rom: bytes, user_byte1: int, user_byte2: int, save_to_eeprom: bool = False
|
||||
) -> bytes:
|
||||
"""@brief Формирует payload ``SET_USER_BYTES``.
|
||||
|
||||
@param rom Идентификатор целевого датчика.
|
||||
@param user_byte1 Значение TH в диапазоне -128..255.
|
||||
@param user_byte2 Значение TL в диапазоне -128..255.
|
||||
@param save_to_eeprom Признак выполнения ``COPY SCRATCHPAD``.
|
||||
@return Одиннадцать байт запроса.
|
||||
@raises ProtocolError При недопустимом ROM или значениях байтов.
|
||||
"""
|
||||
_validate_rom(rom)
|
||||
return rom + bytes(
|
||||
(
|
||||
_validate_byte(user_byte1, "user byte 1"),
|
||||
_validate_byte(user_byte2, "user byte 2"),
|
||||
0x01 if save_to_eeprom else 0x00,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def encode_resolution(rom: bytes, bits: int, save_to_eeprom: bool = False) -> bytes:
|
||||
"""@brief Формирует payload ``SET_RESOLUTION``.
|
||||
|
||||
@param rom Идентификатор целевого датчика.
|
||||
@param bits Разрешение 9..12 бит.
|
||||
@param save_to_eeprom Признак выполнения ``COPY SCRATCHPAD``.
|
||||
@return Десять байт запроса.
|
||||
@raises ProtocolError При недопустимом ROM или разрешении.
|
||||
"""
|
||||
_validate_rom(rom)
|
||||
if not MIN_RESOLUTION_BITS <= bits <= MAX_RESOLUTION_BITS:
|
||||
raise ProtocolError("Разрешение DS18B20 задаётся в диапазоне 9..12 бит")
|
||||
return rom + bytes((bits, 0x01 if save_to_eeprom else 0x00))
|
||||
|
||||
|
||||
def encode_poll_period(period_ms: int) -> bytes:
|
||||
"""@brief Формирует payload ``SET_POLL_PERIOD``.
|
||||
|
||||
@param period_ms Период автоматической выдачи, 0 отключает push.
|
||||
@return Два байта в little-endian.
|
||||
@raises ProtocolError При выходе периода за 16 бит.
|
||||
"""
|
||||
if not 0 <= period_ms <= 0xFFFF:
|
||||
raise ProtocolError("Период опроса задаётся в диапазоне 0..65535 мс")
|
||||
return period_ms.to_bytes(2, "little")
|
||||
|
||||
|
||||
def decode_bus_info(payload: bytes) -> dict[str, int | str]:
|
||||
"""@brief Извлекает хвост ``DEVICE_INFO`` с полями шины 1-Wire.
|
||||
|
||||
Первые 16 байт совпадают с общим форматом транспорта, поэтому разбор
|
||||
хвоста не мешает существующему декодеру устройства.
|
||||
|
||||
@param payload Полезная нагрузка ``DEVICE_INFO`` не короче 24 байт.
|
||||
@return Словарь с числом датчиков, разрешением и периодом выдачи.
|
||||
@raises ProtocolError При слишком короткой полезной нагрузке.
|
||||
"""
|
||||
if len(payload) < 24:
|
||||
raise ProtocolError("DEVICE_INFO не содержит блок 1-Wire")
|
||||
info: dict[str, int | str] = {
|
||||
"sensor_count": payload[16],
|
||||
"max_sensors": payload[17],
|
||||
"default_resolution": payload[18],
|
||||
"bus_flags": payload[19],
|
||||
"poll_period_ms": int.from_bytes(payload[20:22], "little"),
|
||||
}
|
||||
name = payload[24:].split(b"\x00")[0].decode("ascii", "replace").strip()
|
||||
if name:
|
||||
info["product"] = name
|
||||
return info
|
||||
|
||||
|
||||
def _validate_rom(rom: bytes) -> None:
|
||||
"""Проверяет длину ROM до формирования запроса."""
|
||||
if len(rom) != ROM_SIZE:
|
||||
raise ProtocolError("ROM должен содержать ровно 8 байт")
|
||||
|
||||
|
||||
def _validate_byte(value: int, title: str) -> int:
|
||||
"""Приводит знаковое или беззнаковое значение к одному байту."""
|
||||
if not -128 <= value <= 255:
|
||||
raise ProtocolError(f"{title} вне диапазона -128..255")
|
||||
return value & 0xFF
|
||||
|
||||
|
||||
class MockSensorBus:
|
||||
"""@brief Детерминированный источник трёх датчиков для mock-режима.
|
||||
|
||||
Класс не зависит от Qt: планировщик передаёт время, а объект отвечает за
|
||||
модель шины, пользовательские байты и разрешение каждого датчика.
|
||||
"""
|
||||
|
||||
ROMS = (
|
||||
bytes((0x28, 0xFF, 0x64, 0x1E, 0x0C, 0x3D, 0x2A, 0x00)),
|
||||
bytes((0x28, 0x1A, 0x77, 0x91, 0x05, 0x00, 0x00, 0x00)),
|
||||
bytes((0x28, 0x0B, 0xC2, 0x54, 0x0A, 0x11, 0x03, 0x00)),
|
||||
)
|
||||
BASE_TEMPERATURES = (24.5, 36.6, -12.0)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sensors = [
|
||||
SensorReading(
|
||||
rom=self._with_crc(rom),
|
||||
raw=int(round(base / RAW_STEP_C)),
|
||||
user_byte1=0x4B,
|
||||
user_byte2=0xC9,
|
||||
config=config_from_resolution(12),
|
||||
status=int(SensorStatus.VALID),
|
||||
)
|
||||
for rom, base in zip(self.ROMS, self.BASE_TEMPERATURES)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _with_crc(rom: bytes) -> bytes:
|
||||
"""Дописывает корректный CRC8, чтобы ROM проходил проверку GUI."""
|
||||
return rom[:7] + bytes((crc8_maxim(rom[:7]),))
|
||||
|
||||
def roms(self) -> list[bytes]:
|
||||
"""Возвращает список ROM в порядке обхода шины."""
|
||||
return [sensor.rom for sensor in self._sensors]
|
||||
|
||||
def snapshot(self, timestamp_s: float) -> list[SensorReading]:
|
||||
"""@brief Считает очередной набор измерений без побочных эффектов.
|
||||
|
||||
@param timestamp_s Монотонное время в секундах от старта mock-порта.
|
||||
@return Список измерений с плавным дрейфом температуры.
|
||||
"""
|
||||
readings: list[SensorReading] = []
|
||||
for index, sensor in enumerate(self._sensors):
|
||||
drift = math.sin(timestamp_s / (4.0 + index) + index) * (0.5 + index * 0.25)
|
||||
base = self.BASE_TEMPERATURES[index] + drift
|
||||
step = 1 << (MAX_RESOLUTION_BITS - sensor.resolution)
|
||||
raw = int(round(base / RAW_STEP_C / step)) * step
|
||||
readings.append(replace(sensor, raw=raw))
|
||||
return readings
|
||||
|
||||
def set_user_bytes(self, rom: bytes, user_byte1: int, user_byte2: int) -> None:
|
||||
"""@brief Применяет пользовательские байты к mock-датчику.
|
||||
|
||||
@param rom Идентификатор датчика на mock-шине.
|
||||
@param user_byte1 Новое значение TH.
|
||||
@param user_byte2 Новое значение TL.
|
||||
@raises KeyError Если ROM отсутствует на mock-шине.
|
||||
"""
|
||||
index = self._index_of(rom)
|
||||
self._sensors[index] = replace(
|
||||
self._sensors[index],
|
||||
user_byte1=_validate_byte(user_byte1, "user byte 1"),
|
||||
user_byte2=_validate_byte(user_byte2, "user byte 2"),
|
||||
)
|
||||
|
||||
def set_resolution(self, rom: bytes, bits: int) -> None:
|
||||
"""@brief Меняет разрешение mock-датчика.
|
||||
|
||||
@param rom Идентификатор датчика на mock-шине.
|
||||
@param bits Разрешение 9..12 бит.
|
||||
@raises KeyError Если ROM отсутствует на mock-шине.
|
||||
"""
|
||||
index = self._index_of(rom)
|
||||
self._sensors[index] = replace(
|
||||
self._sensors[index], config=config_from_resolution(bits)
|
||||
)
|
||||
|
||||
def _index_of(self, rom: bytes) -> int:
|
||||
"""Ищет датчик по ROM и сообщает об отсутствии через KeyError."""
|
||||
for index, sensor in enumerate(self._sensors):
|
||||
if sensor.rom == rom:
|
||||
return index
|
||||
raise KeyError(f"Датчик {format_rom(rom)} отсутствует на mock-шине")
|
||||
403
python/set_devices/eeprom.py
Normal file
403
python/set_devices/eeprom.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""Каталог «датчик — позиция» во внешней EEPROM прибора DS18B20."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from set_devices.ds18b20 import ROM_SIZE, format_rom
|
||||
from set_devices.protocol import ProtocolError
|
||||
|
||||
#: Заголовки колонок экспорта каталога — общие для CSV и Markdown.
|
||||
EXPORT_COLUMNS = (
|
||||
"№", "ID (ROM)", "Серийный", "Позиция", "Позиция (hex)",
|
||||
"Сборка", "Сборка (hex)",
|
||||
)
|
||||
|
||||
#: Длина полезной нагрузки ответа ``EEPROM_INFO``.
|
||||
MEMORY_INFO_SIZE = 20
|
||||
|
||||
#: Длина одной записи каталога в кадре ``EEPROM_LIST``.
|
||||
CATALOG_RECORD_SIZE = 12
|
||||
|
||||
#: Заголовок кадра ``EEPROM_LIST`` перед записями.
|
||||
CATALOG_HEADER_SIZE = 5
|
||||
|
||||
#: Признак «позиция не назначена» в записи каталога.
|
||||
NO_POSITION = 0xFFFF
|
||||
|
||||
#: Признак «номер сборки не назначен» в записи каталога.
|
||||
NO_SERIAL = 0xFFFF
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MemoryInfo:
|
||||
"""@brief Состояние внешней памяти и каталога позиций.
|
||||
|
||||
@param present Кристалл ответил на свой адрес при последнем опросе.
|
||||
@param persistent Каталог прочитан из памяти и переживёт отключение.
|
||||
@param count Число заполненных записей каталога.
|
||||
@param capacity Вместимость каталога в записях.
|
||||
@param i2c_address Адрес кристалла на шине I2C.
|
||||
@param page_size Размер страницы записи, байт.
|
||||
@param size Объём кристалла, байт.
|
||||
@param base_address Адрес блока каталога в памяти.
|
||||
@param bus_errors Счётчик отказов шины с момента запуска прибора.
|
||||
@param blob_size Длина блока каталога в памяти, байт.
|
||||
"""
|
||||
|
||||
present: bool
|
||||
persistent: bool
|
||||
count: int
|
||||
capacity: int
|
||||
i2c_address: int
|
||||
page_size: int
|
||||
size: int
|
||||
base_address: int
|
||||
bus_errors: int
|
||||
blob_size: int
|
||||
|
||||
@property
|
||||
def size_text(self) -> str:
|
||||
"""Объём кристалла в килобайтах для строки состояния."""
|
||||
return f"{self.size // 1024} КБ" if self.size >= 1024 else f"{self.size} Б"
|
||||
|
||||
def summary(self) -> str:
|
||||
"""@brief Собирает описание памяти одной строкой.
|
||||
|
||||
@return Текст для панели вкладки DS18B20.
|
||||
"""
|
||||
if not self.present:
|
||||
return "Память не найдена: проверьте плату EEPROM на выводах PB6 и PB7"
|
||||
storage = "энергонезависимо" if self.persistent else "только в ОЗУ прибора"
|
||||
return (
|
||||
f"FT24C256 по адресу 0x{self.i2c_address:02X}, {self.size_text}, "
|
||||
f"страница {self.page_size} Б; каталог по адресу 0x{self.base_address:04X}, "
|
||||
f"занято {self.count} из {self.capacity} записей, {storage}"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogEntry:
|
||||
"""@brief Одна запись каталога: датчик, позиция и номер сборки.
|
||||
|
||||
Номер сборки — учётное поле: несколько датчиков одной партии (например,
|
||||
10 штук на одной плате) несут один и тот же номер. По шине CAN он не
|
||||
передаётся, узлу-приёмнику важна только позиция.
|
||||
|
||||
@param rom Идентификатор датчика, 8 байт.
|
||||
@param position Позиция в таблице узла-приёмника, два байта.
|
||||
@param assembly_serial Серийный номер сборки (партии), два байта.
|
||||
"""
|
||||
|
||||
rom: bytes
|
||||
position: int
|
||||
assembly_serial: int = NO_SERIAL
|
||||
|
||||
@property
|
||||
def rom_hex(self) -> str:
|
||||
"""Идентификатор в виде разделённого HEX."""
|
||||
return format_rom(self.rom)
|
||||
|
||||
@property
|
||||
def serial(self) -> str:
|
||||
"""48-битный серийный номер датчика без кода семейства и CRC8."""
|
||||
return "".join(f"{byte:02X}" for byte in self.rom[1:7])
|
||||
|
||||
@property
|
||||
def assigned(self) -> bool:
|
||||
"""Признак того, что позиция действительно назначена."""
|
||||
return self.position != NO_POSITION
|
||||
|
||||
@property
|
||||
def position_text(self) -> str:
|
||||
"""Позиция в десятичном и шестнадцатеричном виде."""
|
||||
if not self.assigned:
|
||||
return "не назначена"
|
||||
return f"{self.position} (0x{self.position:04X})"
|
||||
|
||||
@property
|
||||
def assembly_serial_assigned(self) -> bool:
|
||||
"""Признак того, что номер сборки действительно назначен."""
|
||||
return self.assembly_serial != NO_SERIAL
|
||||
|
||||
@property
|
||||
def assembly_serial_text(self) -> str:
|
||||
"""Номер сборки в десятичном и шестнадцатеричном виде."""
|
||||
if not self.assembly_serial_assigned:
|
||||
return "не назначен"
|
||||
return f"{self.assembly_serial} (0x{self.assembly_serial:04X})"
|
||||
|
||||
|
||||
def position_conflicts(
|
||||
entries: list[CatalogEntry],
|
||||
) -> dict[tuple[int, int], list[CatalogEntry]]:
|
||||
"""Возвращает пары «сборка, позиция» с несколькими различными ROM.
|
||||
|
||||
Неназначенные записи ``0xFFFF`` не участвуют в проверке. Результат
|
||||
сохраняет порядок строк каталога, чтобы GUI мог однозначно подсветить и
|
||||
показать оператору конфликтующие датчики.
|
||||
"""
|
||||
by_position: dict[tuple[int, int], list[CatalogEntry]] = {}
|
||||
for entry in entries:
|
||||
if entry.assigned and entry.assembly_serial_assigned:
|
||||
key = (entry.assembly_serial, entry.position)
|
||||
by_position.setdefault(key, []).append(entry)
|
||||
return {
|
||||
key: owners
|
||||
for key, owners in by_position.items()
|
||||
if len({entry.rom for entry in owners}) > 1
|
||||
}
|
||||
|
||||
|
||||
def decode_memory_info(payload: bytes) -> MemoryInfo:
|
||||
"""@brief Разбирает ответ ``EEPROM_INFO``.
|
||||
|
||||
@param payload Полезная нагрузка кадра без заголовка.
|
||||
@return Состояние памяти и каталога.
|
||||
@raises ProtocolError При слишком короткой полезной нагрузке.
|
||||
"""
|
||||
if len(payload) < MEMORY_INFO_SIZE:
|
||||
raise ProtocolError("EEPROM_INFO короче 20 байт")
|
||||
return MemoryInfo(
|
||||
present=payload[0] != 0,
|
||||
persistent=payload[1] != 0,
|
||||
count=payload[2],
|
||||
capacity=payload[3],
|
||||
i2c_address=payload[4],
|
||||
page_size=int.from_bytes(payload[6:8], "little"),
|
||||
size=int.from_bytes(payload[8:12], "little"),
|
||||
base_address=int.from_bytes(payload[12:16], "little"),
|
||||
bus_errors=int.from_bytes(payload[16:18], "little"),
|
||||
blob_size=int.from_bytes(payload[18:20], "little"),
|
||||
)
|
||||
|
||||
|
||||
def decode_catalog(payload: bytes) -> tuple[int, int, list[CatalogEntry]]:
|
||||
"""@brief Разбирает ответ ``EEPROM_LIST``.
|
||||
|
||||
@param payload Полезная нагрузка кадра без заголовка.
|
||||
@return Смещение окна, полное число записей и сами записи.
|
||||
@raises ProtocolError При неверной длине кадра или счётчике записей.
|
||||
"""
|
||||
if len(payload) < CATALOG_HEADER_SIZE:
|
||||
raise ProtocolError("EEPROM_LIST короче заголовка")
|
||||
offset = int.from_bytes(payload[0:2], "little")
|
||||
total = int.from_bytes(payload[2:4], "little")
|
||||
count = payload[4]
|
||||
if len(payload) != CATALOG_HEADER_SIZE + count * CATALOG_RECORD_SIZE:
|
||||
raise ProtocolError("EEPROM_LIST содержит повреждённые записи")
|
||||
entries: list[CatalogEntry] = []
|
||||
for index in range(count):
|
||||
base = CATALOG_HEADER_SIZE + index * CATALOG_RECORD_SIZE
|
||||
position_offset = base + ROM_SIZE
|
||||
serial_offset = position_offset + 2
|
||||
entries.append(
|
||||
CatalogEntry(
|
||||
rom=payload[base : base + ROM_SIZE],
|
||||
position=int.from_bytes(payload[position_offset:position_offset + 2],
|
||||
"little"),
|
||||
assembly_serial=int.from_bytes(payload[serial_offset:serial_offset + 2],
|
||||
"little"),
|
||||
)
|
||||
)
|
||||
return offset, total, entries
|
||||
|
||||
|
||||
def encode_catalog_request(offset: int = 0, count: int = 0) -> bytes:
|
||||
"""@brief Формирует запрос ``EEPROM_READ``.
|
||||
|
||||
Пустой запрос читает каталог целиком с начала, поэтому значения по
|
||||
умолчанию дают самый частый случай — «показать всё».
|
||||
|
||||
@param offset Номер первой запрашиваемой записи.
|
||||
@param count Число записей; 0 означает «сколько есть».
|
||||
@return Пустая строка байтов либо три байта окна.
|
||||
@raises ProtocolError При выходе аргументов за диапазон.
|
||||
"""
|
||||
if offset == 0 and count == 0:
|
||||
return b""
|
||||
if not 0 <= offset <= 0xFFFF:
|
||||
raise ProtocolError("Смещение каталога задаётся в диапазоне 0..65535")
|
||||
if not 0 <= count <= 0xFF:
|
||||
raise ProtocolError("Число записей задаётся в диапазоне 0..255")
|
||||
return offset.to_bytes(2, "little") + bytes((count,))
|
||||
|
||||
|
||||
def encode_catalog(entries: list[CatalogEntry], offset: int = 0,
|
||||
total: int | None = None) -> bytes:
|
||||
"""@brief Кодирует записи обратно в кадр ``EEPROM_LIST``.
|
||||
|
||||
Общая реализация формата для тестов и mock-прибора исключает расхождение
|
||||
кодера и декодера.
|
||||
|
||||
@param entries Записи каталога.
|
||||
@param offset Смещение окна.
|
||||
@param total Полное число записей; по умолчанию равно длине окна.
|
||||
@return Полезная нагрузка кадра.
|
||||
@raises ProtocolError Если записей больше, чем помещается в кадр.
|
||||
"""
|
||||
if len(entries) > 0xFF:
|
||||
raise ProtocolError("В кадр EEPROM_LIST помещается не более 255 записей")
|
||||
payload = (
|
||||
offset.to_bytes(2, "little")
|
||||
+ (len(entries) if total is None else total).to_bytes(2, "little")
|
||||
+ bytes((len(entries),))
|
||||
)
|
||||
for entry in entries:
|
||||
if len(entry.rom) != ROM_SIZE:
|
||||
raise ProtocolError("ROM должен содержать ровно 8 байт")
|
||||
payload += (
|
||||
entry.rom
|
||||
+ (entry.position & 0xFFFF).to_bytes(2, "little")
|
||||
+ (entry.assembly_serial & 0xFFFF).to_bytes(2, "little")
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
#: Код команды преамбулы CAN «записать датчика в позицию» (см. lib/can на МК).
|
||||
CAN_CMD_WRITE_POSITION = 0xA1
|
||||
|
||||
#: Код команды преамбулы CAN «очистить позицию».
|
||||
CAN_CMD_CLEAR_POSITION = 0xA2
|
||||
|
||||
|
||||
def encode_assign_position(rom: bytes, position: int, assembly_serial: int = 0,
|
||||
command: int = CAN_CMD_WRITE_POSITION) -> bytes:
|
||||
"""@brief Формирует payload команды ``SEND_ID_CAN``.
|
||||
|
||||
Прибор сначала сохраняет пару «датчик — позиция» вместе с номером сборки
|
||||
в каталоге EEPROM — независимо от того, есть ли кто-то на шине CAN — и
|
||||
только потом передаёт идентификатор в CAN двумя кадрами, поэтому один
|
||||
запрос одновременно обновляет каталог прибора и назначает позицию
|
||||
узлу-приёмнику. Номер сборки в кадры CAN не попадает — это учётное поле
|
||||
каталога.
|
||||
|
||||
@param rom Идентификатор датчика, полученный при поиске.
|
||||
@param position Позиция в таблице узла-приёмника, 0..65535.
|
||||
@param assembly_serial Серийный номер сборки (партии), 0..65535.
|
||||
@param command Код команды преамбулы: запись или очистка позиции.
|
||||
@return Тринадцать байт запроса.
|
||||
@raises ProtocolError При недопустимом ROM или значении полей.
|
||||
"""
|
||||
if len(rom) != ROM_SIZE:
|
||||
raise ProtocolError("ROM должен содержать ровно 8 байт")
|
||||
if not 0 <= position <= 0xFFFF:
|
||||
raise ProtocolError("Позиция задаётся в диапазоне 0..65535")
|
||||
if not 0 <= assembly_serial <= 0xFFFF:
|
||||
raise ProtocolError("Номер сборки задаётся в диапазоне 0..65535")
|
||||
return (
|
||||
rom
|
||||
+ position.to_bytes(2, "little")
|
||||
+ assembly_serial.to_bytes(2, "little")
|
||||
+ bytes((command,))
|
||||
)
|
||||
|
||||
|
||||
def encode_memory_info(info: MemoryInfo) -> bytes:
|
||||
"""@brief Кодирует состояние памяти в кадр ``EEPROM_INFO``.
|
||||
|
||||
@param info Состояние памяти и каталога.
|
||||
@return Полезная нагрузка кадра длиной ``MEMORY_INFO_SIZE``.
|
||||
"""
|
||||
return (
|
||||
bytes((int(info.present), int(info.persistent), info.count, info.capacity,
|
||||
info.i2c_address, 0))
|
||||
+ info.page_size.to_bytes(2, "little")
|
||||
+ info.size.to_bytes(4, "little")
|
||||
+ info.base_address.to_bytes(4, "little")
|
||||
+ info.bus_errors.to_bytes(2, "little")
|
||||
+ info.blob_size.to_bytes(2, "little")
|
||||
)
|
||||
|
||||
|
||||
def _export_row(index: int, entry: CatalogEntry) -> tuple[str, ...]:
|
||||
"""@brief Собирает одну строку экспорта из записи каталога.
|
||||
|
||||
Общая для CSV и Markdown реализация исключает расхождение колонок между
|
||||
форматами: обе таблицы читаются одинаково.
|
||||
|
||||
@param index Порядковый номер строки, с единицы.
|
||||
@param entry Запись каталога.
|
||||
@return Кортеж строковых значений в порядке EXPORT_COLUMNS.
|
||||
"""
|
||||
position = "" if not entry.assigned else str(entry.position)
|
||||
position_hex = "" if not entry.assigned else f"0x{entry.position:04X}"
|
||||
serial = "" if not entry.assembly_serial_assigned else str(entry.assembly_serial)
|
||||
serial_hex = (
|
||||
"" if not entry.assembly_serial_assigned else f"0x{entry.assembly_serial:04X}"
|
||||
)
|
||||
return (str(index), entry.rom_hex, entry.serial, position, position_hex,
|
||||
serial, serial_hex)
|
||||
|
||||
|
||||
def export_catalog_csv(entries: list[CatalogEntry]) -> str:
|
||||
"""@brief Формирует содержимое CSV-файла каталога для Excel.
|
||||
|
||||
Разделитель — точка с запятой: в русской локали Excel открывает такой
|
||||
CSV с готовой разбивкой по колонкам без диалога импорта, в отличие от
|
||||
запятой. Пустое значение позиции или номера сборки означает, что запись
|
||||
не назначена.
|
||||
|
||||
@param entries Записи каталога в порядке отображения.
|
||||
@return Текст файла с завершающим переводом строки.
|
||||
"""
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n")
|
||||
writer.writerow(EXPORT_COLUMNS)
|
||||
for index, entry in enumerate(entries, start=1):
|
||||
writer.writerow(_export_row(index, entry))
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def export_catalog_json(entries: list[CatalogEntry]) -> str:
|
||||
"""@brief Формирует содержимое JSON-файла каталога.
|
||||
|
||||
Годится для повторной загрузки в другой программе: значения остаются
|
||||
числами (а не отформатированным текстом), отсутствие позиции или номера
|
||||
сборки — явный ``null``, а не признак-заглушка вроде 0xFFFF.
|
||||
|
||||
@param entries Записи каталога в порядке отображения.
|
||||
@return Текст файла в кодировке UTF-8 с отступом в 2 пробела.
|
||||
"""
|
||||
payload = {
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"count": len(entries),
|
||||
"entries": [
|
||||
{
|
||||
"index": index,
|
||||
"rom": entry.rom_hex,
|
||||
"serial": entry.serial,
|
||||
"position": entry.position if entry.assigned else None,
|
||||
"assembly_serial": (
|
||||
entry.assembly_serial if entry.assembly_serial_assigned else None
|
||||
),
|
||||
}
|
||||
for index, entry in enumerate(entries, start=1)
|
||||
],
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def export_catalog_markdown(entries: list[CatalogEntry]) -> str:
|
||||
"""@brief Формирует содержимое Markdown-файла каталога для отчёта.
|
||||
|
||||
@param entries Записи каталога в порядке отображения.
|
||||
@return Текст файла: заголовок, время выгрузки и таблица GFM.
|
||||
"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
lines = [
|
||||
"# Каталог EEPROM DS18B20",
|
||||
"",
|
||||
f"Выгружено: {timestamp}. Записей: {len(entries)}.",
|
||||
"",
|
||||
"| " + " | ".join(EXPORT_COLUMNS) + " |",
|
||||
"|" + "|".join("---" for _ in EXPORT_COLUMNS) + "|",
|
||||
]
|
||||
for index, entry in enumerate(entries, start=1):
|
||||
row = _export_row(index, entry)
|
||||
lines.append("| " + " | ".join(value or "—" for value in row) + " |")
|
||||
return "\n".join(lines) + "\n"
|
||||
125
python/set_devices/firmware.py
Normal file
125
python/set_devices/firmware.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""@file firmware.py
|
||||
@brief Проверка образов и переносимая машина блочной передачи прошивки.
|
||||
|
||||
Реализует загрузку и валидацию образов прошивки из различных форматов файлов,
|
||||
проверку целостности данных, и логику передачи образов на устройство.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from set_devices.protocol import crc32_ieee
|
||||
|
||||
|
||||
class FirmwareImageError(ValueError):
|
||||
"""@brief Файл нельзя безопасно представить как непрерывный бинарный образ."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwareImage:
|
||||
"""@brief Загруженный образ прошивки с метаданными.
|
||||
|
||||
@param path Путь к исходному файлу образа
|
||||
@param data Бинарные данные образа
|
||||
@param base_address Адрес начала загрузки (для Intel HEX формата)
|
||||
@param version Номер версии прошивки
|
||||
"""
|
||||
path: Path
|
||||
data: bytes
|
||||
base_address: int = 0
|
||||
version: int = 0
|
||||
|
||||
@property
|
||||
def crc32(self) -> int:
|
||||
return crc32_ieee(self.data)
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(self.data).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path, maximum_size: int = 2 * 1024 * 1024) -> "FirmwareImage":
|
||||
file_path = Path(path)
|
||||
raw = file_path.read_bytes()
|
||||
if file_path.suffix.lower() == ".hex":
|
||||
data, base = _parse_intel_hex(raw.decode("ascii"))
|
||||
elif file_path.suffix.lower() == ".bin":
|
||||
data, base = raw, 0
|
||||
else:
|
||||
raise FirmwareImageError("Поддерживаются только .bin и Intel HEX")
|
||||
if not data:
|
||||
raise FirmwareImageError("Образ пуст")
|
||||
if len(data) > maximum_size:
|
||||
raise FirmwareImageError(f"Размер образа превышает {maximum_size} байт")
|
||||
return cls(file_path, data, base)
|
||||
|
||||
|
||||
def _parse_intel_hex(text: str) -> tuple[bytes, int]:
|
||||
memory: dict[int, int] = {}
|
||||
upper = 0
|
||||
eof = False
|
||||
for line_number, source in enumerate(text.splitlines(), 1):
|
||||
line = source.strip()
|
||||
if not line:
|
||||
continue
|
||||
if not line.startswith(":"):
|
||||
raise FirmwareImageError(f"Intel HEX: строка {line_number} без ':'")
|
||||
try:
|
||||
record = bytes.fromhex(line[1:])
|
||||
except ValueError as error:
|
||||
raise FirmwareImageError(f"Intel HEX: неверный HEX в строке {line_number}") from error
|
||||
if len(record) < 5 or len(record) != record[0] + 5 or sum(record) & 0xFF:
|
||||
raise FirmwareImageError(f"Intel HEX: неверная длина/CRC строки {line_number}")
|
||||
count = record[0]
|
||||
address = int.from_bytes(record[1:3], "big")
|
||||
kind = record[3]
|
||||
payload = record[4 : 4 + count]
|
||||
if kind == 0x00:
|
||||
absolute = upper + address
|
||||
for index, value in enumerate(payload):
|
||||
key = absolute + index
|
||||
if key in memory and memory[key] != value:
|
||||
raise FirmwareImageError("Intel HEX содержит конфликтующие диапазоны")
|
||||
memory[key] = value
|
||||
elif kind == 0x01:
|
||||
eof = True
|
||||
break
|
||||
elif kind == 0x04 and count == 2:
|
||||
upper = int.from_bytes(payload, "big") << 16
|
||||
elif kind == 0x02 and count == 2:
|
||||
upper = int.from_bytes(payload, "big") << 4
|
||||
elif kind not in (0x03, 0x05):
|
||||
raise FirmwareImageError(f"Intel HEX: тип записи {kind:#x} не поддержан")
|
||||
if not eof or not memory:
|
||||
raise FirmwareImageError("Intel HEX не содержит данных или EOF")
|
||||
start, end = min(memory), max(memory) + 1
|
||||
image = bytearray(b"\xFF" * (end - start))
|
||||
for address, value in memory.items():
|
||||
image[address - start] = value
|
||||
return bytes(image), start
|
||||
|
||||
|
||||
@dataclass
|
||||
class FirmwareTransfer:
|
||||
image: FirmwareImage
|
||||
block_size: int = 480
|
||||
offset: int = 0
|
||||
|
||||
def next_block(self) -> tuple[int, bytes] | None:
|
||||
if self.offset >= len(self.image.data):
|
||||
return None
|
||||
offset = self.offset
|
||||
return offset, self.image.data[offset : offset + self.block_size]
|
||||
|
||||
def acknowledge(self, next_offset: int) -> None:
|
||||
current = self.next_block()
|
||||
if current is None or next_offset != current[0] + len(current[1]):
|
||||
raise FirmwareImageError("МК подтвердил неожиданный offset")
|
||||
self.offset = next_offset
|
||||
|
||||
@property
|
||||
def percent(self) -> int:
|
||||
return int(self.offset * 100 / len(self.image.data))
|
||||
317
python/set_devices/gas_catalog.py
Normal file
317
python/set_devices/gas_catalog.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""Каталог общего адресного пространства и поток выбранных значений.
|
||||
|
||||
Прибор объявляет, какие регистры у него есть и как они называются, GUI
|
||||
выбирает подмножество и получает его пакетами. Схема повторяет реестр
|
||||
регистров ST Motor Control Workbench.
|
||||
|
||||
Модуль не импортирует Qt: кодеки переносимы и проверяются host-тестами.
|
||||
Двоичный контракт описан в ``templates/c/set-protocol/docs/legacy/GUI_CATALOG.md`` и
|
||||
продублирован на C в ``gui/gui_catalog.c``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from .protocol import MAX_PAYLOAD_SIZE, ProtocolError, decode_u16, decode_u32, encode_u16
|
||||
|
||||
#: Длина одной записи каталога на линии.
|
||||
ENTRY_SIZE = 32
|
||||
#: Длина заголовка ответа GAS_CATALOG.
|
||||
CATALOG_HEADER_SIZE = 6
|
||||
#: Длина поля имени в записи. 24 байта - это 12 кириллических
|
||||
#: символов в UTF-8; на 16 байтах не помещалось даже "Температура".
|
||||
NAME_SIZE = 24
|
||||
#: Максимум адресов в одной подписке.
|
||||
WATCH_MAX = 64
|
||||
|
||||
#: Сколько записей каталога помещается в один кадр.
|
||||
ENTRIES_PER_FRAME = (MAX_PAYLOAD_SIZE - CATALOG_HEADER_SIZE) // ENTRY_SIZE
|
||||
|
||||
#: Сколько значений помещается в один кадр потока.
|
||||
VALUES_PER_FRAME = (MAX_PAYLOAD_SIZE - 6) // 2
|
||||
|
||||
|
||||
class ObjectType(IntEnum):
|
||||
"""Формат значения регистра."""
|
||||
|
||||
U16 = 0
|
||||
I16 = 1
|
||||
U32 = 2
|
||||
I32 = 3
|
||||
BITS = 4
|
||||
|
||||
@property
|
||||
def registers(self) -> int:
|
||||
"""Сколько подряд идущих адресов занимает значение."""
|
||||
return 2 if self in (ObjectType.U32, ObjectType.I32) else 1
|
||||
|
||||
@property
|
||||
def signed(self) -> bool:
|
||||
return self in (ObjectType.I16, ObjectType.I32)
|
||||
|
||||
|
||||
#: Доступ и признаки записи каталога.
|
||||
FLAG_READABLE = 0x01
|
||||
FLAG_WRITABLE = 0x02
|
||||
FLAG_DEFAULT_WATCH = 0x04
|
||||
|
||||
#: Единицы измерения; коды входят в wire-контракт и не перенумеровываются.
|
||||
UNITS: dict[int, str] = {
|
||||
0: "",
|
||||
1: "В",
|
||||
2: "А",
|
||||
3: "°C",
|
||||
4: "%",
|
||||
5: "Гц",
|
||||
6: "мс",
|
||||
7: "с",
|
||||
8: "кбит/с",
|
||||
9: "шт.",
|
||||
10: "об/мин",
|
||||
11: "Вт",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObjectEntry:
|
||||
"""@brief Одна запись каталога общего адресного пространства.
|
||||
|
||||
@param address Адрес первого регистра значения.
|
||||
@param type Формат значения.
|
||||
@param flags Биты доступа ``FLAG_*``.
|
||||
@param scale_pow10 Показатель степени: физическое = raw * 10^scale.
|
||||
@param unit Код единицы измерения из ``UNITS``.
|
||||
@param name Имя для оператора, до 24 байт в UTF-8.
|
||||
"""
|
||||
|
||||
address: int
|
||||
type: ObjectType
|
||||
flags: int
|
||||
scale_pow10: int
|
||||
unit: int
|
||||
name: str
|
||||
|
||||
@property
|
||||
def readable(self) -> bool:
|
||||
return bool(self.flags & FLAG_READABLE)
|
||||
|
||||
@property
|
||||
def writable(self) -> bool:
|
||||
return bool(self.flags & FLAG_WRITABLE)
|
||||
|
||||
@property
|
||||
def default_watch(self) -> bool:
|
||||
return bool(self.flags & FLAG_DEFAULT_WATCH)
|
||||
|
||||
@property
|
||||
def unit_text(self) -> str:
|
||||
return UNITS.get(self.unit, "?")
|
||||
|
||||
@property
|
||||
def registers(self) -> int:
|
||||
return self.type.registers
|
||||
|
||||
def scale(self, raw: int) -> float:
|
||||
"""@brief Переводит сырое значение в физическую величину.
|
||||
|
||||
@param raw Слово или пара слов, уже собранные в целое.
|
||||
@return Значение с учётом знака и множителя ``10^scale_pow10``.
|
||||
"""
|
||||
width = 32 if self.registers == 2 else 16
|
||||
if self.type.signed and raw >= (1 << (width - 1)):
|
||||
raw -= 1 << width
|
||||
return raw * (10.0 ** self.scale_pow10)
|
||||
|
||||
def format(self, raw: int) -> str:
|
||||
"""@brief Готовая подпись значения для таблицы GUI."""
|
||||
if self.type is ObjectType.BITS:
|
||||
width = 4 * self.registers
|
||||
return f"0x{raw:0{width}X}"
|
||||
value = self.scale(raw)
|
||||
decimals = max(0, -self.scale_pow10)
|
||||
text = f"{value:.{decimals}f}"
|
||||
return f"{text} {self.unit_text}".strip()
|
||||
|
||||
|
||||
def encode_entry(entry: ObjectEntry) -> bytes:
|
||||
"""@brief Кодирует запись каталога в 32 байта.
|
||||
|
||||
@raises ProtocolError Если имя не влезает в 24 байта UTF-8.
|
||||
"""
|
||||
name = entry.name.encode("utf-8")
|
||||
if len(name) > NAME_SIZE:
|
||||
raise ProtocolError(f"имя '{entry.name}' длиннее {NAME_SIZE} байт в UTF-8")
|
||||
if not 0 <= entry.address <= 0xFFFF:
|
||||
raise ProtocolError("адрес вне диапазона 0..0xFFFF")
|
||||
if not -128 <= entry.scale_pow10 <= 127:
|
||||
raise ProtocolError("scale_pow10 вне диапазона int8")
|
||||
return (
|
||||
encode_u16(entry.address)
|
||||
+ bytes((int(entry.type) & 0xFF, entry.flags & 0xFF,
|
||||
entry.scale_pow10 & 0xFF, entry.unit & 0xFF))
|
||||
+ b"\x00\x00"
|
||||
+ name.ljust(NAME_SIZE, b"\x00")
|
||||
)
|
||||
|
||||
|
||||
def decode_entry(data: bytes, offset: int = 0) -> ObjectEntry:
|
||||
"""@brief Разбирает 32 байта записи каталога.
|
||||
|
||||
Неизвестный код типа не роняет разбор: запись остаётся видимой
|
||||
оператору как ``U16``, иначе новая прошивка сделала бы старый GUI
|
||||
полностью слепым.
|
||||
"""
|
||||
if offset + ENTRY_SIZE > len(data):
|
||||
raise ProtocolError("payload короче записи каталога")
|
||||
chunk = data[offset:offset + ENTRY_SIZE]
|
||||
raw_type = chunk[2]
|
||||
try:
|
||||
object_type = ObjectType(raw_type)
|
||||
except ValueError:
|
||||
object_type = ObjectType.U16
|
||||
scale = chunk[4] - 256 if chunk[4] >= 128 else chunk[4]
|
||||
name = chunk[8:8 + NAME_SIZE].split(b"\x00", 1)[0].decode("utf-8", "replace")
|
||||
return ObjectEntry(
|
||||
address=decode_u16(chunk),
|
||||
type=object_type,
|
||||
flags=chunk[3],
|
||||
scale_pow10=scale,
|
||||
unit=chunk[5],
|
||||
name=name,
|
||||
)
|
||||
|
||||
|
||||
def build_catalog_request(start_index: int = 0, max_count: int = 0) -> bytes:
|
||||
"""@brief Payload запроса каталога.
|
||||
|
||||
@param start_index Порядковый номер записи, а не адрес.
|
||||
@param max_count Сколько записей вернуть; 0 — сколько влезет в кадр.
|
||||
"""
|
||||
return encode_u16(start_index) + encode_u16(max_count)
|
||||
|
||||
|
||||
def build_catalog_response(total: int, start_index: int,
|
||||
entries: list[ObjectEntry]) -> bytes:
|
||||
"""@brief Payload ответа каталога (нужен mock-режиму и тестам)."""
|
||||
if len(entries) > ENTRIES_PER_FRAME:
|
||||
raise ProtocolError(f"в кадр входит не более {ENTRIES_PER_FRAME} записей")
|
||||
return (encode_u16(total) + encode_u16(start_index) + encode_u16(len(entries))
|
||||
+ b"".join(encode_entry(item) for item in entries))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogChunk:
|
||||
"""@brief Часть каталога из одного кадра.
|
||||
|
||||
@param total Полный размер каталога прибора.
|
||||
@param start_index Индекс первой записи в этом куске.
|
||||
@param entries Записи в порядке прибора.
|
||||
"""
|
||||
|
||||
total: int
|
||||
start_index: int
|
||||
entries: tuple[ObjectEntry, ...]
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Дошёл ли каталог до конца именно этим куском."""
|
||||
return self.start_index + len(self.entries) >= self.total
|
||||
|
||||
|
||||
def decode_catalog(payload: bytes) -> CatalogChunk:
|
||||
"""@brief Разбирает кадр ``GAS_CATALOG`` от прибора."""
|
||||
if len(payload) < CATALOG_HEADER_SIZE:
|
||||
raise ProtocolError("GAS_CATALOG короче 6 байт")
|
||||
total = decode_u16(payload)
|
||||
start_index = decode_u16(payload, 2)
|
||||
count = decode_u16(payload, 4)
|
||||
if len(payload) != CATALOG_HEADER_SIZE + count * ENTRY_SIZE:
|
||||
raise ProtocolError("GAS_CATALOG содержит неверное число записей")
|
||||
if start_index + count > total:
|
||||
raise ProtocolError("GAS_CATALOG выходит за объявленный размер каталога")
|
||||
entries = tuple(
|
||||
decode_entry(payload, CATALOG_HEADER_SIZE + index * ENTRY_SIZE)
|
||||
for index in range(count)
|
||||
)
|
||||
return CatalogChunk(total=total, start_index=start_index, entries=entries)
|
||||
|
||||
|
||||
def build_watch_set(period_ms: int, addresses: list[int]) -> bytes:
|
||||
"""@brief Payload подписки на поток значений.
|
||||
|
||||
@param period_ms Период потока; 0 останавливает поток.
|
||||
@param addresses Адреса в нужном порядке, не больше ``WATCH_MAX``.
|
||||
"""
|
||||
if not 0 <= period_ms <= 0xFFFF:
|
||||
raise ProtocolError("период вне диапазона 0..65535 мс")
|
||||
if len(addresses) > WATCH_MAX:
|
||||
raise ProtocolError(f"в подписке не больше {WATCH_MAX} адресов")
|
||||
return (encode_u16(period_ms) + encode_u16(len(addresses))
|
||||
+ b"".join(encode_u16(item) for item in addresses))
|
||||
|
||||
|
||||
def decode_watch_set(payload: bytes) -> tuple[int, list[int]]:
|
||||
"""@brief Разбирает подписку; на стороне прибора и в mock-режиме."""
|
||||
if len(payload) < 4:
|
||||
raise ProtocolError("GAS_WATCH_SET короче 4 байт")
|
||||
period_ms = decode_u16(payload)
|
||||
count = decode_u16(payload, 2)
|
||||
if len(payload) != 4 + count * 2:
|
||||
raise ProtocolError("GAS_WATCH_SET содержит неверное число адресов")
|
||||
return period_ms, [decode_u16(payload, 4 + index * 2) for index in range(count)]
|
||||
|
||||
|
||||
def decode_watch_ack(payload: bytes) -> tuple[int, int]:
|
||||
"""@brief Разбирает эхо прибора: принятый период и число адресов."""
|
||||
if len(payload) < 4:
|
||||
raise ProtocolError("эхо GAS_WATCH_SET короче 4 байт")
|
||||
return decode_u16(payload), decode_u16(payload, 2)
|
||||
|
||||
|
||||
def build_watch_data(timestamp_ms: int, values: list[int]) -> bytes:
|
||||
"""@brief Payload пакета значений (нужен mock-режиму и тестам)."""
|
||||
if len(values) > VALUES_PER_FRAME:
|
||||
raise ProtocolError(f"в кадр входит не более {VALUES_PER_FRAME} значений")
|
||||
return (int(timestamp_ms & 0xFFFFFFFF).to_bytes(4, "little")
|
||||
+ encode_u16(len(values))
|
||||
+ b"".join(encode_u16(item & 0xFFFF) for item in values))
|
||||
|
||||
|
||||
def decode_watch_data(payload: bytes) -> tuple[int, list[int]]:
|
||||
"""@brief Разбирает пакет потока.
|
||||
|
||||
@return Время прибора в миллисекундах и значения в порядке подписки.
|
||||
"""
|
||||
if len(payload) < 6:
|
||||
raise ProtocolError("GAS_WATCH_DATA короче 6 байт")
|
||||
timestamp_ms = decode_u32(payload)
|
||||
count = decode_u16(payload, 4)
|
||||
if len(payload) != 6 + count * 2:
|
||||
raise ProtocolError("GAS_WATCH_DATA содержит неверное число значений")
|
||||
return timestamp_ms, [decode_u16(payload, 6 + index * 2) for index in range(count)]
|
||||
|
||||
|
||||
def assemble(entries: list[ObjectEntry], values: dict[int, int]) -> dict[int, int]:
|
||||
"""@brief Собирает многословные значения из потока сырых слов.
|
||||
|
||||
@param entries Записи каталога, описывающие ширину каждого значения.
|
||||
@param values Сырые слова по адресам, как пришли в потоке.
|
||||
@return Значение по адресу первого слова; ширина учтена.
|
||||
"""
|
||||
result: dict[int, int] = {}
|
||||
for entry in entries:
|
||||
low = values.get(entry.address)
|
||||
if low is None:
|
||||
continue
|
||||
if entry.registers == 1:
|
||||
result[entry.address] = low
|
||||
continue
|
||||
high = values.get(entry.address + 1)
|
||||
if high is None:
|
||||
# Старшее слово не подписано - показывать половину числа хуже,
|
||||
# чем не показывать ничего.
|
||||
continue
|
||||
result[entry.address] = low | (high << 16)
|
||||
return result
|
||||
162
python/set_devices/models.py
Normal file
162
python/set_devices/models.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Модель нейтральных сигналов для шаблона без привязки к ТЗ."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from math import sin
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BitDefinition:
|
||||
"""@brief Описание битового индикатора или управляющей дискреты.
|
||||
|
||||
@param key Стабильный ключ обмена, не зависящий от подписи в UI.
|
||||
@param title Отображаемое имя сигнала.
|
||||
@param writable Разрешено ли пользователю изменять значение.
|
||||
@param alarm Активное состояние отображается аварийным цветом.
|
||||
"""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
writable: bool = False
|
||||
alarm: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NumericDefinition:
|
||||
"""@brief Метаданные цифрового поля или измерительного индикатора.
|
||||
|
||||
Границы и точность принадлежат catalog, поэтому UI не содержит сведений
|
||||
о конкретной карте объектов контроллера.
|
||||
"""
|
||||
|
||||
key: str
|
||||
title: str
|
||||
unit: str = ""
|
||||
minimum: float = 0.0
|
||||
maximum: float = 100.0
|
||||
decimals: int = 1
|
||||
writable: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignalCatalog:
|
||||
"""@brief Полный неизменяемый набор отображаемых сигналов.
|
||||
|
||||
Catalog является переносимой границей между данными устройства и Qt UI.
|
||||
Для реального изделия заменяются определения, а не код вкладок.
|
||||
"""
|
||||
|
||||
controller_fields: tuple[NumericDefinition, ...]
|
||||
indicators: tuple[NumericDefinition, ...]
|
||||
status_bits: tuple[BitDefinition, ...]
|
||||
input_discretes: tuple[BitDefinition, ...]
|
||||
output_discretes: tuple[BitDefinition, ...]
|
||||
|
||||
@classmethod
|
||||
def template(cls) -> "SignalCatalog":
|
||||
"""@brief Создаёт согласованный catalog с нейтральными именами.
|
||||
|
||||
@return Шаблон для обсуждения компоновки без зависимости от финального ТЗ.
|
||||
"""
|
||||
return cls(
|
||||
controller_fields=tuple(
|
||||
NumericDefinition(
|
||||
key=f"controller_{index:02d}",
|
||||
title=f"Параметр {index:02d}",
|
||||
minimum=-9999,
|
||||
maximum=9999,
|
||||
decimals=0,
|
||||
writable=True,
|
||||
)
|
||||
for index in range(1, 9)
|
||||
),
|
||||
indicators=tuple(
|
||||
NumericDefinition(
|
||||
key=f"indicator_{index:02d}",
|
||||
title=f"Индикатор {index:02d}",
|
||||
unit=("В" if index <= 4 else "%"),
|
||||
maximum=(400.0 if index <= 4 else 100.0),
|
||||
)
|
||||
for index in range(1, 9)
|
||||
),
|
||||
status_bits=tuple(
|
||||
BitDefinition(
|
||||
key=f"status_{index:02d}",
|
||||
title=f"Статус {index:02d}",
|
||||
alarm=index in (7, 8),
|
||||
)
|
||||
for index in range(1, 9)
|
||||
),
|
||||
input_discretes=tuple(
|
||||
BitDefinition(
|
||||
key=f"input_{index:02d}",
|
||||
title=f"Вход {index:02d}",
|
||||
)
|
||||
for index in range(1, 17)
|
||||
),
|
||||
output_discretes=tuple(
|
||||
BitDefinition(
|
||||
key=f"output_{index:02d}",
|
||||
title=f"Выход {index:02d}",
|
||||
writable=True,
|
||||
)
|
||||
for index in range(1, 17)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalSnapshot:
|
||||
"""@brief Атомарный снимок значений от одного момента времени.
|
||||
|
||||
Словари заменяются целиком при публикации, чтобы вкладки не видели смесь
|
||||
данных от двух циклов опроса.
|
||||
"""
|
||||
|
||||
timestamp_s: float = 0.0
|
||||
numeric: dict[str, float] = field(default_factory=dict)
|
||||
bits: dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
|
||||
class MockSignalSource:
|
||||
"""@brief Детерминированный генератор данных без контроллера.
|
||||
|
||||
Не зависит от Qt и используется как переносимое ядро в host-тестах.
|
||||
"""
|
||||
|
||||
def __init__(self, catalog: SignalCatalog) -> None:
|
||||
self._catalog = catalog
|
||||
self._outputs = {item.key: False for item in catalog.output_discretes}
|
||||
|
||||
def set_output(self, key: str, value: bool) -> None:
|
||||
"""@brief Сохраняет состояние существующего template-выхода.
|
||||
|
||||
@param key Ключ из ``SignalCatalog.output_discretes``.
|
||||
@param value Новое логическое состояние.
|
||||
@raises KeyError Если ключ отсутствует в разрешённом наборе.
|
||||
"""
|
||||
if key not in self._outputs:
|
||||
raise KeyError(f"Неизвестная управляющая дискрета: {key}")
|
||||
self._outputs[key] = bool(value)
|
||||
|
||||
def snapshot(self, timestamp_s: float) -> SignalSnapshot:
|
||||
"""@brief Генерирует согласованный снимок для заданного времени.
|
||||
|
||||
@param timestamp_s Монотонное время от запуска mock в секундах.
|
||||
@return Новый независимый ``SignalSnapshot``.
|
||||
"""
|
||||
numeric: dict[str, float] = {}
|
||||
bits: dict[str, bool] = {}
|
||||
for index, item in enumerate(self._catalog.controller_fields):
|
||||
numeric[item.key] = float((index + 1) * 10)
|
||||
for index, item in enumerate(self._catalog.indicators):
|
||||
center = item.maximum * 0.55
|
||||
amplitude = item.maximum * 0.18
|
||||
numeric[item.key] = center + amplitude * sin(timestamp_s * 0.7 + index)
|
||||
for index, item in enumerate(self._catalog.status_bits):
|
||||
bits[item.key] = int(timestamp_s + index) % (index + 2) == 0
|
||||
for index, item in enumerate(self._catalog.input_discretes):
|
||||
bits[item.key] = int(timestamp_s * 2 + index) % 5 < 2
|
||||
bits.update(self._outputs)
|
||||
return SignalSnapshot(timestamp_s=timestamp_s, numeric=numeric, bits=bits)
|
||||
320
python/set_devices/panel.py
Normal file
320
python/set_devices/panel.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""@file panel.py
|
||||
@brief Зеркало экрана прибора и кнопки его панели.
|
||||
|
||||
Модуль описывает кадры ``UI_KEY``, ``UI_READ`` и ``UI_STATE``: они позволяют
|
||||
управлять меню прибора из GUI параллельно физическим кнопкам и показывать в
|
||||
окне то же, что видно на дисплее. Разбор здесь не зависит от Qt, поэтому
|
||||
тесты обходятся без графической подсистемы.
|
||||
|
||||
Формат снимка описан в ``PROTOCOL.md`` прошивки STM32F103C8T6; зеркало на
|
||||
стороне МК — ``app_send_ui_state`` в ``src/main.c``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from set_devices.protocol import ProtocolError
|
||||
|
||||
#: Длина заголовка ``UI_STATE`` до первой строки.
|
||||
STATE_HEADER_SIZE = 6
|
||||
|
||||
#: Предельная длина строки снимка; прошивка обрезает по этой границе.
|
||||
TEXT_MAX = 31
|
||||
|
||||
#: Бит 0 первого байта ``UI_STATE``: панель найдена и работает.
|
||||
FLAG_ACTIVE = 0x01
|
||||
|
||||
|
||||
class PanelKey(IntEnum):
|
||||
"""@brief Кнопки панели прибора.
|
||||
|
||||
Значения совпадают с ``Menu_Key`` прошивки и являются частью протокола.
|
||||
"""
|
||||
|
||||
UP = 0
|
||||
DOWN = 1
|
||||
LEFT = 2
|
||||
RIGHT = 3
|
||||
ENTER = 4
|
||||
BACK = 5
|
||||
|
||||
|
||||
#: Подписи кнопок для интерфейса в порядке их размещения на пульте.
|
||||
KEY_TITLES: dict[PanelKey, str] = {
|
||||
PanelKey.UP: "▲",
|
||||
PanelKey.DOWN: "▼",
|
||||
PanelKey.LEFT: "◀",
|
||||
PanelKey.RIGHT: "▶",
|
||||
PanelKey.ENTER: "OK",
|
||||
PanelKey.BACK: "Назад",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelRow:
|
||||
"""@brief Одна видимая строка списка на экране прибора."""
|
||||
|
||||
label: str
|
||||
value: str
|
||||
selected: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PanelScreen:
|
||||
"""@brief Снимок видимой части экрана прибора.
|
||||
|
||||
Повторяет изображение построчно: прошивка отдаёт содержимое из кэша
|
||||
последней отрисовки, поэтому снимок совпадает с панелью.
|
||||
"""
|
||||
|
||||
active: bool
|
||||
title: str
|
||||
status: str
|
||||
rows: tuple[PanelRow, ...]
|
||||
cursor: int
|
||||
first: int
|
||||
total: int
|
||||
depth: int
|
||||
|
||||
@property
|
||||
def cursor_row(self) -> int | None:
|
||||
"""@brief Номер выделенной строки в пределах снимка либо ``None``."""
|
||||
for index, row in enumerate(self.rows):
|
||||
if row.selected:
|
||||
return index
|
||||
return None
|
||||
|
||||
@property
|
||||
def scrollable(self) -> bool:
|
||||
"""@brief Сообщает, что список не поместился в окно экрана целиком."""
|
||||
return self.total > len(self.rows)
|
||||
|
||||
|
||||
def encode_key(key: PanelKey, hold: bool = False) -> bytes:
|
||||
"""@brief Формирует payload ``UI_KEY``.
|
||||
|
||||
@param key Нажимаемая кнопка.
|
||||
@param hold Длинное нажатие; для «назад» означает возврат в корень меню.
|
||||
@return Два байта полезной нагрузки.
|
||||
"""
|
||||
if not isinstance(key, PanelKey):
|
||||
key = PanelKey(key)
|
||||
return bytes((int(key), 0x01 if hold else 0x00))
|
||||
|
||||
|
||||
def encode_read(subscribe: bool | None = None) -> bytes:
|
||||
"""@brief Формирует payload ``UI_READ``.
|
||||
|
||||
@param subscribe ``True`` — подписаться на инициативную выдачу снимков,
|
||||
``False`` — отписаться, ``None`` — только запросить снимок, не меняя
|
||||
подписку.
|
||||
@return Ноль или один байт полезной нагрузки.
|
||||
"""
|
||||
if subscribe is None:
|
||||
return b""
|
||||
return bytes((0x01 if subscribe else 0x00,))
|
||||
|
||||
|
||||
def _take_text(payload: bytes, offset: int) -> tuple[str, int]:
|
||||
"""Читает строку «длина и байты» и возвращает её вместе с новой позицией."""
|
||||
if offset >= len(payload):
|
||||
raise ProtocolError("UI_STATE: строка выходит за границу payload")
|
||||
length = payload[offset]
|
||||
offset += 1
|
||||
if length > TEXT_MAX:
|
||||
raise ProtocolError(f"UI_STATE: строка длиннее {TEXT_MAX} байт: {length}")
|
||||
end = offset + length
|
||||
if end > len(payload):
|
||||
raise ProtocolError("UI_STATE: строка обрывается на границе payload")
|
||||
# Прошивка печатает только ASCII, но битый байт не должен ронять разбор.
|
||||
return payload[offset:end].decode("ascii", errors="replace"), end
|
||||
|
||||
|
||||
def decode_state(payload: bytes) -> PanelScreen:
|
||||
"""@brief Разбирает payload ``UI_STATE`` в снимок экрана.
|
||||
|
||||
@param payload Полезная нагрузка кадра.
|
||||
@return Снимок экрана прибора.
|
||||
@throws ProtocolError Если длина или структура снимка нарушена.
|
||||
"""
|
||||
if len(payload) < STATE_HEADER_SIZE:
|
||||
raise ProtocolError(
|
||||
f"UI_STATE: ожидалось не менее {STATE_HEADER_SIZE} байт, получено {len(payload)}"
|
||||
)
|
||||
flags = payload[0]
|
||||
depth = payload[1]
|
||||
total = payload[2]
|
||||
cursor = payload[3]
|
||||
first = payload[4]
|
||||
row_count = payload[5]
|
||||
|
||||
offset = STATE_HEADER_SIZE
|
||||
title, offset = _take_text(payload, offset)
|
||||
status, offset = _take_text(payload, offset)
|
||||
|
||||
rows: list[PanelRow] = []
|
||||
for _ in range(row_count):
|
||||
if offset >= len(payload):
|
||||
raise ProtocolError("UI_STATE: строк меньше, чем объявлено в заголовке")
|
||||
selected = payload[offset] != 0
|
||||
offset += 1
|
||||
label, offset = _take_text(payload, offset)
|
||||
value, offset = _take_text(payload, offset)
|
||||
rows.append(PanelRow(label=label, value=value, selected=selected))
|
||||
|
||||
return PanelScreen(
|
||||
active=(flags & FLAG_ACTIVE) != 0,
|
||||
title=title,
|
||||
status=status,
|
||||
rows=tuple(rows),
|
||||
cursor=cursor,
|
||||
first=first,
|
||||
total=total,
|
||||
depth=depth,
|
||||
)
|
||||
|
||||
|
||||
def encode_state(screen: PanelScreen) -> bytes:
|
||||
"""@brief Собирает payload ``UI_STATE`` из снимка.
|
||||
|
||||
Нужен mock-режиму и тестам: прошивка формирует кадр сама.
|
||||
|
||||
@param screen Снимок экрана.
|
||||
@return Полезная нагрузка кадра.
|
||||
"""
|
||||
|
||||
def text(value: str) -> bytes:
|
||||
raw = value.encode("ascii", errors="replace")[:TEXT_MAX]
|
||||
return bytes((len(raw),)) + raw
|
||||
|
||||
payload = bytearray(
|
||||
(
|
||||
FLAG_ACTIVE if screen.active else 0x00,
|
||||
screen.depth,
|
||||
screen.total,
|
||||
screen.cursor,
|
||||
screen.first,
|
||||
len(screen.rows),
|
||||
)
|
||||
)
|
||||
payload += text(screen.title)
|
||||
payload += text(screen.status)
|
||||
for row in screen.rows:
|
||||
payload.append(0x01 if row.selected else 0x00)
|
||||
payload += text(row.label)
|
||||
payload += text(row.value)
|
||||
return bytes(payload)
|
||||
|
||||
|
||||
#: Пункты корневого экрана прошивки; порядок совпадает с ``ui_root_label``.
|
||||
ROOT_ITEMS = ("SENSORS", "RESCAN", "CAN", "SETTINGS", "ABOUT")
|
||||
|
||||
#: Сколько строк помещается на панели 135x240 при оформлении по умолчанию.
|
||||
ROWS_VISIBLE = 5
|
||||
|
||||
#: Подсказка по кнопкам, которую прошивка ставит в строку состояния.
|
||||
HINT = "ENT-OPEN BACK-UP HOLD"
|
||||
|
||||
|
||||
class MockPanel:
|
||||
"""@brief Модель меню прибора для работы GUI без оборудования.
|
||||
|
||||
Повторяет структуру экранов прошивки настолько, чтобы пультом можно было
|
||||
пользоваться в mock-режиме: корневой экран и список датчиков с прокруткой.
|
||||
Значения берутся у поставщика, поэтому модель не знает про 1-Wire.
|
||||
"""
|
||||
|
||||
def __init__(self, sensors: Callable[[], Sequence[tuple[str, str]]] | None = None) -> None:
|
||||
"""@param sensors Поставщик строк экрана датчиков: название и значение."""
|
||||
self._sensors = sensors
|
||||
self._depth = 1
|
||||
self._cursor = [0, 0]
|
||||
self._first = [0, 0]
|
||||
self._status = HINT
|
||||
|
||||
def press(self, key: int, hold: bool = False) -> None:
|
||||
"""@brief Применяет нажатие кнопки к модели меню.
|
||||
|
||||
@param key Код кнопки из PanelKey.
|
||||
@param hold Длинное нажатие; для «назад» возвращает в корень.
|
||||
"""
|
||||
key = PanelKey(key)
|
||||
level = self._depth - 1
|
||||
total = self._total()
|
||||
|
||||
if key is PanelKey.BACK:
|
||||
if hold or self._depth > 1:
|
||||
self._depth = 1
|
||||
self._status = HINT
|
||||
return
|
||||
if key is PanelKey.ENTER:
|
||||
if self._depth == 1 and self._cursor[0] == 0:
|
||||
self._depth = 2
|
||||
self._cursor[1] = 0
|
||||
self._first[1] = 0
|
||||
elif self._depth == 1 and self._cursor[0] == 1:
|
||||
self._status = f"{len(self._rows())} SENSORS FOUND"
|
||||
return
|
||||
if total == 0:
|
||||
return
|
||||
if key is PanelKey.UP:
|
||||
self._cursor[level] = (self._cursor[level] - 1) % total
|
||||
elif key is PanelKey.DOWN:
|
||||
self._cursor[level] = (self._cursor[level] + 1) % total
|
||||
else:
|
||||
# Влево и вправо правят значения, которых у mock-экранов нет.
|
||||
return
|
||||
self._scroll(level, total)
|
||||
|
||||
def screen(self) -> PanelScreen:
|
||||
"""@brief Возвращает снимок текущего экрана модели."""
|
||||
level = self._depth - 1
|
||||
total = self._total()
|
||||
first = self._first[level]
|
||||
items = self._items()[first:first + ROWS_VISIBLE]
|
||||
rows = tuple(
|
||||
PanelRow(label=label, value=value,
|
||||
selected=(first + index) == self._cursor[level])
|
||||
for index, (label, value) in enumerate(items)
|
||||
)
|
||||
marks = ""
|
||||
if total > ROWS_VISIBLE:
|
||||
marks = ("^" if first != 0 else "") + ("v" if first + ROWS_VISIBLE < total else "")
|
||||
return PanelScreen(
|
||||
active=True,
|
||||
title="DS18B20" if self._depth == 1 else "SENSORS",
|
||||
status=self._status + marks,
|
||||
rows=rows,
|
||||
cursor=self._cursor[level],
|
||||
first=first,
|
||||
total=total,
|
||||
depth=self._depth,
|
||||
)
|
||||
|
||||
def _rows(self) -> Sequence[tuple[str, str]]:
|
||||
"""Строки экрана датчиков от поставщика; пустой список без него."""
|
||||
return () if self._sensors is None else self._sensors()
|
||||
|
||||
def _items(self) -> Sequence[tuple[str, str]]:
|
||||
"""Пункты открытого экрана вместе со значениями."""
|
||||
if self._depth == 1:
|
||||
values = (str(len(self._rows())), "", "OFF", "", "")
|
||||
return tuple(zip(ROOT_ITEMS, values))
|
||||
return self._rows()
|
||||
|
||||
def _total(self) -> int:
|
||||
"""Число пунктов открытого экрана."""
|
||||
return len(self._items())
|
||||
|
||||
def _scroll(self, level: int, total: int) -> None:
|
||||
"""Держит курсор внутри окна прокрутки после перемещения."""
|
||||
cursor = self._cursor[level]
|
||||
first = self._first[level]
|
||||
if cursor < first:
|
||||
first = cursor
|
||||
elif cursor >= first + ROWS_VISIBLE:
|
||||
first = cursor - ROWS_VISIBLE + 1
|
||||
self._first[level] = max(0, min(first, max(0, total - ROWS_VISIBLE)))
|
||||
325
python/set_devices/protocol.py
Normal file
325
python/set_devices/protocol.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""Совместимое с ``lib/gui_transport`` кадрирование GUI protocol v1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from .shared_protocol import get_shared_protocol
|
||||
|
||||
_SHARED_PROTOCOL = get_shared_protocol()
|
||||
|
||||
SOF = b"\xA5\x5A"
|
||||
PROTOCOL_VERSION = 0x01
|
||||
MAX_PAYLOAD_SIZE = 512
|
||||
HEADER_SIZE = 8
|
||||
CRC_SIZE = 4
|
||||
|
||||
|
||||
class ProtocolError(ValueError):
|
||||
"""@brief Ошибка нарушения контракта транспортного кадра.
|
||||
|
||||
Возникает до передачи при неверных границах и при явной проверке кадра.
|
||||
"""
|
||||
|
||||
|
||||
class MessageType(IntEnum):
|
||||
"""@brief Стабильные типы сообщений из ``gui_transport_protocol.h``.
|
||||
|
||||
Числовые значения являются частью wire-протокола и не перенумеровываются.
|
||||
"""
|
||||
|
||||
PING = 0x01
|
||||
DEVICE_INFO = 0x02
|
||||
GET_OBJECT = 0x03
|
||||
SET_OBJECT = 0x04
|
||||
GET_OBJECT_LIST = 0x05
|
||||
GET_OBJECT_INFO = 0x06
|
||||
COMMAND_STATUS = 0x07
|
||||
DIAGNOSTICS = 0x08
|
||||
READ_REGISTERS = 0x09
|
||||
WRITE_REGISTERS = 0x0A
|
||||
FIRMWARE_BEGIN = 0x0B
|
||||
FIRMWARE_DATA = 0x0C
|
||||
FIRMWARE_END = 0x0D
|
||||
FIRMWARE_ABORT = 0x0E
|
||||
FIRMWARE_STATUS = 0x0F
|
||||
READ_LOGS = 0x10
|
||||
# Каталог общего адресного пространства и поток выбранных значений,
|
||||
# см. templates/c/set-protocol/docs/legacy/GUI_CATALOG.md.
|
||||
GAS_CATALOG = 0x11
|
||||
GAS_WATCH_SET = 0x12
|
||||
GAS_WATCH_DATA = 0x13
|
||||
SENSOR_SCAN = 0x20
|
||||
SENSOR_LIST = 0x21
|
||||
SENSOR_READ = 0x22
|
||||
SENSOR_DATA = 0x23
|
||||
SET_USER_BYTES = 0x24
|
||||
SET_RESOLUTION = 0x25
|
||||
SET_POLL_PERIOD = 0x26
|
||||
SEND_ID_CAN = 0x27
|
||||
UI_KEY = 0x28
|
||||
UI_READ = 0x29
|
||||
UI_STATE = 0x2A
|
||||
EEPROM_SCAN = 0x2B
|
||||
EEPROM_INFO = 0x2C
|
||||
EEPROM_READ = 0x2D
|
||||
EEPROM_LIST = 0x2E
|
||||
NACK = 0x80
|
||||
ACK = 0x81
|
||||
ERROR = 0x82
|
||||
|
||||
|
||||
class ObjectResult(IntEnum):
|
||||
"""Коды результата, общие с ``GUITransport_ObjectResult``."""
|
||||
|
||||
OK = 0
|
||||
INVALID_ARGUMENT = 1
|
||||
INVALID_LENGTH = 2
|
||||
NOT_FOUND = 3
|
||||
ACCESS_DENIED = 4
|
||||
BUSY = 5
|
||||
NO_PROVIDER = 6
|
||||
INTERNAL = 7
|
||||
# Расширение диапазона 0x10+ занято прошивкой STM32F103C8T6 (1-Wire).
|
||||
BUS_ERROR = 0x10
|
||||
UNSUPPORTED = 0x11
|
||||
NO_DISPLAY = 0x12
|
||||
NOT_APPLIED = 0x13
|
||||
NO_MEMORY = 0x14
|
||||
#: Кадр CAN не удалось поставить в почтовый ящик; к 1-Wire не относится.
|
||||
CAN_ERROR = 0x15
|
||||
#: Позиция этой сборки уже принадлежит другому физическому датчику.
|
||||
POSITION_CONFLICT = 0x16
|
||||
|
||||
|
||||
#: Причины отказа прибора на языке оператора.
|
||||
RESULT_TEXT: dict[int, str] = {
|
||||
ObjectResult.OK: "успех",
|
||||
ObjectResult.INVALID_ARGUMENT: "недопустимый аргумент",
|
||||
ObjectResult.INVALID_LENGTH: "неверная длина payload",
|
||||
ObjectResult.NOT_FOUND: "объект или датчик не найден",
|
||||
ObjectResult.ACCESS_DENIED: "доступ запрещён",
|
||||
ObjectResult.BUSY: "прибор занят",
|
||||
ObjectResult.NO_PROVIDER: "обработчик не назначен",
|
||||
ObjectResult.INTERNAL: "внутренняя ошибка прошивки",
|
||||
ObjectResult.BUS_ERROR: "ошибка шины 1-Wire: датчик не подтвердил запись",
|
||||
ObjectResult.UNSUPPORTED: "команда не поддерживается",
|
||||
ObjectResult.NO_DISPLAY: "панель недоступна",
|
||||
ObjectResult.NOT_APPLIED: "датчик ответил, но оставил прежнее значение",
|
||||
ObjectResult.NO_MEMORY: "внешняя память недоступна: отсутствует, переполнена или не приняла запись",
|
||||
ObjectResult.CAN_ERROR: "кадр не удалось поставить в почтовый ящик CAN",
|
||||
ObjectResult.POSITION_CONFLICT: (
|
||||
"позиция этой сборки уже назначена другому датчику; "
|
||||
"выберите запись для удаления"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def describe_result(code: int) -> str:
|
||||
"""@brief Переводит код результата в текст для журнала GUI.
|
||||
|
||||
@param code Значение из payload кадров NACK и ERROR.
|
||||
@return Русское описание либо запись с неизвестным числовым кодом.
|
||||
"""
|
||||
return RESULT_TEXT.get(code, f"неизвестный код {code}")
|
||||
|
||||
|
||||
def crc8_maxim(data: bytes) -> int:
|
||||
"""@brief Вычисляет CRC8 Dallas/Maxim для ROM и scratchpad DS18B20.
|
||||
|
||||
@param data Байты без поля контрольной суммы.
|
||||
@return Значение CRC8 с обратным полиномом ``0x8C``.
|
||||
"""
|
||||
crc = 0
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0x8C if crc & 0x01 else crc >> 1
|
||||
return crc
|
||||
|
||||
|
||||
def encode_u16(value: int) -> bytes:
|
||||
if not 0 <= value <= 0xFFFF:
|
||||
raise ProtocolError("u16 вне диапазона")
|
||||
return value.to_bytes(2, "little")
|
||||
|
||||
|
||||
def encode_u32(value: int) -> bytes:
|
||||
if not 0 <= value <= 0xFFFFFFFF:
|
||||
raise ProtocolError("u32 вне диапазона")
|
||||
return value.to_bytes(4, "little")
|
||||
|
||||
|
||||
def decode_u16(data: bytes, offset: int = 0) -> int:
|
||||
if offset < 0 or offset + 2 > len(data):
|
||||
raise ProtocolError("payload не содержит u16")
|
||||
return int.from_bytes(data[offset : offset + 2], "little")
|
||||
|
||||
|
||||
def decode_u32(data: bytes, offset: int = 0) -> int:
|
||||
if offset < 0 or offset + 4 > len(data):
|
||||
raise ProtocolError("payload не содержит u32")
|
||||
return int.from_bytes(data[offset : offset + 4], "little")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Frame:
|
||||
"""@brief Проверенный кадр без служебных полей SOF и CRC.
|
||||
|
||||
@param message_type Тип сообщения из общего C/Python-контракта.
|
||||
@param sequence Номер запроса в диапазоне 0..65535.
|
||||
@param payload Полезная нагрузка не более ``MAX_PAYLOAD_SIZE`` байт.
|
||||
"""
|
||||
|
||||
message_type: MessageType
|
||||
sequence: int
|
||||
payload: bytes = b""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""@brief Проверяет границы до кодирования или передачи кадра.
|
||||
|
||||
@raises ProtocolError При неверном sequence или слишком длинном payload.
|
||||
"""
|
||||
if not 0 <= self.sequence <= 0xFFFF:
|
||||
raise ProtocolError("sequence должен быть в диапазоне 0..65535")
|
||||
if len(self.payload) > MAX_PAYLOAD_SIZE:
|
||||
raise ProtocolError("payload превышает 512 байт")
|
||||
|
||||
|
||||
def crc32_ieee(data: bytes) -> int:
|
||||
"""@brief Вычисляет IEEE CRC32 как ``GUITransport_Protocol_Crc32``.
|
||||
|
||||
@param data Защищаемые байты от version до конца payload.
|
||||
@return Беззнаковое 32-битное значение контрольной суммы.
|
||||
"""
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
return _SHARED_PROTOCOL.gui_crc32(data)
|
||||
return binascii.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def build_frame(frame: Frame) -> bytes:
|
||||
"""@brief Кодирует полный wire-кадр транспортного протокола.
|
||||
|
||||
@param frame Предварительно проверенная модель кадра.
|
||||
@return SOF, big-endian header, payload и little-endian CRC32.
|
||||
"""
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
return _SHARED_PROTOCOL.gui_encode(
|
||||
int(frame.message_type), frame.sequence, frame.payload)
|
||||
payload_size = len(frame.payload)
|
||||
protected = bytes(
|
||||
(
|
||||
PROTOCOL_VERSION,
|
||||
int(frame.message_type),
|
||||
(frame.sequence >> 8) & 0xFF,
|
||||
frame.sequence & 0xFF,
|
||||
(payload_size >> 8) & 0xFF,
|
||||
payload_size & 0xFF,
|
||||
)
|
||||
) + frame.payload
|
||||
return SOF + protected + crc32_ieee(protected).to_bytes(4, "little")
|
||||
|
||||
|
||||
class FrameParser:
|
||||
"""@brief Потоковый parser с восстановлением синхронизации.
|
||||
|
||||
Экземпляр владеет входным буфером и счётчиками ошибок. Он не использует
|
||||
глобальное состояние и подходит для независимых последовательных каналов.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = bytearray()
|
||||
self._native = (_SHARED_PROTOCOL.gui_parser()
|
||||
if _SHARED_PROTOCOL is not None else None)
|
||||
self._native_offsets = (0, 0, 0)
|
||||
self.crc_errors = 0
|
||||
self.version_errors = 0
|
||||
self.length_errors = 0
|
||||
|
||||
def reset(self) -> None:
|
||||
"""@brief Удаляет незавершённые входные данные.
|
||||
|
||||
Счётчики диагностики сохраняются для анализа качества соединения.
|
||||
"""
|
||||
self._buffer.clear()
|
||||
if _SHARED_PROTOCOL is not None:
|
||||
self._native_offsets = (
|
||||
self.crc_errors, self.version_errors, self.length_errors)
|
||||
self._native = _SHARED_PROTOCOL.gui_parser()
|
||||
|
||||
def feed(self, data: bytes) -> list[Frame]:
|
||||
"""@brief Добавляет произвольный фрагмент последовательного потока.
|
||||
|
||||
@param data Новые байты; границы фрагмента не обязаны совпадать с кадром.
|
||||
@return Ноль или несколько полностью проверенных кадров.
|
||||
"""
|
||||
if not data:
|
||||
return []
|
||||
if self._native is not None:
|
||||
frames: list[Frame] = []
|
||||
for item in self._native.feed(data):
|
||||
try:
|
||||
message_type = MessageType(item.message_type)
|
||||
except ValueError:
|
||||
continue
|
||||
frames.append(Frame(message_type, item.sequence, item.payload))
|
||||
native_stats = self._native.stats()
|
||||
self.crc_errors = self._native_offsets[0] + native_stats["crc_errors"]
|
||||
self.version_errors = (
|
||||
self._native_offsets[1] + native_stats["version_errors"])
|
||||
self.length_errors = (
|
||||
self._native_offsets[2] + native_stats["length_errors"])
|
||||
return frames
|
||||
self._buffer.extend(data)
|
||||
frames: list[Frame] = []
|
||||
|
||||
while True:
|
||||
sof_index = self._buffer.find(SOF)
|
||||
if sof_index < 0:
|
||||
# Последний A5 может быть началом SOF следующего фрагмента.
|
||||
self._buffer[:] = self._buffer[-1:] if self._buffer[-1:] == SOF[:1] else b""
|
||||
break
|
||||
if sof_index:
|
||||
del self._buffer[:sof_index]
|
||||
if len(self._buffer) < HEADER_SIZE:
|
||||
break
|
||||
if self._buffer[2] != PROTOCOL_VERSION:
|
||||
self.version_errors += 1
|
||||
del self._buffer[0]
|
||||
continue
|
||||
|
||||
payload_size = (self._buffer[6] << 8) | self._buffer[7]
|
||||
if payload_size > MAX_PAYLOAD_SIZE:
|
||||
self.length_errors += 1
|
||||
del self._buffer[0]
|
||||
continue
|
||||
total_size = HEADER_SIZE + payload_size + CRC_SIZE
|
||||
if len(self._buffer) < total_size:
|
||||
break
|
||||
|
||||
packet = bytes(self._buffer[:total_size])
|
||||
protected = packet[2:-CRC_SIZE]
|
||||
received_crc = int.from_bytes(packet[-CRC_SIZE:], "little")
|
||||
if crc32_ieee(protected) != received_crc:
|
||||
self.crc_errors += 1
|
||||
del self._buffer[0]
|
||||
continue
|
||||
|
||||
try:
|
||||
message_type = MessageType(packet[3])
|
||||
except ValueError:
|
||||
# Неизвестный тип остаётся протокольной ошибкой GUI.
|
||||
del self._buffer[:total_size]
|
||||
continue
|
||||
frames.append(
|
||||
Frame(
|
||||
message_type=message_type,
|
||||
sequence=(packet[4] << 8) | packet[5],
|
||||
payload=packet[8 : 8 + payload_size],
|
||||
)
|
||||
)
|
||||
del self._buffer[:total_size]
|
||||
|
||||
return frames
|
||||
253
python/set_devices/protocol_capture.py
Normal file
253
python/set_devices/protocol_capture.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Bounded host traffic recording and synthetic DSView v3 logic captures.
|
||||
|
||||
No hardware timing is inferred: timestamps are host observations and overlapping
|
||||
packets on one wire are serialized. Original events remain in the archive.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from time import monotonic_ns
|
||||
import zipfile
|
||||
|
||||
|
||||
def uart_event(source: str, direction: str, data: bytes, rate: int) -> dict:
|
||||
return dict(source=source, kind="uart", direction=direction, data=bytes(data),
|
||||
rate=rate, time_ns=monotonic_ns())
|
||||
|
||||
|
||||
def can_event(source: str, direction: str, identifier: int, data: bytes,
|
||||
rate: int, extended: bool = True, remote: bool = False) -> dict:
|
||||
return dict(source=source, kind="can", direction=direction, data=bytes(data),
|
||||
rate=rate, time_ns=monotonic_ns(), identifier=identifier,
|
||||
extended=extended, remote=remote)
|
||||
|
||||
|
||||
def validate_event(event: dict) -> None:
|
||||
if event['kind'] not in ('uart', 'can') or event['direction'] not in ('RX', 'TX'):
|
||||
raise ValueError('Unknown traffic type/direction')
|
||||
if not isinstance(event['rate'], int) or not 1 <= event['rate'] <= 10_000_000:
|
||||
raise ValueError('Invalid line rate')
|
||||
if not isinstance(event['time_ns'], int) or event['time_ns'] < 0:
|
||||
raise ValueError('Invalid event timestamp')
|
||||
if not event['source'] or any(c in event['source'] for c in '\r\n=[]'):
|
||||
raise ValueError('Invalid source name')
|
||||
if event['kind'] == 'can':
|
||||
if len(event['data']) > 8 or not 0 <= event['identifier'] <= (0x1fffffff if event['extended'] else 0x7ff):
|
||||
raise ValueError('Only classic CAN IDs and DLC 0..8 are supported')
|
||||
|
||||
|
||||
class TrafficRecording:
|
||||
MAX_EVENTS = 100_000
|
||||
MAX_BYTES = 8 * 1024 * 1024
|
||||
|
||||
def __init__(self):
|
||||
self.events: list[dict] = []
|
||||
self.byte_count = 0
|
||||
|
||||
def append(self, event: dict) -> None:
|
||||
validate_event(event)
|
||||
data = bytes(event['data'])
|
||||
if event['kind'] == 'uart' and not data:
|
||||
return
|
||||
if len(self.events) >= self.MAX_EVENTS or self.byte_count + len(data) > self.MAX_BYTES:
|
||||
raise ValueError('Достигнут лимит записи: 100 000 событий / 8 МиБ данных')
|
||||
self.events.append(dict(event, data=data))
|
||||
self.byte_count += len(data)
|
||||
|
||||
|
||||
def bits(value: int, count: int):
|
||||
return [(value >> i) & 1 for i in range(count-1, -1, -1)]
|
||||
|
||||
|
||||
def uart_bits(data: bytes):
|
||||
for byte in data:
|
||||
yield 0
|
||||
for i in range(8):
|
||||
yield (byte >> i) & 1
|
||||
yield 1
|
||||
|
||||
|
||||
def can_bits(event: dict):
|
||||
"""CAN 2.0 data/remote frame, CRC15, stuffing, synthetic successful ACK."""
|
||||
ident, remote, payload = event['identifier'], event['remote'], event['data']
|
||||
if event['extended']:
|
||||
raw = [0] + bits(ident >> 18, 11) + [1, 1] + bits(ident & 0x3ffff, 18) + [int(remote), 0, 0]
|
||||
else:
|
||||
raw = [0] + bits(ident, 11) + [int(remote), 0, 0]
|
||||
raw += bits(len(payload), 4)
|
||||
if not remote:
|
||||
for byte in payload:
|
||||
raw += bits(byte, 8)
|
||||
crc = 0
|
||||
for bit in raw:
|
||||
feedback = ((crc >> 14) & 1) ^ bit
|
||||
crc = (crc << 1) & 0x7fff
|
||||
if feedback:
|
||||
crc ^= 0x4599
|
||||
raw += bits(crc, 15)
|
||||
last, run = None, 0
|
||||
for bit in raw:
|
||||
yield bit
|
||||
run = run + 1 if bit == last else 1
|
||||
last = bit
|
||||
if run == 5:
|
||||
last = 1 - bit
|
||||
yield last
|
||||
run = 1
|
||||
# CRC delimiter, reconstructed ACK, ACK delimiter, EOF, intermission.
|
||||
yield from [1, 0, 1] + [1] * 10
|
||||
|
||||
|
||||
def channel_name(event):
|
||||
# A reconnect at another line rate must remain independently decodable.
|
||||
return event['source'] + ('_' + event['direction'] if event['kind'] == 'uart' else '_CAN') + '_' + str(event['rate'])
|
||||
|
||||
|
||||
class _PackedWriter:
|
||||
"""LSB-first samples streamed in bounded chunks, including long idle runs."""
|
||||
def __init__(self, stream, cancelled):
|
||||
self.stream, self.cancelled = stream, cancelled
|
||||
self.partial = self.used = 0
|
||||
self.buffer = bytearray()
|
||||
|
||||
def _flush(self):
|
||||
if self.cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
self.stream.write(self.buffer)
|
||||
self.buffer.clear()
|
||||
|
||||
def run(self, level: int, count: int):
|
||||
if count < 0:
|
||||
raise ValueError('Non-monotonic waveform')
|
||||
if self.used:
|
||||
take = min(count, 8 - self.used)
|
||||
if level:
|
||||
self.partial |= ((1 << take)-1) << self.used
|
||||
self.used += take
|
||||
count -= take
|
||||
if self.used == 8:
|
||||
self.buffer.append(self.partial)
|
||||
self.partial = self.used = 0
|
||||
full, tail = divmod(count, 8)
|
||||
while full:
|
||||
take = min(full, 65536)
|
||||
self.buffer.extend(bytes([255 if level else 0]) * take)
|
||||
full -= take
|
||||
if len(self.buffer) >= 65536:
|
||||
self._flush()
|
||||
if tail:
|
||||
self.partial = (1 << tail)-1 if level else 0
|
||||
self.used = tail
|
||||
|
||||
def finish(self):
|
||||
if self.used:
|
||||
raise ValueError('Unaligned sample count')
|
||||
self._flush()
|
||||
|
||||
|
||||
def export_dsl(path, events, samplerate=20_000_000, *, overwrite=False,
|
||||
cancelled=lambda: False, progress=lambda value: None):
|
||||
"""Atomic export. Existing destination survives failed/cancelled exports."""
|
||||
path = Path(path)
|
||||
if not events:
|
||||
raise ValueError('Нет записанного трафика')
|
||||
if path.exists() and not overwrite:
|
||||
raise FileExistsError(path)
|
||||
if not isinstance(samplerate, int) or not 1 <= samplerate <= 100_000_000:
|
||||
raise ValueError('Частота дискретизации должна быть от 1 до 100 МГц')
|
||||
groups = {}
|
||||
origin = min(event['time_ns'] for event in events)
|
||||
shifted = 0
|
||||
for event in events:
|
||||
validate_event(event)
|
||||
if samplerate < event['rate'] * 8:
|
||||
raise ValueError('Частота дискретизации должна быть не меньше 8× скорости линии')
|
||||
groups.setdefault(channel_name(event), []).append(event)
|
||||
if len(groups) > 32:
|
||||
raise ValueError('DSView: максимум 32 цифровых канала')
|
||||
schedule, maximum = {}, 0
|
||||
for name, channel_events in groups.items():
|
||||
cursor = 16
|
||||
scheduled = []
|
||||
for event in sorted(channel_events, key=lambda item: item['time_ns']):
|
||||
if cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
data_bits = list(can_bits(event)) if event['kind'] == 'can' else None
|
||||
bit_count = len(data_bits) if data_bits is not None else len(event['data']) * 10
|
||||
wanted = 16 + (event['time_ns'] - origin) * samplerate // 1_000_000_000
|
||||
start = max(cursor, wanted)
|
||||
shifted += int(start > wanted)
|
||||
count = (bit_count * samplerate + event['rate'] - 1) // event['rate']
|
||||
scheduled.append((start, event, data_bits, bit_count))
|
||||
cursor = start + count
|
||||
maximum = max(maximum, cursor + 16)
|
||||
schedule[name] = scheduled
|
||||
padded = (maximum + 63) // 64 * 64
|
||||
if padded > 2_000_000_000:
|
||||
raise ValueError('Запись превышает 2 млрд отсчётов: сократите длительность или частоту')
|
||||
names = list(groups)
|
||||
header = ('[version]\nversion = 3\n[header]\ndriver = virtual-session\n'
|
||||
'device mode = 0\ncapturefile = data\n'
|
||||
f'total samples = {padded}\ntotal probes = {len(names)}\ntotal blocks = 1\n'
|
||||
f'samplerate = {samplerate} Hz\ntrigger time = 0\ntrigger pos = 0\n'
|
||||
+ ''.join(f'probe{i} = {name}\n' for i, name in enumerate(names)))
|
||||
session = {'Device': 'virtual-session', 'DeviceMode': 0, 'Version': 3,
|
||||
'Title': 'DSView v1.3.2', 'Sample count': str(padded),
|
||||
'Sample rate': str(samplerate), 'Max Height': '1X', 'decoder': [],
|
||||
'channel': [{'colour': 'default', 'enabled': True, 'index': i,
|
||||
'name': name, 'strigger': 0, 'type': 10000, 'view_index': i}
|
||||
for i, name in enumerate(names)]}
|
||||
fd, temporary = tempfile.mkstemp(prefix='.setgui-dsl-', suffix='.tmp', dir=path.parent)
|
||||
os.close(fd)
|
||||
try:
|
||||
with zipfile.ZipFile(temporary, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr('header', header)
|
||||
archive.writestr('session', json.dumps(session, ensure_ascii=False))
|
||||
archive.writestr('decoders', '[]')
|
||||
metadata = dict(schema='setgui.protocol-capture.v1', synthetic=True,
|
||||
timing='host observation; overlapping events serialized per wire',
|
||||
uart='8N1, non-inverted', can_ack='synthetic dominant ACK',
|
||||
shifted_events=shifted, samplerate=samplerate,
|
||||
origin_monotonic_ns=origin,
|
||||
events=[dict(event, data=event['data'].hex()) for event in events])
|
||||
archive.writestr('protocol-log.json', json.dumps(metadata, ensure_ascii=False))
|
||||
done, last_progress = 0, -1
|
||||
for index, name in enumerate(names):
|
||||
with archive.open(f'L-{index}/0', 'w', force_zip64=True) as output:
|
||||
writer = _PackedWriter(output, cancelled)
|
||||
cursor = 0
|
||||
for start, event, data_bits, bit_count in schedule[name]:
|
||||
writer.run(1, start - cursor)
|
||||
bitstream = data_bits if data_bits is not None else uart_bits(event['data'])
|
||||
previous = 0
|
||||
for i, level in enumerate(bitstream, 1):
|
||||
boundary = (i * samplerate + event['rate'] - 1) // event['rate']
|
||||
writer.run(level, boundary - previous)
|
||||
previous = boundary
|
||||
cursor = start + previous
|
||||
done += 1
|
||||
value = done * 95 // len(events)
|
||||
if value != last_progress:
|
||||
progress(value)
|
||||
last_progress = value
|
||||
writer.run(1, padded - cursor)
|
||||
writer.finish()
|
||||
if cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
with zipfile.ZipFile(temporary) as archive:
|
||||
if archive.testzip() is not None:
|
||||
raise ValueError('Ошибка проверки архива DSL')
|
||||
if cancelled():
|
||||
raise InterruptedError('Экспорт отменён')
|
||||
if overwrite:
|
||||
os.replace(temporary, path)
|
||||
else:
|
||||
# Atomic no-clobber publication on the same filesystem.
|
||||
os.link(temporary, path)
|
||||
progress(100)
|
||||
finally:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
return dict(samples=padded, samplerate=samplerate, channels=names, shifted_events=shifted)
|
||||
71
python/set_devices/protocol_router.py
Normal file
71
python/set_devices/protocol_router.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Выбор wire-протокола для одного последовательного канала SETGUI.
|
||||
|
||||
Во время подключения оба потоковых parser-а получают одинаковые байты. Первый
|
||||
полностью проверенный кадр фиксирует режим канала до следующего ``reset``.
|
||||
Версия находится сразу после общего SOF A5 5A, поэтому протоколы не могут
|
||||
ошибочно принять кадр друг друга.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from set_devices import protocol as gui_v1
|
||||
from setprotocol import core as setp_v2
|
||||
|
||||
|
||||
class ProtocolMode(Enum):
|
||||
"""Wire-протокол, выбранный для текущего физического соединения."""
|
||||
|
||||
AUTO = "auto"
|
||||
SETPROTOCOL_V2 = "setprotocol_v2"
|
||||
GUI_V1 = "gui_v1"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return {
|
||||
ProtocolMode.AUTO: "Определение протокола…",
|
||||
ProtocolMode.SETPROTOCOL_V2: "SETProtocol v2",
|
||||
ProtocolMode.GUI_V1: "GUI protocol v1 (совместимость)",
|
||||
}[self]
|
||||
|
||||
|
||||
class ProtocolRouter:
|
||||
"""Потоковый маршрутизатор SETProtocol v2 и устаревшего GUI v1."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._v2 = setp_v2.FrameParser()
|
||||
self._v1 = gui_v1.FrameParser()
|
||||
self.mode = ProtocolMode.AUTO
|
||||
|
||||
def reset(self, mode: ProtocolMode = ProtocolMode.AUTO) -> None:
|
||||
self._v2.reset()
|
||||
self._v1.reset()
|
||||
self.mode = mode
|
||||
|
||||
def select(self, mode: ProtocolMode) -> None:
|
||||
"""Фиксирует режим и отбрасывает остатки предыдущего разбора."""
|
||||
if mode is ProtocolMode.AUTO:
|
||||
self.reset()
|
||||
return
|
||||
self.reset(mode)
|
||||
|
||||
def feed(self, data: bytes) -> list[gui_v1.Frame | setp_v2.Frame]:
|
||||
if self.mode is ProtocolMode.SETPROTOCOL_V2:
|
||||
return list(self._v2.feed(data))
|
||||
if self.mode is ProtocolMode.GUI_V1:
|
||||
return list(self._v1.feed(data))
|
||||
|
||||
# В AUTO parser-ы независимы. Неверная версия учитывается только их
|
||||
# внутренней диагностикой и не влияет на второй parser.
|
||||
v2_frames = self._v2.feed(data)
|
||||
v1_frames = self._v1.feed(data)
|
||||
if v2_frames:
|
||||
self.mode = ProtocolMode.SETPROTOCOL_V2
|
||||
self._v1.reset()
|
||||
return list(v2_frames)
|
||||
if v1_frames:
|
||||
self.mode = ProtocolMode.GUI_V1
|
||||
self._v2.reset()
|
||||
return list(v1_frames)
|
||||
return []
|
||||
1
python/set_devices/qt_ports/__init__.py
Normal file
1
python/set_devices/qt_ports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reusable device libraries from setcorp/templates."""
|
||||
258
python/set_devices/qt_ports/candle_adapter.py
Normal file
258
python/set_devices/qt_ports/candle_adapter.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""WinUSB transport for candleLight/gs_usb CAN adapters.
|
||||
|
||||
The native library is the same Candle API used by CANgaroo. This module keeps
|
||||
all ctypes details out of the UI and exposes a small Qt-friendly connection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes as ct
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import re
|
||||
|
||||
from .qt_compat import QObject, Signal
|
||||
|
||||
|
||||
_EXTENDED_ID = 0x80000000
|
||||
_FRAME_RECEIVE = 1
|
||||
_MODE_LISTEN_ONLY = 0x0001
|
||||
|
||||
|
||||
class CandleError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandleChannel:
|
||||
path: str
|
||||
channel: int
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
lower = self.path.lower()
|
||||
product = "CANnectivity" if "vid_1209&pid_ca01" in lower else "candle"
|
||||
return f"{product} — канал {self.channel}"
|
||||
|
||||
@property
|
||||
def adapter_kind(self) -> str:
|
||||
return "candle"
|
||||
|
||||
|
||||
class _Frame(ct.Structure):
|
||||
_pack_ = 1
|
||||
_fields_ = [
|
||||
("echo_id", ct.c_uint32), ("can_id", ct.c_uint32),
|
||||
("can_dlc", ct.c_uint8), ("channel", ct.c_uint8),
|
||||
("flags", ct.c_uint8), ("reserved", ct.c_uint8),
|
||||
("data", ct.c_uint8 * 8), ("timestamp_us", ct.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
def _library_path() -> Path:
|
||||
explicit = os.environ.get('CANDLE_LIBRARY')
|
||||
if explicit:
|
||||
return Path(explicit)
|
||||
bundled = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[1]))
|
||||
candidates = (
|
||||
Path(__file__).resolve().parents[1] / "native" / "candle.dll",
|
||||
bundled / "native" / "candle.dll",
|
||||
bundled / "candle.dll",
|
||||
)
|
||||
return next((path for path in candidates if path.exists()), candidates[0])
|
||||
|
||||
|
||||
class _Api:
|
||||
def __init__(self) -> None:
|
||||
path = _library_path()
|
||||
if sys.platform != "win32" or not path.exists():
|
||||
raise CandleError(f"Candle API недоступен: {path}")
|
||||
self.dll = ct.WinDLL(str(path))
|
||||
handle = ct.c_void_p
|
||||
self._fn("candle_list_scan", [ct.POINTER(handle)])
|
||||
self._fn("candle_list_free", [handle])
|
||||
self._fn("candle_list_length", [handle, ct.POINTER(ct.c_uint8)])
|
||||
self._fn("candle_dev_get", [handle, ct.c_uint8, ct.POINTER(handle)])
|
||||
self._fn("candle_dev_get_path", [handle], ct.c_wchar_p)
|
||||
self._fn("candle_dev_open", [handle])
|
||||
self._fn("candle_dev_close", [handle])
|
||||
self._fn("candle_dev_free", [handle])
|
||||
self._fn("candle_dev_last_error", [handle], ct.c_int)
|
||||
self._fn("candle_channel_count", [handle, ct.POINTER(ct.c_uint8)])
|
||||
self._fn("candle_channel_set_bitrate", [handle, ct.c_uint8, ct.c_uint32])
|
||||
self._fn("candle_channel_start", [handle, ct.c_uint8, ct.c_uint32])
|
||||
self._fn("candle_channel_stop", [handle, ct.c_uint8])
|
||||
self._fn("candle_frame_send", [handle, ct.c_uint8, ct.POINTER(_Frame)])
|
||||
self._fn("candle_frame_read", [handle, ct.POINTER(_Frame), ct.c_uint32])
|
||||
self._fn("candle_frame_type", [ct.POINTER(_Frame)], ct.c_int)
|
||||
self._fn("candle_frame_id", [ct.POINTER(_Frame)], ct.c_uint32)
|
||||
|
||||
def _fn(self, name: str, args: list, result=ct.c_bool) -> None:
|
||||
function = getattr(self.dll, name)
|
||||
function.argtypes = args
|
||||
function.restype = result
|
||||
|
||||
def scan(self) -> list[CandleChannel]:
|
||||
result: list[CandleChannel] = []
|
||||
physical_devices: set[str] = set()
|
||||
listing = ct.c_void_p()
|
||||
if not self.dll.candle_list_scan(ct.byref(listing)):
|
||||
raise CandleError("Не удалось выполнить поиск candle-адаптеров")
|
||||
try:
|
||||
count = ct.c_uint8()
|
||||
if not self.dll.candle_list_length(listing, ct.byref(count)):
|
||||
raise CandleError("Candle API не вернул список устройств")
|
||||
for index in range(count.value):
|
||||
device = ct.c_void_p()
|
||||
if not self.dll.candle_dev_get(listing, index, ct.byref(device)):
|
||||
continue
|
||||
try:
|
||||
if not self.dll.candle_dev_open(device):
|
||||
continue
|
||||
channels = ct.c_uint8()
|
||||
if self.dll.candle_channel_count(device, ct.byref(channels)):
|
||||
path = self.dll.candle_dev_get_path(device) or ""
|
||||
# Composite gs_usb devices expose MI_00, MI_02, ... as
|
||||
# separate Windows paths although each path reports all
|
||||
# CAN channels. CANgaroo folds them into one device.
|
||||
key = re.sub(r"&mi_[0-9a-f]+", "", path.casefold())
|
||||
key = re.sub(r"&[0-9a-f]{4}(?=#\{)", "", key)
|
||||
if key not in physical_devices:
|
||||
physical_devices.add(key)
|
||||
result.extend(CandleChannel(path, channel)
|
||||
for channel in range(channels.value))
|
||||
self.dll.candle_dev_close(device)
|
||||
finally:
|
||||
self.dll.candle_dev_free(device)
|
||||
finally:
|
||||
self.dll.candle_list_free(listing)
|
||||
return result
|
||||
|
||||
def acquire(self, wanted_path: str) -> ct.c_void_p:
|
||||
listing = ct.c_void_p()
|
||||
if not self.dll.candle_list_scan(ct.byref(listing)):
|
||||
raise CandleError("Не удалось обновить список candle-адаптеров")
|
||||
try:
|
||||
count = ct.c_uint8()
|
||||
self.dll.candle_list_length(listing, ct.byref(count))
|
||||
for index in range(count.value):
|
||||
device = ct.c_void_p()
|
||||
if not self.dll.candle_dev_get(listing, index, ct.byref(device)):
|
||||
continue
|
||||
path = self.dll.candle_dev_get_path(device) or ""
|
||||
if path.casefold() == wanted_path.casefold():
|
||||
return device
|
||||
self.dll.candle_dev_free(device)
|
||||
finally:
|
||||
self.dll.candle_list_free(listing)
|
||||
raise CandleError("Выбранный candle-адаптер больше не подключён")
|
||||
|
||||
|
||||
class CandleAdapter(QObject):
|
||||
"""One opened classic-CAN channel with a background RX loop."""
|
||||
|
||||
frame_received = Signal(int, bytes)
|
||||
capture_received = Signal(int, bytes, bool, bool)
|
||||
connection_lost = Signal(str)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._api: _Api | None = None
|
||||
self._device: ct.c_void_p | None = None
|
||||
self._channel = 0
|
||||
self._stop = threading.Event()
|
||||
self._reader: threading.Thread | None = None
|
||||
self._send_lock = threading.Lock()
|
||||
self._listen_only = False
|
||||
|
||||
def _get_api(self) -> _Api:
|
||||
if self._api is None:
|
||||
self._api = _Api()
|
||||
return self._api
|
||||
|
||||
def scan(self) -> list[CandleChannel]:
|
||||
return self._get_api().scan()
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._device is not None
|
||||
|
||||
def connect_channel(self, channel: CandleChannel, bitrate: int,
|
||||
listen_only: bool = False) -> None:
|
||||
self.disconnect_channel()
|
||||
api = self._get_api()
|
||||
device = api.acquire(channel.path)
|
||||
try:
|
||||
if not api.dll.candle_dev_open(device):
|
||||
raise CandleError("Не удалось открыть candle-адаптер")
|
||||
if not api.dll.candle_channel_set_bitrate(device, channel.channel, bitrate):
|
||||
raise CandleError(f"Адаптер не поддерживает {bitrate} бит/с")
|
||||
mode = _MODE_LISTEN_ONLY if listen_only else 0
|
||||
if not api.dll.candle_channel_start(device, channel.channel, mode):
|
||||
raise CandleError("Не удалось запустить CAN-канал")
|
||||
except Exception:
|
||||
api.dll.candle_dev_close(device)
|
||||
api.dll.candle_dev_free(device)
|
||||
raise
|
||||
self._device = device
|
||||
self._channel = channel.channel
|
||||
self._listen_only = listen_only
|
||||
self._stop.clear()
|
||||
self._reader = threading.Thread(target=self._read_loop,
|
||||
name="candle-rx", daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
def disconnect_channel(self) -> None:
|
||||
device, self._device = self._device, None
|
||||
if device is None:
|
||||
return
|
||||
self._stop.set()
|
||||
if self._reader is not None:
|
||||
self._reader.join(timeout=0.3)
|
||||
api = self._get_api()
|
||||
api.dll.candle_channel_stop(device, self._channel)
|
||||
api.dll.candle_dev_close(device)
|
||||
api.dll.candle_dev_free(device)
|
||||
self._reader = None
|
||||
self._listen_only = False
|
||||
|
||||
def send(self, can_id: int, data: bytes) -> None:
|
||||
device = self._device
|
||||
if device is None:
|
||||
raise CandleError("Candle-адаптер не подключён")
|
||||
if self._listen_only:
|
||||
raise CandleError("Candle-адаптер открыт в режиме только приёма")
|
||||
if len(data) > 8:
|
||||
raise CandleError("Classic CAN поддерживает не более 8 байт")
|
||||
frame = _Frame()
|
||||
frame.can_id = (can_id & 0x1FFFFFFF) | _EXTENDED_ID
|
||||
frame.can_dlc = len(data)
|
||||
frame.channel = self._channel
|
||||
frame.data[:len(data)] = data
|
||||
with self._send_lock:
|
||||
if not self._get_api().dll.candle_frame_send(
|
||||
device, self._channel, ct.byref(frame)):
|
||||
raise CandleError("Candle API не передал CAN-кадр")
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
device = self._device
|
||||
if device is None:
|
||||
return
|
||||
api = self._get_api()
|
||||
while not self._stop.is_set() and self._device is device:
|
||||
frame = _Frame()
|
||||
if not api.dll.candle_frame_read(device, ct.byref(frame), 50):
|
||||
continue
|
||||
if (api.dll.candle_frame_type(ct.byref(frame)) == _FRAME_RECEIVE
|
||||
and frame.channel == self._channel):
|
||||
size = min(frame.can_dlc, 8)
|
||||
self.capture_received.emit(api.dll.candle_frame_id(ct.byref(frame)),
|
||||
bytes(frame.data[:size]), bool(frame.can_id & 0x80000000), bool(frame.can_id & 0x40000000))
|
||||
self.frame_received.emit(api.dll.candle_frame_id(ct.byref(frame)),
|
||||
bytes(frame.data[:size]))
|
||||
|
||||
def close(self) -> None:
|
||||
self.disconnect_channel()
|
||||
187
python/set_devices/qt_ports/mock_port.py
Normal file
187
python/set_devices/qt_ports/mock_port.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""@file mock_port.py
|
||||
@brief Qt-таймер, имитирующий подключённый контроллер без COM-порта.
|
||||
|
||||
Реализует mock-устройство для тестирования и разработки без физического
|
||||
оборудования. Использует детерминированные значения для надёжного тестирования.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .qt_compat import QElapsedTimer, QObject, QTimer, Signal
|
||||
|
||||
from set_devices.ds18b20 import MockSensorBus
|
||||
from set_devices.eeprom import CatalogEntry, MemoryInfo
|
||||
from set_devices.models import MockSignalSource, SignalCatalog
|
||||
from set_devices.panel import MockPanel
|
||||
|
||||
|
||||
class MockDevicePort(QObject):
|
||||
"""@brief Асинхронный mock-порт с интерфейсом реального порта.
|
||||
|
||||
Qt-таймер является платформенным планировщиком, а генерация значений
|
||||
делегируется переносимому ``MockSignalSource``.
|
||||
"""
|
||||
|
||||
connected_changed = Signal(bool, str)
|
||||
snapshot_received = Signal(object)
|
||||
sensors_received = Signal(object)
|
||||
memory_info_received = Signal(object)
|
||||
catalog_received = Signal(object)
|
||||
sensor_list_received = Signal(object)
|
||||
panel_received = Signal(object)
|
||||
tx_logged = Signal(str)
|
||||
rx_logged = Signal(str)
|
||||
error_occurred = Signal(str)
|
||||
|
||||
def __init__(self, catalog: SignalCatalog, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._source = MockSignalSource(catalog)
|
||||
self._bus = MockSensorBus()
|
||||
# Каталог mock-прибора: два датчика уже разложены по позициям одной
|
||||
# сборки (assembly_serial=7), третий остаётся без записи.
|
||||
self._catalog = [
|
||||
CatalogEntry(rom=self._bus.roms()[0], position=1, assembly_serial=7),
|
||||
CatalogEntry(rom=self._bus.roms()[2], position=258, assembly_serial=7),
|
||||
]
|
||||
self._panel = MockPanel(self._panel_rows)
|
||||
self._elapsed = QElapsedTimer()
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setInterval(250)
|
||||
self._timer.timeout.connect(self._publish_snapshot)
|
||||
|
||||
def open(self) -> None:
|
||||
"""@brief Запускает генерацию четырёх снимков в секунду.
|
||||
|
||||
Повторный вызов безопасен и не создаёт второй таймер.
|
||||
"""
|
||||
if self._timer.isActive():
|
||||
return
|
||||
self._elapsed.start()
|
||||
self._timer.start()
|
||||
self.connected_changed.emit(True, "MOCK")
|
||||
self.rx_logged.emit("Mock-контроллер подключён")
|
||||
self._publish_snapshot()
|
||||
|
||||
def close(self) -> None:
|
||||
"""@brief Останавливает генератор и публикует отключение.
|
||||
|
||||
Повторное закрытие не создаёт ложное событие изменения состояния.
|
||||
"""
|
||||
was_active = self._timer.isActive()
|
||||
self._timer.stop()
|
||||
if was_active:
|
||||
self.connected_changed.emit(False, "MOCK")
|
||||
|
||||
def scan_sensors(self) -> None:
|
||||
"""@brief Публикует список ROM mock-шины 1-Wire.
|
||||
|
||||
Поиск выполняется мгновенно: модель шины известна заранее.
|
||||
"""
|
||||
self.tx_logged.emit("SENSOR_SCAN mock")
|
||||
self.sensor_list_received.emit(self._bus.roms())
|
||||
self.publish_sensors()
|
||||
|
||||
def publish_sensors(self) -> None:
|
||||
"""@brief Публикует очередной набор измерений DS18B20."""
|
||||
self.sensors_received.emit(self._bus.snapshot(self._timestamp_s()))
|
||||
|
||||
def scan_memory(self) -> None:
|
||||
"""@brief Публикует состояние mock-памяти и каталога позиций."""
|
||||
self.tx_logged.emit("EEPROM_SCAN mock")
|
||||
self.memory_info_received.emit(
|
||||
MemoryInfo(
|
||||
present=True,
|
||||
persistent=True,
|
||||
count=len(self._catalog),
|
||||
capacity=32,
|
||||
i2c_address=0x50,
|
||||
page_size=64,
|
||||
size=32768,
|
||||
base_address=1024,
|
||||
bus_errors=0,
|
||||
blob_size=332,
|
||||
)
|
||||
)
|
||||
|
||||
def read_catalog(self) -> None:
|
||||
"""@brief Публикует записи каталога mock-прибора."""
|
||||
self.tx_logged.emit("EEPROM_READ mock")
|
||||
self.catalog_received.emit(list(self._catalog))
|
||||
|
||||
def set_user_bytes(self, rom: bytes, user_byte1: int, user_byte2: int) -> None:
|
||||
"""@brief Применяет пользовательские байты к mock-датчику.
|
||||
|
||||
@param rom Идентификатор датчика на mock-шине.
|
||||
@param user_byte1 Новое значение TH.
|
||||
@param user_byte2 Новое значение TL.
|
||||
"""
|
||||
try:
|
||||
self._bus.set_user_bytes(rom, user_byte1, user_byte2)
|
||||
except (KeyError, ValueError) as error:
|
||||
self.error_occurred.emit(str(error))
|
||||
return
|
||||
self.tx_logged.emit(f"SET_USER_BYTES mock {user_byte1:#04x} {user_byte2:#04x}")
|
||||
self.publish_sensors()
|
||||
|
||||
def set_resolution(self, rom: bytes, bits: int) -> None:
|
||||
"""@brief Меняет разрешение mock-датчика.
|
||||
|
||||
@param rom Идентификатор датчика на mock-шине.
|
||||
@param bits Разрешение 9..12 бит.
|
||||
"""
|
||||
try:
|
||||
self._bus.set_resolution(rom, bits)
|
||||
except (KeyError, ValueError) as error:
|
||||
self.error_occurred.emit(str(error))
|
||||
return
|
||||
self.tx_logged.emit(f"SET_RESOLUTION mock {bits} бит")
|
||||
self.publish_sensors()
|
||||
|
||||
def read_panel(self) -> None:
|
||||
"""@brief Публикует снимок экрана mock-панели."""
|
||||
self.panel_received.emit(self._panel.screen())
|
||||
|
||||
def press_panel_key(self, key: int, hold: bool = False) -> None:
|
||||
"""@brief Применяет нажатие кнопки к mock-панели.
|
||||
|
||||
@param key Код кнопки из PanelKey.
|
||||
@param hold Длинное нажатие.
|
||||
"""
|
||||
self._panel.press(key, hold)
|
||||
self.tx_logged.emit(f"UI_KEY mock {int(key)}{' hold' if hold else ''}")
|
||||
self.read_panel()
|
||||
|
||||
def _panel_rows(self) -> list[tuple[str, str]]:
|
||||
"""Строки экрана датчиков mock-панели: номер и температура."""
|
||||
rows: list[tuple[str, str]] = []
|
||||
for index, reading in enumerate(self._bus.snapshot(self._timestamp_s())):
|
||||
temperature = reading.temperature
|
||||
rows.append((
|
||||
f"{index + 1} {reading.serial}",
|
||||
"---" if temperature is None else f"{temperature:+.1f}",
|
||||
))
|
||||
return rows
|
||||
|
||||
def set_output(self, key: str, value: bool) -> None:
|
||||
"""@brief Применяет управляющую дискрету к mock-модели.
|
||||
|
||||
@param key Ключ разрешённого выхода.
|
||||
@param value Новое состояние; ошибка неизвестного ключа идёт в signal.
|
||||
"""
|
||||
try:
|
||||
self._source.set_output(key, value)
|
||||
except KeyError as error:
|
||||
self.error_occurred.emit(str(error))
|
||||
return
|
||||
self.tx_logged.emit(f"{key} = {int(value)}")
|
||||
self._publish_snapshot()
|
||||
|
||||
def _timestamp_s(self) -> float:
|
||||
"""Возвращает монотонное время mock-порта в секундах."""
|
||||
return self._elapsed.elapsed() / 1000.0
|
||||
|
||||
def _publish_snapshot(self) -> None:
|
||||
"""@brief Создаёт и публикует атомарный снимок вне UI-виджетов."""
|
||||
timestamp_s = self._timestamp_s()
|
||||
self.snapshot_received.emit(self._source.snapshot(timestamp_s))
|
||||
self.sensors_received.emit(self._bus.snapshot(timestamp_s))
|
||||
7
python/set_devices/qt_ports/qt_compat.py
Normal file
7
python/set_devices/qt_ports/qt_compat.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Qt transport dependencies; importing set_devices itself never loads Qt."""
|
||||
try:
|
||||
from PySide6.QtCore import QObject, Signal, QTimer, QElapsedTimer
|
||||
from PySide6.QtSerialPort import QSerialPort, QSerialPortInfo
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QObject, Signal, QTimer, QElapsedTimer
|
||||
from PySide2.QtSerialPort import QSerialPort, QSerialPortInfo
|
||||
212
python/set_devices/qt_ports/serial_port.py
Normal file
212
python/set_devices/qt_ports/serial_port.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""@file serial_port.py
|
||||
@brief Неблокирующий QSerialPort и потоковый parser GUI transport.
|
||||
|
||||
Реализует асинхронный интерфейс к последовательному порту с полностью
|
||||
неблокирующей обработкой данных и автоматическим разбором кадров протокола.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from set_devices.protocol_capture import uart_event
|
||||
|
||||
from .qt_compat import QObject, Signal
|
||||
from .qt_compat import QSerialPort, QSerialPortInfo
|
||||
|
||||
from set_devices.protocol import Frame, MessageType, build_frame
|
||||
from set_devices.protocol_router import ProtocolMode, ProtocolRouter
|
||||
from setprotocol.core import (
|
||||
Frame as SetFrame,
|
||||
FrameFlag as SetFrameFlag,
|
||||
MessageType as SetMessageType,
|
||||
build_frame as build_set_frame,
|
||||
)
|
||||
|
||||
|
||||
class SerialDevicePort(QObject):
|
||||
"""@brief Реальный COM-порт без блокирующих ожиданий в GUI thread.
|
||||
|
||||
Управляет связью с устройством через последовательный порт, обеспечивая
|
||||
асинхронный обмен данными и передачу кадров по протоколу GUI.
|
||||
"""
|
||||
|
||||
connected_changed = Signal(bool, str)
|
||||
frame_received = Signal(object)
|
||||
tx_logged = Signal(str)
|
||||
capture_event = Signal(object)
|
||||
rx_logged = Signal(str)
|
||||
error_occurred = Signal(str)
|
||||
protocol_changed = Signal(object)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._router = ProtocolRouter()
|
||||
self._sequence = 0
|
||||
self._serial.readyRead.connect(self._read_available)
|
||||
self._serial.errorOccurred.connect(self._handle_error)
|
||||
|
||||
@staticmethod
|
||||
def available_ports() -> list[str]:
|
||||
"""Возвращает стабильный отсортированный список имён COM-портов."""
|
||||
return [name for name, _description in SerialDevicePort.available_port_infos()]
|
||||
|
||||
@staticmethod
|
||||
def available_port_infos() -> list[tuple[str, str]]:
|
||||
"""@brief Перечисляет COM-порты вместе с описанием драйвера.
|
||||
|
||||
Порядок естественный: COM3 идёт перед COM10, поэтому список не
|
||||
перестраивается при подключении новых преобразователей.
|
||||
|
||||
@return Пары «имя порта, описание устройства».
|
||||
"""
|
||||
infos = [
|
||||
(info.portName(), info.description() or info.manufacturer())
|
||||
for info in QSerialPortInfo.availablePorts()
|
||||
]
|
||||
return sorted(infos, key=lambda item: SerialDevicePort._sort_key(item[0]))
|
||||
|
||||
@staticmethod
|
||||
def _sort_key(port_name: str) -> tuple[str, int, str]:
|
||||
"""Разделяет имя порта на буквенный префикс и числовой индекс."""
|
||||
digits = "".join(char for char in port_name if char.isdigit())
|
||||
prefix = port_name[: len(port_name) - len(digits)] if digits else port_name
|
||||
return (prefix.upper(), int(digits) if digits else 0, port_name)
|
||||
|
||||
def open(self, port_name: str, baud_rate: int) -> bool:
|
||||
"""Открывает 8N1 без flow control; результат сообщает синхронную ошибку."""
|
||||
if not port_name:
|
||||
self.error_occurred.emit("COM-порт не выбран")
|
||||
return False
|
||||
self.close()
|
||||
self._serial.setPortName(port_name)
|
||||
self._serial.setBaudRate(baud_rate)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
return False
|
||||
self.set_protocol_mode(ProtocolMode.AUTO)
|
||||
self.connected_changed.emit(True, port_name)
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
"""Признак открытого физического COM-порта."""
|
||||
return self._serial.isOpen()
|
||||
|
||||
@property
|
||||
def port_name(self) -> str:
|
||||
"""Имя текущего COM-порта, например ``COM11``."""
|
||||
return self._serial.portName()
|
||||
|
||||
@property
|
||||
def baud_rate(self) -> int:
|
||||
"""Фактически установленная скорость последовательного порта."""
|
||||
return self._serial.baudRate()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Закрывает порт и не оставляет активных Qt-операций чтения."""
|
||||
if not self._serial.isOpen():
|
||||
return
|
||||
name = self._serial.portName()
|
||||
self._serial.close()
|
||||
self.connected_changed.emit(False, name)
|
||||
|
||||
def send(self, frame: Frame) -> bool:
|
||||
"""Ставит целый кадр в буфер Qt без ожидания физической передачи."""
|
||||
if not self._serial.isOpen():
|
||||
self.error_occurred.emit("Последовательный порт не подключён")
|
||||
return False
|
||||
packet = build_frame(frame)
|
||||
written = self._serial.write(packet)
|
||||
if written > 0:
|
||||
self.capture_event.emit(uart_event('SET', 'TX', packet[:written], self.baud_rate))
|
||||
if written != len(packet):
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
return False
|
||||
self.tx_logged.emit(packet.hex(" ").upper())
|
||||
return True
|
||||
|
||||
def send_set(self, frame: SetFrame) -> bool:
|
||||
"""Передаёт SETProtocol v2 кадр, в том числе пробный кадр AUTO."""
|
||||
if not self._serial.isOpen():
|
||||
self.error_occurred.emit("Последовательный порт не подключён")
|
||||
return False
|
||||
if self.protocol_mode is ProtocolMode.GUI_V1:
|
||||
self.error_occurred.emit("Канал уже работает в режиме GUI protocol v1")
|
||||
return False
|
||||
packet = build_set_frame(frame)
|
||||
written = self._serial.write(packet)
|
||||
if written > 0:
|
||||
self.capture_event.emit(uart_event('SET', 'TX', packet[:written], self.baud_rate))
|
||||
if written != len(packet):
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
return False
|
||||
self.tx_logged.emit(packet.hex(" ").upper())
|
||||
return True
|
||||
|
||||
def next_sequence(self) -> int:
|
||||
"""Выделяет очередной sequence для адаптера транзакций."""
|
||||
self._sequence = (self._sequence + 1) & 0xFFFF
|
||||
return self._sequence
|
||||
|
||||
def send_message(self, message_type: MessageType, payload: bytes = b"") -> int | None:
|
||||
"""Передаёт запрос и возвращает его sequence либо ``None``."""
|
||||
sequence = self.next_sequence()
|
||||
return sequence if self.send(Frame(message_type, sequence, payload)) else None
|
||||
|
||||
def send_set_message(
|
||||
self,
|
||||
message_type: int | SetMessageType,
|
||||
payload: bytes = b"",
|
||||
*,
|
||||
flags: SetFrameFlag = SetFrameFlag.ACK_REQUIRED,
|
||||
destination: int = 0,
|
||||
) -> int | None:
|
||||
"""Передаёт запрос SETProtocol v2 и возвращает sequence."""
|
||||
sequence = self.next_sequence()
|
||||
frame = SetFrame(
|
||||
message_type,
|
||||
sequence,
|
||||
payload,
|
||||
flags=flags,
|
||||
destination=destination,
|
||||
)
|
||||
return sequence if self.send_set(frame) else None
|
||||
|
||||
@property
|
||||
def protocol_mode(self) -> ProtocolMode:
|
||||
return self._router.mode
|
||||
|
||||
def set_protocol_mode(self, mode: ProtocolMode) -> None:
|
||||
"""Сбрасывает parser-ы и выбирает режим текущего соединения."""
|
||||
changed = self._router.mode is not mode
|
||||
self._router.select(mode)
|
||||
if changed or mode is ProtocolMode.AUTO:
|
||||
self.protocol_changed.emit(mode)
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Отправляет совместимый PING с новым 16-битным sequence."""
|
||||
return self.send_message(MessageType.PING) is not None
|
||||
|
||||
def _read_available(self) -> None:
|
||||
"""Выгружает доступный фрагмент и передаёт parser-у без блокировки."""
|
||||
data = bytes(self._serial.readAll())
|
||||
if not data:
|
||||
return
|
||||
self.rx_logged.emit(data.hex(" ").upper())
|
||||
self.capture_event.emit(uart_event('SET', 'RX', data, self.baud_rate))
|
||||
previous_mode = self._router.mode
|
||||
frames = self._router.feed(data)
|
||||
if self._router.mode is not previous_mode:
|
||||
self.protocol_changed.emit(self._router.mode)
|
||||
for frame in frames:
|
||||
self.frame_received.emit(frame)
|
||||
|
||||
def _handle_error(self, error: QSerialPort.SerialPortError) -> None:
|
||||
"""Публикует только реальные ошибки, игнорируя NoError."""
|
||||
if error == QSerialPort.SerialPortError.NoError:
|
||||
return
|
||||
self.error_occurred.emit(self._serial.errorString())
|
||||
168
python/set_devices/qt_ports/slcan_adapter.py
Normal file
168
python/set_devices/qt_ports/slcan_adapter.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Serial Line CAN (Lawicel/SLCAN) transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .qt_compat import QObject, Signal
|
||||
from .qt_compat import QSerialPort, QSerialPortInfo
|
||||
|
||||
|
||||
class SlcanError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SlcanChannel:
|
||||
path: str
|
||||
channel: int = 0
|
||||
description: str = ""
|
||||
manufacturer: str = ""
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
details = self.description or self.manufacturer or "COM-порт"
|
||||
return f"{self.path} — {details}"
|
||||
|
||||
@property
|
||||
def adapter_kind(self) -> str:
|
||||
return "slcan"
|
||||
|
||||
|
||||
_BITRATE_COMMANDS = {
|
||||
10_000: "S0", 20_000: "S1", 50_000: "S2", 100_000: "S3",
|
||||
125_000: "S4", 250_000: "S5", 500_000: "S6", 800_000: "S7",
|
||||
1_000_000: "S8",
|
||||
}
|
||||
|
||||
|
||||
def encode_frame(can_id: int, data: bytes, *, extended: bool | None = None) -> bytes:
|
||||
"""Encode one classic-CAN frame in Lawicel ASCII format."""
|
||||
if len(data) > 8:
|
||||
raise SlcanError("Classic CAN поддерживает не более 8 байт")
|
||||
if not 0 <= can_id <= 0x1FFFFFFF:
|
||||
raise SlcanError("CAN ID вне диапазона")
|
||||
if extended is None:
|
||||
extended = can_id > 0x7FF
|
||||
if not extended:
|
||||
if can_id > 0x7FF:
|
||||
raise SlcanError("Стандартный CAN ID вне диапазона")
|
||||
return f"t{can_id:03X}{len(data):X}{data.hex().upper()}\r".encode("ascii")
|
||||
return f"T{can_id:08X}{len(data):X}{data.hex().upper()}\r".encode("ascii")
|
||||
|
||||
|
||||
def decode_frame(line: bytes) -> tuple[int, bytes] | None:
|
||||
"""Decode a received SLCAN data frame; ignore ACK/status/RTR records."""
|
||||
if not line or line[:1] not in (b"t", b"T"):
|
||||
return None
|
||||
extended = line[:1] == b"T"
|
||||
id_size = 8 if extended else 3
|
||||
try:
|
||||
can_id = int(line[1:1 + id_size], 16)
|
||||
size = int(line[1 + id_size:2 + id_size], 16)
|
||||
start = 2 + id_size
|
||||
payload = bytes.fromhex(line[start:start + size * 2].decode("ascii"))
|
||||
except (ValueError, UnicodeError):
|
||||
return None
|
||||
maximum = 0x1FFFFFFF if extended else 0x7FF
|
||||
if can_id > maximum or size > 8 or len(payload) != size:
|
||||
return None
|
||||
return can_id, payload
|
||||
|
||||
|
||||
class SlcanAdapter(QObject):
|
||||
"""One SLCAN COM port using the usual 115200 8N1 host connection."""
|
||||
|
||||
frame_received = Signal(int, bytes)
|
||||
capture_received = Signal(int, bytes, bool, bool)
|
||||
connection_lost = Signal(str)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._serial.readyRead.connect(self._read)
|
||||
self._serial.errorOccurred.connect(self._error)
|
||||
self._buffer = bytearray()
|
||||
self._listen_only = False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._serial.isOpen()
|
||||
|
||||
def scan(self) -> list[SlcanChannel]:
|
||||
# Windows не предоставляет надёжного признака протокола SLCAN.
|
||||
# Показываем COM-кандидаты только внутри явно выбранного режима SLCAN.
|
||||
return [SlcanChannel(info.portName(), description=info.description(),
|
||||
manufacturer=info.manufacturer())
|
||||
for info in QSerialPortInfo.availablePorts()]
|
||||
|
||||
def connect_channel(self, channel: SlcanChannel, bitrate: int,
|
||||
host_baudrate: int = 115200,
|
||||
listen_only: bool = False) -> None:
|
||||
self.disconnect_channel()
|
||||
command = _BITRATE_COMMANDS.get(bitrate)
|
||||
if command is None:
|
||||
raise SlcanError(f"SLCAN не поддерживает {bitrate} бит/с")
|
||||
self._serial.setPortName(channel.path)
|
||||
if host_baudrate not in (9600, 19200, 38400, 57600, 115200,
|
||||
230400, 460800, 921600):
|
||||
raise SlcanError(f"Недопустимая скорость COM: {host_baudrate}")
|
||||
self._serial.setBaudRate(host_baudrate)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
raise SlcanError(f"Не удалось открыть {channel.path}: {self._serial.errorString()}")
|
||||
self._buffer.clear()
|
||||
self._listen_only = listen_only
|
||||
# Close first so reconnecting also works after an interrupted session.
|
||||
open_command = "L" if listen_only else "O"
|
||||
if self._serial.write(f"C\r{command}\r{open_command}\r".encode("ascii")) < 0:
|
||||
message = self._serial.errorString()
|
||||
self._serial.close()
|
||||
self._listen_only = False
|
||||
raise SlcanError(f"Не удалось настроить SLCAN: {message}")
|
||||
|
||||
def disconnect_channel(self) -> None:
|
||||
if not self._serial.isOpen():
|
||||
return
|
||||
self._serial.write(b"C\r")
|
||||
self._serial.waitForBytesWritten(100)
|
||||
self._serial.close()
|
||||
self._buffer.clear()
|
||||
self._listen_only = False
|
||||
|
||||
def send(self, can_id: int, data: bytes) -> None:
|
||||
if not self.connected:
|
||||
raise SlcanError("SLCAN-адаптер не подключён")
|
||||
if self._listen_only:
|
||||
raise SlcanError("SLCAN-адаптер открыт в режиме только приёма")
|
||||
# The adapter contract is used by ProtoCAN, whose IDs are always EXT,
|
||||
# including the numerically small values that would also fit in 11 bits.
|
||||
packet = encode_frame(can_id, data, extended=True)
|
||||
if self._serial.write(packet) != len(packet):
|
||||
raise SlcanError(f"SLCAN не передал CAN-кадр: {self._serial.errorString()}")
|
||||
|
||||
def _read(self) -> None:
|
||||
self._buffer.extend(bytes(self._serial.readAll()))
|
||||
while b"\r" in self._buffer:
|
||||
raw, _, remainder = self._buffer.partition(b"\r")
|
||||
self._buffer[:] = remainder
|
||||
frame = decode_frame(raw)
|
||||
if frame is not None:
|
||||
self.capture_received.emit(frame[0], frame[1], raw[:1] == b'T', False)
|
||||
self.frame_received.emit(*frame)
|
||||
|
||||
def _error(self, error: QSerialPort.SerialPortError) -> None:
|
||||
if error in (QSerialPort.SerialPortError.NoError,
|
||||
QSerialPort.SerialPortError.NotOpenError):
|
||||
return
|
||||
if self._serial.isOpen():
|
||||
message = self._serial.errorString()
|
||||
self._serial.close()
|
||||
self._listen_only = False
|
||||
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}")
|
||||
|
||||
def close(self) -> None:
|
||||
self.disconnect_channel()
|
||||
177
python/set_devices/qt_ports/stm32_bootloader.py
Normal file
177
python/set_devices/qt_ports/stm32_bootloader.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Non-blocking client for the STM32 ROM UART bootloader described by AN3155."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from typing import Callable
|
||||
|
||||
from .qt_compat import QObject, QTimer, Signal
|
||||
from .qt_compat import QSerialPort
|
||||
|
||||
from set_devices.firmware import FirmwareImage
|
||||
from protocan.stm32_boot import (
|
||||
ACK, NACK, SYNC, address, command, erase_pages_payload, write_payload,
|
||||
)
|
||||
|
||||
|
||||
class Stm32Bootloader(QObject):
|
||||
"""Programs STM32 system Flash over the factory UART bootloader (8E1)."""
|
||||
|
||||
progress = Signal(int, str)
|
||||
finished = Signal(bool, str)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._serial.readyRead.connect(self._read_available)
|
||||
self._serial.errorOccurred.connect(self._serial_error)
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(lambda: self._fail("Нет ответа STM32 bootloader"))
|
||||
self._rx = bytearray()
|
||||
self._steps: deque[tuple[bytes, int, str, Callable[[], None]]] = deque()
|
||||
self._waiting: tuple[int, str, Callable[[], None]] | None = None
|
||||
self._image: FirmwareImage | None = None
|
||||
self._offset = 0
|
||||
self._cancelled = False
|
||||
|
||||
def start(self, image: FirmwareImage, port_name: str, baud_rate: int) -> None:
|
||||
if self._serial.isOpen() or self._waiting is not None:
|
||||
self.finished.emit(False, "Прошивка STM32 уже выполняется")
|
||||
return
|
||||
if not port_name or baud_rate <= 0:
|
||||
self.finished.emit(False, "Неверные настройки UART")
|
||||
return
|
||||
if not 0x08000000 <= image.base_address <= 0x080FFFFF:
|
||||
self.finished.emit(False, "Для STM32 укажите адрес Flash, например 0x08000000")
|
||||
return
|
||||
if image.base_address + len(image.data) > 0x08100000:
|
||||
self.finished.emit(False, "Образ выходит за допустимый диапазон Flash STM32")
|
||||
return
|
||||
self._serial.setPortName(port_name)
|
||||
self._serial.setBaudRate(baud_rate)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.EvenParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self.finished.emit(False, f"Не удалось открыть {port_name}: {self._serial.errorString()}")
|
||||
return
|
||||
self._image = image
|
||||
self._offset = 0
|
||||
self._cancelled = False
|
||||
self._rx.clear()
|
||||
self._steps.clear()
|
||||
self.progress.emit(0, "Синхронизация с STM32 (BOOT0=1)")
|
||||
self._queue(SYNC, 1500, "синхронизации", self._erase)
|
||||
self._next()
|
||||
|
||||
def cancel(self) -> None:
|
||||
if not self._serial.isOpen():
|
||||
return
|
||||
self._cancelled = True
|
||||
self._finish(False, "Операция отменена")
|
||||
|
||||
def _queue(self, packet: bytes, timeout_ms: int, stage: str,
|
||||
callback: Callable[[], None]) -> None:
|
||||
self._steps.append((packet, timeout_ms, stage, callback))
|
||||
|
||||
def _next(self) -> None:
|
||||
if self._waiting is not None or not self._steps or self._cancelled:
|
||||
return
|
||||
packet, timeout_ms, stage, callback = self._steps.popleft()
|
||||
if self._serial.write(packet) != len(packet):
|
||||
self._fail(f"Ошибка передачи на этапе {stage}")
|
||||
return
|
||||
self._waiting = (timeout_ms, stage, callback)
|
||||
self._timer.start(timeout_ms)
|
||||
|
||||
def _erase(self) -> None:
|
||||
self.progress.emit(1, "Стирание Flash")
|
||||
self._queue(command(0x43), 1500, "команды Erase", self._erase_payload)
|
||||
self._next()
|
||||
|
||||
def _erase_payload(self) -> None:
|
||||
assert self._image is not None
|
||||
first = (self._image.base_address - 0x08000000) // 1024
|
||||
last = (self._image.base_address + len(self._image.data) - 1 - 0x08000000) // 1024
|
||||
pages = list(range(first, last + 1))
|
||||
self._queue(erase_pages_payload(pages), 20000, "стирания страниц Flash",
|
||||
self._write_next)
|
||||
self._next()
|
||||
|
||||
def _write_next(self) -> None:
|
||||
image = self._image
|
||||
if image is None:
|
||||
return
|
||||
if self._offset >= len(image.data):
|
||||
self.progress.emit(100, "Запуск приложения")
|
||||
self._queue(command(0x21), 1500, "команды Go", self._go_address)
|
||||
self._next()
|
||||
return
|
||||
chunk = image.data[self._offset:self._offset + 256]
|
||||
# STM32 Flash is programmed by words; erased bytes safely pad the tail.
|
||||
if len(chunk) & 3:
|
||||
chunk += b"\xff" * (4 - (len(chunk) & 3))
|
||||
self._queue(command(0x31), 1500, "команды Write Memory", self._write_address)
|
||||
self._next()
|
||||
|
||||
def _write_address(self) -> None:
|
||||
assert self._image is not None
|
||||
self._queue(address(self._image.base_address + self._offset), 1500,
|
||||
"адреса блока", self._write_data)
|
||||
self._next()
|
||||
|
||||
def _write_data(self) -> None:
|
||||
assert self._image is not None
|
||||
chunk = self._image.data[self._offset:self._offset + 256]
|
||||
actual = len(chunk)
|
||||
if len(chunk) & 3:
|
||||
chunk += b"\xff" * (4 - (len(chunk) & 3))
|
||||
|
||||
def accepted() -> None:
|
||||
self._offset += actual
|
||||
percent = int(self._offset * 100 / len(self._image.data))
|
||||
self.progress.emit(percent, f"Записано {self._offset} байт")
|
||||
self._write_next()
|
||||
|
||||
self._queue(write_payload(chunk), 2500, "записи блока", accepted)
|
||||
self._next()
|
||||
|
||||
def _go_address(self) -> None:
|
||||
assert self._image is not None
|
||||
self._queue(address(self._image.base_address), 1500, "адреса запуска",
|
||||
lambda: self._finish(True, "Прошивка STM32 завершена, приложение запущено"))
|
||||
self._next()
|
||||
|
||||
def _read_available(self) -> None:
|
||||
self._rx.extend(bytes(self._serial.readAll()))
|
||||
while self._waiting is not None and self._rx:
|
||||
response = self._rx.pop(0)
|
||||
timeout_ms, stage, callback = self._waiting
|
||||
if response not in (ACK, NACK):
|
||||
continue
|
||||
self._timer.stop()
|
||||
self._waiting = None
|
||||
if response == NACK:
|
||||
self._fail(f"STM32 отклонил операцию на этапе {stage}")
|
||||
return
|
||||
callback()
|
||||
|
||||
def _serial_error(self, error: QSerialPort.SerialPortError) -> None:
|
||||
if error not in (QSerialPort.SerialPortError.NoError,
|
||||
QSerialPort.SerialPortError.TimeoutError):
|
||||
self._fail(self._serial.errorString())
|
||||
|
||||
def _fail(self, message: str) -> None:
|
||||
self._finish(False, message)
|
||||
|
||||
def _finish(self, success: bool, message: str) -> None:
|
||||
self._timer.stop()
|
||||
self._waiting = None
|
||||
self._steps.clear()
|
||||
self._image = None
|
||||
if self._serial.isOpen():
|
||||
self._serial.close()
|
||||
self.progress.emit(100 if success else 0, message)
|
||||
self.finished.emit(success, message)
|
||||
170
python/set_devices/qt_ports/stm_settings.py
Normal file
170
python/set_devices/qt_ports/stm_settings.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Transactional STM configuration extension (FC03/FC06, 0x1210)."""
|
||||
import struct
|
||||
|
||||
from .qt_compat import QObject, QTimer, Signal
|
||||
from .qt_compat import QSerialPort
|
||||
from set_devices.tms_terminal import crc16_modbus
|
||||
|
||||
BAUDRATES = (9600, 19200, 38400, 57600, 115200)
|
||||
|
||||
|
||||
def request(unit, function, register, value):
|
||||
body = struct.pack(">BBHH", unit, function, register, value)
|
||||
return body + struct.pack("<H", crc16_modbus(body))
|
||||
|
||||
|
||||
def decode_config(raw):
|
||||
if len(raw) != 25:
|
||||
raise ValueError("Нужна прошивка STM с выбором режима эмуляции")
|
||||
words = struct.unpack(">10H", raw[3:-2])
|
||||
if words[:2] != (0x5343, 2):
|
||||
raise ValueError("Прошивка STM не поддерживает настройку связи (нужна новая версия)")
|
||||
address, tms, rate = words[2:5]
|
||||
if not 1 <= address <= 247 or not 1 <= tms <= 255 or address == tms or rate >= len(BAUDRATES) or words[8] > 1 or words[9] > 1:
|
||||
raise ValueError("STM вернула неверные параметры связи")
|
||||
return words
|
||||
|
||||
|
||||
class StmSettingsClient(QObject):
|
||||
finished = Signal(bool, str, object)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.serial = QSerialPort(self)
|
||||
self.serial.readyRead.connect(self._receive)
|
||||
self.serial.errorOccurred.connect(self._error)
|
||||
self.timeout = QTimer(self)
|
||||
self.timeout.setSingleShot(True)
|
||||
self.timeout.timeout.connect(lambda: self._finish(False, "Тайм-аут настройки STM"))
|
||||
self.delay = QTimer(self)
|
||||
self.delay.setSingleShot(True)
|
||||
self.delay.timeout.connect(self._next)
|
||||
self.active = False
|
||||
self.committed = False
|
||||
self.rx = bytearray()
|
||||
|
||||
def start(self, port, unit, baud, timeout, desired=None):
|
||||
if self.active:
|
||||
return
|
||||
if desired is not None:
|
||||
addr, tms, rate, mode = desired
|
||||
if not 1 <= addr <= 247 or not 1 <= tms <= 255 or addr == tms or rate not in BAUDRATES or mode not in (0, 1):
|
||||
self.finished.emit(False, "Адреса УМП и 2812 должны различаться; проверьте диапазоны и скорость", None)
|
||||
return
|
||||
self.unit, self.baud, self.timeout_ms = unit, baud, timeout
|
||||
self.desired = desired
|
||||
self.active, self.committed = True, False
|
||||
self.stage = "probe"
|
||||
self.serial.setPortName(port)
|
||||
self.serial.setBaudRate(baud)
|
||||
self.serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self.serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self.serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self.serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self.serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self._finish(False, self.serial.errorString())
|
||||
return
|
||||
self._send(3, 0x1210, 10)
|
||||
|
||||
def _send(self, function, register, value):
|
||||
self.rx.clear()
|
||||
self.packet = request(self.unit, function, register, value)
|
||||
self.timeout.start(self.timeout_ms)
|
||||
if self.stage == "commit":
|
||||
# If ACK is lost, the board may nevertheless switch settings.
|
||||
self.committed = True
|
||||
if self.serial.write(self.packet) != len(self.packet):
|
||||
self._finish(False, "Ошибка передачи настройки STM")
|
||||
|
||||
def _receive(self):
|
||||
chunk = bytes(self.serial.readAll())
|
||||
if not self.active or not self.timeout.isActive():
|
||||
return
|
||||
self.rx.extend(chunk)
|
||||
function = self.packet[1]
|
||||
while len(self.rx) >= 2:
|
||||
if self.rx[0] != self.unit or self.rx[1] not in (function, function | 0x80):
|
||||
del self.rx[0]
|
||||
continue
|
||||
size = 5 if self.rx[1] & 0x80 else (25 if function == 3 else 8)
|
||||
if len(self.rx) < size:
|
||||
return
|
||||
raw = bytes(self.rx[:size])
|
||||
del self.rx[:size]
|
||||
if crc16_modbus(raw):
|
||||
self._finish(False, "Неверная CRC ответа STM")
|
||||
return
|
||||
if raw[1] & 0x80:
|
||||
self._finish(False, "STM отклонила настройку, код %d. Для старой прошивки требуется обновление." % raw[2])
|
||||
return
|
||||
if (function == 6 and raw != self.packet) or (function == 3 and raw[2] != 20):
|
||||
self._finish(False, "Неверный ответ настройки STM")
|
||||
return
|
||||
self.timeout.stop()
|
||||
try:
|
||||
self._accept(raw)
|
||||
except ValueError as error:
|
||||
self._finish(False, str(error))
|
||||
return
|
||||
|
||||
def _accept(self, raw):
|
||||
if self.stage == "probe":
|
||||
words = decode_config(raw)
|
||||
if self.desired is None:
|
||||
self._finish(True, "Настройки STM прочитаны", (words[2], words[3], BAUDRATES[words[4]], words[8]))
|
||||
return
|
||||
addr, tms, baud, mode = self.desired
|
||||
self.staged = (addr, tms, BAUDRATES.index(baud), mode)
|
||||
self.steps = [(6, 0x1218, 0), (6, 0x1215, addr), (6, 0x1216, tms),
|
||||
(6, 0x1217, self.staged[2]), (6, 0x1219, mode), (3, 0x1210, 10)]
|
||||
self.stage = "stage"
|
||||
elif self.stage == "stage" and self.packet[1] == 3:
|
||||
words = decode_config(raw)
|
||||
if (*words[5:8], words[9]) != self.staged:
|
||||
raise ValueError("STM не подтвердила подготовленные настройки")
|
||||
self.stage = "commit"
|
||||
self.steps = [(6, 0x1218, 0xA55A)]
|
||||
elif self.stage == "commit":
|
||||
self.stage = "switch"
|
||||
self.delay.start(700)
|
||||
return
|
||||
elif self.stage == "verify":
|
||||
words = decode_config(raw)
|
||||
if (*words[2:5], words[8]) != self.staged:
|
||||
raise ValueError("Новые настройки STM не подтвердились")
|
||||
self._finish(True, "Настройки применены в STM и проверены. Действуют до перезагрузки платы.", self.desired)
|
||||
return
|
||||
self.delay.start(20)
|
||||
|
||||
def _next(self):
|
||||
if not self.active:
|
||||
return
|
||||
if self.stage == "switch":
|
||||
self.unit, _tms, baud, _mode = self.desired
|
||||
if not self.serial.setBaudRate(baud):
|
||||
self._finish(False, "Не удалось переключить скорость COM")
|
||||
return
|
||||
self.stage = "verify"
|
||||
self._send(3, 0x1210, 10)
|
||||
else:
|
||||
self._send(*self.steps.pop(0))
|
||||
|
||||
def _error(self, error):
|
||||
if self.active and error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError):
|
||||
self._finish(False, self.serial.errorString())
|
||||
|
||||
def cancel(self):
|
||||
if self.active:
|
||||
self._finish(False, "Настройка прервана")
|
||||
|
||||
def _finish(self, ok, message, values=None):
|
||||
if not self.active:
|
||||
return
|
||||
self.active = False
|
||||
self.timeout.stop()
|
||||
self.delay.stop()
|
||||
if self.serial.isOpen():
|
||||
self.serial.close()
|
||||
if not ok and self.committed:
|
||||
message += " Возможно, STM уже применила новые значения: адрес УМП %d, адрес 2812 %d, %d бод, режим %d (0=УМП, 1=2812). Повторите чтение по ним или перезагрузите плату." % self.desired
|
||||
self.finished.emit(ok, message, values)
|
||||
137
python/set_devices/qt_ports/tms_bootloader.py
Normal file
137
python/set_devices/qt_ports/tms_bootloader.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Non-blocking port of Gui_Android's INITLOAD / LOAD / TFLASH workflow."""
|
||||
from .qt_compat import QObject, QTimer, Signal
|
||||
from .qt_compat import QSerialPort
|
||||
|
||||
from protocan import tms_firmware as wire
|
||||
|
||||
|
||||
class TmsBootloader(QObject):
|
||||
progress = Signal(int, str)
|
||||
finished = Signal(bool, str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._serial = QSerialPort(self)
|
||||
self._serial.readyRead.connect(self._read)
|
||||
self._serial.errorOccurred.connect(self._error)
|
||||
self._timeout = QTimer(self)
|
||||
self._timeout.setSingleShot(True)
|
||||
self._timeout.timeout.connect(lambda: self._finish(False, f"Тайм-аут {self._stage}"))
|
||||
self._settle = QTimer(self)
|
||||
self._settle.setSingleShot(True)
|
||||
self._settle.setInterval(100)
|
||||
self._settle.timeout.connect(self._accept)
|
||||
self._delay = QTimer(self)
|
||||
self._delay.setSingleShot(True)
|
||||
self._delay.setInterval(100)
|
||||
self._delay.timeout.connect(self._advance)
|
||||
self._active = False
|
||||
self._waiting = False
|
||||
self._rx = bytearray()
|
||||
self._stage = ""
|
||||
|
||||
def start(self, image, port: str, baud: int, target: wire.TmsTarget):
|
||||
if self._active:
|
||||
return
|
||||
try:
|
||||
target.validate(len(image.data))
|
||||
if not port or baud <= 0:
|
||||
raise ValueError("Неверные настройки COM-порта")
|
||||
except ValueError as error:
|
||||
self.finished.emit(False, str(error))
|
||||
return
|
||||
self._image, self._target = image, target
|
||||
self._active = True
|
||||
self._serial.setPortName(port)
|
||||
self._serial.setBaudRate(baud)
|
||||
self._serial.setDataBits(QSerialPort.DataBits.Data8)
|
||||
self._serial.setParity(QSerialPort.Parity.NoParity)
|
||||
self._serial.setStopBits(QSerialPort.StopBits.OneStop)
|
||||
self._serial.setFlowControl(QSerialPort.FlowControl.NoFlowControl)
|
||||
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
|
||||
self._finish(False, self._serial.errorString())
|
||||
return
|
||||
self._response = None
|
||||
self._steps = wire.programming_steps(image.data, target)
|
||||
self._advance()
|
||||
|
||||
def _advance(self):
|
||||
if not self._active:
|
||||
return
|
||||
try:
|
||||
request, self._size, timeout, self._stage, percent = self._steps.send(self._response)
|
||||
except StopIteration:
|
||||
self._finish(True, "Образ загружен в RAM TMS" if self._target.load_only
|
||||
else "Запись Spartan-6 подтверждена платой" if self._target.kind == "spartan6"
|
||||
else "Прошивка завершена, записанные данные проверены")
|
||||
return
|
||||
except (ValueError, RuntimeError) as error:
|
||||
self._finish(False, str(error))
|
||||
return
|
||||
self._command = request[1]
|
||||
self._rx.clear()
|
||||
self._waiting = True
|
||||
self.progress.emit(percent, self._stage)
|
||||
# LOAD is one continuous write, without pauses inside its packet.
|
||||
if self._serial.write(request) != len(request):
|
||||
self._finish(False, "Ошибка передачи " + self._stage)
|
||||
return
|
||||
if self._active:
|
||||
self._timeout.start(timeout + int(len(request) * 10000 / self._serial.baudRate()))
|
||||
|
||||
def _read(self):
|
||||
chunk = bytes(self._serial.readAll())
|
||||
if not self._waiting:
|
||||
return
|
||||
self._rx.extend(chunk)
|
||||
header = bytes((self._target.controller, self._command))
|
||||
start = self._rx.find(header)
|
||||
if start < 0:
|
||||
self._rx[:] = self._rx[-1:]
|
||||
return
|
||||
del self._rx[:start]
|
||||
# Some devices send an ACK before an UPLOAD data response.
|
||||
if self._size > 6:
|
||||
for ack_size in (6, 4):
|
||||
if (self._rx[ack_size:ack_size + 2] == header
|
||||
and wire.normalize_reply(bytes(self._rx[:ack_size]), *header, 6)):
|
||||
del self._rx[:ack_size]
|
||||
break
|
||||
if len(self._rx) >= self._size:
|
||||
self._accept()
|
||||
else:
|
||||
self._settle.start()
|
||||
|
||||
def _accept(self):
|
||||
if not self._waiting:
|
||||
return
|
||||
reply = wire.normalize_reply(bytes(self._rx[:self._size]), self._target.controller, self._command, self._size)
|
||||
if reply is None:
|
||||
if len(self._rx) >= self._size:
|
||||
self._finish(False, "Неверный CRC ответа " + self._stage)
|
||||
return
|
||||
self._timeout.stop()
|
||||
self._settle.stop()
|
||||
self._waiting = False
|
||||
self._response = reply
|
||||
self._delay.start()
|
||||
|
||||
def cancel(self):
|
||||
if self._active:
|
||||
self._finish(False, "Операция TMS отменена; запись Flash могла быть начата")
|
||||
|
||||
def _error(self, error):
|
||||
if self._active and error not in (QSerialPort.SerialPortError.NoError, QSerialPort.SerialPortError.TimeoutError):
|
||||
self._finish(False, self._serial.errorString())
|
||||
|
||||
def _finish(self, success, message):
|
||||
if not self._active:
|
||||
return
|
||||
self._active = self._waiting = False
|
||||
for timer in (self._timeout, self._settle, self._delay):
|
||||
timer.stop()
|
||||
if self._serial.isOpen():
|
||||
self._serial.close()
|
||||
if success:
|
||||
self.progress.emit(100, message)
|
||||
self.finished.emit(success, message)
|
||||
69
python/set_devices/qt_ports/ump_can_client.py
Normal file
69
python/set_devices/qt_ports/ump_can_client.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Последовательный клиент CAN-логгера поверх уже подключённого Dima-адаптера."""
|
||||
import secrets
|
||||
from .qt_compat import QObject, QTimer, Signal
|
||||
from set_devices.ump_logger_can import Response, build_request
|
||||
|
||||
|
||||
class UmpCanClient(QObject):
|
||||
completed = Signal(str, object)
|
||||
failed = Signal(str)
|
||||
|
||||
def __init__(self, send, parent=None):
|
||||
super().__init__(parent)
|
||||
self._send = send
|
||||
self._token = secrets.randbelow(255) + 1
|
||||
self._pending = None
|
||||
self._operation = ''
|
||||
self._timer = QTimer(self)
|
||||
self._timer.setSingleShot(True)
|
||||
self._timer.timeout.connect(self._timeout)
|
||||
|
||||
@property
|
||||
def busy(self):
|
||||
return self._pending is not None
|
||||
|
||||
def cancel(self):
|
||||
# Поздний ответ после переключения транспорта не должен продолжить
|
||||
# старую цепочку и отправить команду в новое подключение.
|
||||
self._timer.stop()
|
||||
self._pending = None
|
||||
self._operation = ''
|
||||
|
||||
def request(self, operation, mode, function, address, value, timeout_ms=2000):
|
||||
if self.busy:
|
||||
self.failed.emit('CAN-логгер занят предыдущим запросом')
|
||||
return
|
||||
self._token = self._token % 255 + 1
|
||||
try:
|
||||
can_id, data = build_request(mode, self._token, function, address, value)
|
||||
self._pending = Response(mode, self._token, function, address, value)
|
||||
self._operation = operation
|
||||
self._timer.start(timeout_ms)
|
||||
self._send(can_id, data)
|
||||
except (ValueError, RuntimeError, OSError) as error:
|
||||
self.cancel()
|
||||
self.failed.emit(str(error))
|
||||
|
||||
def receive(self, can_id, data):
|
||||
if self._pending is None:
|
||||
return
|
||||
try:
|
||||
words = self._pending.feed(can_id, data)
|
||||
except ValueError as error:
|
||||
self.cancel()
|
||||
self.failed.emit(str(error))
|
||||
return
|
||||
if words is None:
|
||||
return
|
||||
operation = self._operation
|
||||
self.cancel()
|
||||
self.completed.emit(operation, words)
|
||||
|
||||
def _timeout(self):
|
||||
writing = self._pending is not None and self._pending.function == 6
|
||||
self.cancel()
|
||||
text = ('Таймаут CAN-логгера: проверьте адаптер, скорость, номер платы '
|
||||
'и прошивку с поддержкой CAN-логгера.')
|
||||
if writing:
|
||||
text += ' Подтверждение записи потеряно; команда могла выполниться.'
|
||||
self.failed.emit(text)
|
||||
18
python/set_devices/shared_protocol.py
Normal file
18
python/set_devices/shared_protocol.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Optional native SETProtocol backend, configured by SETPROTOCOL_LIBRARY."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
def _load():
|
||||
try:
|
||||
from protocan.native import NativeProtocol
|
||||
return NativeProtocol()
|
||||
except (ImportError, OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
SHARED_PROTOCOL = _load()
|
||||
|
||||
|
||||
def get_shared_protocol():
|
||||
"""Return SETProtocol, or None for source/test fallback builds."""
|
||||
return SHARED_PROTOCOL
|
||||
119
python/set_devices/spectrum_demo.py
Normal file
119
python/set_devices/spectrum_demo.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""@file spectrum_demo.py
|
||||
@brief Демонстрационный источник спектра для проверки вкладки без прибора.
|
||||
|
||||
Повторяет то, что передаёт прошивка: основной тон, несколько гармоник
|
||||
и шумовая подложка. Нужен, чтобы вкладку можно было открыть и убедиться
|
||||
в правильности отрисовки, не имея под рукой ни платы, ни датчика.
|
||||
|
||||
Источник намеренно живёт рядом с разбором потока, а не внутри вкладки:
|
||||
он выдаёт те же самые строки, что и прибор, и проходит через тот же
|
||||
разбор. Иначе демонстрация проверяла бы отрисовку, но не формат.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
from set_devices.spectrum_stream import (
|
||||
Harmonic,
|
||||
HarmonicsFrame,
|
||||
build_harmonics_line,
|
||||
build_spectrum_line,
|
||||
)
|
||||
|
||||
#: Число элементов спектра в демонстрационном кадре.
|
||||
DEMO_BINS = 128
|
||||
|
||||
#: Частота выборки демонстрационного источника, Гц.
|
||||
DEMO_SAMPLE_RATE = 20000
|
||||
|
||||
#: Размер преобразования, которому соответствует спектр.
|
||||
DEMO_FFT_SIZE = 512
|
||||
|
||||
#: Верхняя граница шкалы уровней, дБ относительно милливольта.
|
||||
DEMO_TOP_DBMV = 60.0
|
||||
|
||||
#: Размах шкалы уровней, дБ.
|
||||
DEMO_RANGE_DB = 60.0
|
||||
|
||||
|
||||
class SpectrumDemoSource:
|
||||
"""@brief Выдаёт строки спектра и гармоник, как это делал бы прибор."""
|
||||
|
||||
def __init__(self, seed: int = 1) -> None:
|
||||
self._seq = 0
|
||||
self._random = random.Random(seed)
|
||||
self._phase = 0.0
|
||||
|
||||
def next_lines(self) -> str:
|
||||
"""@brief Формирует очередную пару строк.
|
||||
|
||||
@return Строка спектра и строка гармоник, склеенные подряд.
|
||||
|
||||
Основной тон медленно плывёт по частоте, чтобы на экране было
|
||||
видно движение и стало заметно, если отрисовка где-то подвисает.
|
||||
"""
|
||||
self._seq += 1
|
||||
self._phase += 0.05
|
||||
|
||||
fundamental_hz = 1000.0 + 200.0 * math.sin(self._phase)
|
||||
fundamental_mv = 400.0
|
||||
bin_width = (DEMO_SAMPLE_RATE / 2.0) / DEMO_BINS
|
||||
|
||||
# Уровни: шумовая подложка около 3 мВ плюс узкие пики на основном
|
||||
# тоне и его гармониках.
|
||||
amplitudes = [self._random.uniform(1.5, 4.0) for _ in range(DEMO_BINS)]
|
||||
|
||||
harmonics: list[Harmonic] = []
|
||||
for order in range(1, 5):
|
||||
frequency = fundamental_hz * order
|
||||
if frequency >= DEMO_SAMPLE_RATE / 2.0:
|
||||
break
|
||||
|
||||
amplitude = fundamental_mv / (order * order)
|
||||
index = int(round(frequency / bin_width))
|
||||
if 0 <= index < DEMO_BINS:
|
||||
amplitudes[index] = amplitude
|
||||
# Соседние элементы приподняты: у реального окна энергия
|
||||
# тона всегда занимает несколько элементов, и совсем узкий
|
||||
# пик выглядел бы неправдоподобно.
|
||||
for neighbour in (index - 1, index + 1):
|
||||
if 0 <= neighbour < DEMO_BINS:
|
||||
amplitudes[neighbour] = max(
|
||||
amplitudes[neighbour], amplitude * 0.45
|
||||
)
|
||||
|
||||
if order >= 2:
|
||||
harmonics.append(
|
||||
Harmonic(
|
||||
index=order,
|
||||
frequency_hz=frequency,
|
||||
amplitude_mv=amplitude,
|
||||
relative_db=20.0 * math.log10(amplitude / fundamental_mv),
|
||||
)
|
||||
)
|
||||
|
||||
levels = [20.0 * math.log10(max(value, 1e-3)) for value in amplitudes]
|
||||
|
||||
spectrum = build_spectrum_line(
|
||||
seq=self._seq,
|
||||
sample_rate_hz=DEMO_SAMPLE_RATE,
|
||||
fft_size=DEMO_FFT_SIZE,
|
||||
levels_db=levels,
|
||||
top_dbmv=DEMO_TOP_DBMV,
|
||||
range_db=DEMO_RANGE_DB,
|
||||
)
|
||||
|
||||
total = math.sqrt(sum(h.amplitude_mv**2 for h in harmonics))
|
||||
frame = HarmonicsFrame(
|
||||
seq=self._seq,
|
||||
fundamental_hz=fundamental_hz,
|
||||
fundamental_mv=fundamental_mv,
|
||||
thd_percent=100.0 * total / fundamental_mv,
|
||||
snr_db=42.0,
|
||||
rms_mv=fundamental_mv / math.sqrt(2.0),
|
||||
harmonics=tuple(harmonics),
|
||||
)
|
||||
|
||||
return spectrum + build_harmonics_line(frame)
|
||||
420
python/set_devices/spectrum_stream.py
Normal file
420
python/set_devices/spectrum_stream.py
Normal file
@@ -0,0 +1,420 @@
|
||||
"""@file spectrum_stream.py
|
||||
@brief Разбор потока спектра и гармоник от прибора на STM32.
|
||||
|
||||
Прибор снимает сигнал вибродатчика или микрофона, считает быстрое
|
||||
преобразование Фурье прямо на месте и передаёт готовый результат: сам спектр
|
||||
и параметры найденных гармоник. Задача этого модуля — превратить поток байтов
|
||||
в структуры, с которыми работает вкладка.
|
||||
|
||||
Модуль не зависит от Qt и от последовательного порта: на вход подаются байты,
|
||||
на выход идут кадры. Так его можно проверить тестами без прибора и без окна.
|
||||
|
||||
Формат строки повторяет привычный по навигационным приборам вид: доллар,
|
||||
поля через запятую, звёздочка и контрольная сумма::
|
||||
|
||||
$SPEC,<seq>,<fs>,<fft>,<bins>,<top>,<range>,<hex>*<cs>
|
||||
$HARM,<seq>,<f0>,<a0>,<thd>,<snr>,<rms>,<n>[,<f>,<a>,<db>]...*<cs>
|
||||
|
||||
Текстовый формат выбран намеренно, хотя двоичный вышел бы компактнее.
|
||||
Прибор отлаживается через обычный терминал, и возможность увидеть поток
|
||||
глазами дороже экономии канала. Спектр при этом ужат до одного байта
|
||||
на элемент, поэтому кадр укладывается примерно в 290 байт и на скорости
|
||||
115200 бод спокойно идёт пять раз в секунду.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
#: Начальный символ строки.
|
||||
LINE_START = "$"
|
||||
|
||||
#: Разделитель контрольной суммы.
|
||||
CHECKSUM_MARK = "*"
|
||||
|
||||
#: Тип строки со спектром.
|
||||
TAG_SPECTRUM = "SPEC"
|
||||
|
||||
#: Тип строки с гармониками.
|
||||
TAG_HARMONICS = "HARM"
|
||||
|
||||
#: Наибольшее число гармоник в кадре; совпадает с пределом прошивки.
|
||||
MAX_HARMONICS = 8
|
||||
|
||||
#: Наибольшая длина строки. Защита от мусора в канале: без неё обрыв связи
|
||||
#: посреди передачи привёл бы к бесконечному накоплению в буфере.
|
||||
MAX_LINE_LENGTH = 4096
|
||||
|
||||
|
||||
def checksum(payload: str) -> int:
|
||||
"""@brief Считает контрольную сумму строки.
|
||||
|
||||
@param payload Содержимое между ``$`` и ``*``.
|
||||
@return Побайтовое исключающее ИЛИ, 0..255.
|
||||
|
||||
Сумма именно такая простая не от небрежности: канал короткий и
|
||||
проводной, а задача суммы здесь — отсеять строку, склеенную из двух
|
||||
обрывков, а не защитить от искажения одного бита.
|
||||
"""
|
||||
value = 0
|
||||
for char in payload.encode("ascii", errors="replace"):
|
||||
value ^= char
|
||||
return value & 0xFF
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Harmonic:
|
||||
"""@brief Одна найденная гармоника."""
|
||||
|
||||
index: int
|
||||
"""Номер гармоники: 2 — вторая, 3 — третья и так далее."""
|
||||
|
||||
frequency_hz: float
|
||||
"""Измеренная частота."""
|
||||
|
||||
amplitude_mv: float
|
||||
"""Амплитуда."""
|
||||
|
||||
relative_db: float
|
||||
"""Уровень относительно основного тона, децибелы. Всегда отрицателен
|
||||
у исправного тракта: гармоника слабее основного тона."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HarmonicsFrame:
|
||||
"""@brief Параметры основного тона и его гармоник."""
|
||||
|
||||
seq: int
|
||||
fundamental_hz: float
|
||||
fundamental_mv: float
|
||||
thd_percent: float
|
||||
"""Коэффициент гармонических искажений."""
|
||||
snr_db: float
|
||||
rms_mv: float
|
||||
harmonics: tuple[Harmonic, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectrumFrame:
|
||||
"""@brief Спектр целиком."""
|
||||
|
||||
seq: int
|
||||
sample_rate_hz: int
|
||||
fft_size: int
|
||||
top_dbmv: float
|
||||
"""Верхняя граница шкалы уровней, децибелы относительно милливольта."""
|
||||
range_db: float
|
||||
"""Полный размах шкалы уровней."""
|
||||
levels_db: tuple[float, ...] = field(default=())
|
||||
"""Уровни элементов спектра в децибелах относительно милливольта."""
|
||||
|
||||
@property
|
||||
def bin_width_hz(self) -> float:
|
||||
"""@brief Шаг по частоте между элементами переданного спектра.
|
||||
|
||||
Учитывает, что прибор мог проредить спектр перед передачей: делится
|
||||
не на размер преобразования, а на фактическое число элементов.
|
||||
"""
|
||||
if not self.levels_db:
|
||||
return 0.0
|
||||
top_frequency = self.sample_rate_hz / 2.0
|
||||
return top_frequency / len(self.levels_db)
|
||||
|
||||
def frequency_of(self, index: int) -> float:
|
||||
"""@brief Частота, соответствующая элементу спектра.
|
||||
|
||||
@param index Номер элемента.
|
||||
@return Частота в герцах.
|
||||
"""
|
||||
return index * self.bin_width_hz
|
||||
|
||||
|
||||
class SpectrumStreamError(Exception):
|
||||
"""@brief Строка не разобрана. Несёт причину для журнала."""
|
||||
|
||||
|
||||
def _split_line(line: str) -> tuple[str, list[str]]:
|
||||
"""@brief Проверяет обрамление и контрольную сумму, возвращает поля.
|
||||
|
||||
@param line Одна строка без перевода строки.
|
||||
@return Кортеж «тип строки, список полей».
|
||||
@throws SpectrumStreamError При нарушении формата или суммы.
|
||||
"""
|
||||
if not line.startswith(LINE_START):
|
||||
raise SpectrumStreamError("строка не начинается с '$'")
|
||||
|
||||
star = line.rfind(CHECKSUM_MARK)
|
||||
if star < 0:
|
||||
raise SpectrumStreamError("нет контрольной суммы")
|
||||
|
||||
payload = line[1:star]
|
||||
tail = line[star + 1 :].strip()
|
||||
|
||||
if len(tail) != 2:
|
||||
raise SpectrumStreamError("контрольная сумма не из двух знаков")
|
||||
|
||||
try:
|
||||
expected = int(tail, 16)
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError("контрольная сумма не шестнадцатеричная") from exc
|
||||
|
||||
actual = checksum(payload)
|
||||
if actual != expected:
|
||||
raise SpectrumStreamError(
|
||||
f"контрольная сумма не сошлась: получено {expected:02X}, "
|
||||
f"посчитано {actual:02X}"
|
||||
)
|
||||
|
||||
fields = payload.split(",")
|
||||
if not fields:
|
||||
raise SpectrumStreamError("пустая строка")
|
||||
|
||||
return fields[0], fields[1:]
|
||||
|
||||
|
||||
def _decode_levels(hex_data: str, top_dbmv: float, range_db: float) -> tuple[float, ...]:
|
||||
"""@brief Разворачивает упакованный спектр в уровни.
|
||||
|
||||
@param hex_data Элементы спектра, по два знака на элемент.
|
||||
@param top_dbmv Верхняя граница шкалы.
|
||||
@param range_db Размах шкалы.
|
||||
@return Уровни в децибелах.
|
||||
|
||||
Прибор передаёт каждый элемент одним байтом: ноль означает нижнюю
|
||||
границу шкалы, 255 — верхнюю. Такое сжатие теряет доли децибела,
|
||||
что для картины спектра несущественно, зато уменьшает кадр вчетверо
|
||||
относительно передачи чисел текстом.
|
||||
"""
|
||||
if len(hex_data) % 2 != 0:
|
||||
raise SpectrumStreamError("нечётное число знаков в данных спектра")
|
||||
|
||||
bottom = top_dbmv - range_db
|
||||
levels: list[float] = []
|
||||
|
||||
for pos in range(0, len(hex_data), 2):
|
||||
chunk = hex_data[pos : pos + 2]
|
||||
try:
|
||||
raw = int(chunk, 16)
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError(f"неверный знак в данных: '{chunk}'") from exc
|
||||
levels.append(bottom + (raw / 255.0) * range_db)
|
||||
|
||||
return tuple(levels)
|
||||
|
||||
|
||||
def parse_line(line: str) -> SpectrumFrame | HarmonicsFrame:
|
||||
"""@brief Разбирает одну строку.
|
||||
|
||||
@param line Строка без перевода строки.
|
||||
@return Кадр спектра или кадр гармоник.
|
||||
@throws SpectrumStreamError При любой ошибке разбора.
|
||||
"""
|
||||
tag, fields = _split_line(line)
|
||||
|
||||
if tag == TAG_SPECTRUM:
|
||||
if len(fields) != 7:
|
||||
raise SpectrumStreamError(
|
||||
f"в строке спектра {len(fields)} полей вместо 7"
|
||||
)
|
||||
try:
|
||||
seq = int(fields[0])
|
||||
sample_rate = int(fields[1])
|
||||
fft_size = int(fields[2])
|
||||
bins = int(fields[3])
|
||||
top = float(fields[4])
|
||||
span = float(fields[5])
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError("нечисловое поле в строке спектра") from exc
|
||||
|
||||
if span <= 0.0:
|
||||
raise SpectrumStreamError("размах шкалы должен быть положительным")
|
||||
|
||||
levels = _decode_levels(fields[6], top, span)
|
||||
|
||||
# Заявленное число элементов обязано совпасть с переданным: иначе
|
||||
# шкала частот сдвинется, и оператор увидит тон не на своей частоте.
|
||||
if len(levels) != bins:
|
||||
raise SpectrumStreamError(
|
||||
f"заявлено {bins} элементов, передано {len(levels)}"
|
||||
)
|
||||
|
||||
return SpectrumFrame(
|
||||
seq=seq,
|
||||
sample_rate_hz=sample_rate,
|
||||
fft_size=fft_size,
|
||||
top_dbmv=top,
|
||||
range_db=span,
|
||||
levels_db=levels,
|
||||
)
|
||||
|
||||
if tag == TAG_HARMONICS:
|
||||
if len(fields) < 6:
|
||||
raise SpectrumStreamError("слишком короткая строка гармоник")
|
||||
try:
|
||||
seq = int(fields[0])
|
||||
fundamental_hz = float(fields[1])
|
||||
fundamental_mv = float(fields[2])
|
||||
thd = float(fields[3])
|
||||
snr = float(fields[4])
|
||||
rms = float(fields[5])
|
||||
count = int(fields[6]) if len(fields) > 6 else 0
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError("нечисловое поле в строке гармоник") from exc
|
||||
|
||||
if count < 0 or count > MAX_HARMONICS:
|
||||
raise SpectrumStreamError(f"недопустимое число гармоник: {count}")
|
||||
|
||||
expected_fields = 7 + count * 3
|
||||
if len(fields) != expected_fields:
|
||||
raise SpectrumStreamError(
|
||||
f"для {count} гармоник нужно {expected_fields} полей, "
|
||||
f"получено {len(fields)}"
|
||||
)
|
||||
|
||||
harmonics: list[Harmonic] = []
|
||||
for item in range(count):
|
||||
base = 7 + item * 3
|
||||
try:
|
||||
harmonics.append(
|
||||
Harmonic(
|
||||
index=item + 2,
|
||||
frequency_hz=float(fields[base]),
|
||||
amplitude_mv=float(fields[base + 1]),
|
||||
relative_db=float(fields[base + 2]),
|
||||
)
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise SpectrumStreamError(
|
||||
f"нечисловое поле у гармоники {item + 2}"
|
||||
) from exc
|
||||
|
||||
return HarmonicsFrame(
|
||||
seq=seq,
|
||||
fundamental_hz=fundamental_hz,
|
||||
fundamental_mv=fundamental_mv,
|
||||
thd_percent=thd,
|
||||
snr_db=snr,
|
||||
rms_mv=rms,
|
||||
harmonics=tuple(harmonics),
|
||||
)
|
||||
|
||||
raise SpectrumStreamError(f"неизвестный тип строки: '{tag}'")
|
||||
|
||||
|
||||
class SpectrumStream:
|
||||
"""@brief Накопитель байтов, отдающий разобранные кадры.
|
||||
|
||||
Данные из порта приходят кусками произвольного размера: строка может
|
||||
прийти по частям, а в одном куске может оказаться несколько строк.
|
||||
Накопитель собирает их обратно.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._buffer = ""
|
||||
self.parsed = 0
|
||||
"""Число успешно разобранных строк."""
|
||||
self.errors = 0
|
||||
"""Число отброшенных строк."""
|
||||
self.last_error: str = ""
|
||||
"""Причина последней ошибки — для журнала вкладки."""
|
||||
|
||||
def reset(self) -> None:
|
||||
"""@brief Сбрасывает накопитель и счётчики."""
|
||||
self._buffer = ""
|
||||
self.parsed = 0
|
||||
self.errors = 0
|
||||
self.last_error = ""
|
||||
|
||||
def feed(self, chunk: str | bytes) -> list[SpectrumFrame | HarmonicsFrame]:
|
||||
"""@brief Добавляет очередной кусок потока.
|
||||
|
||||
@param chunk Данные из порта.
|
||||
@return Список кадров, разобранных из накопленного.
|
||||
|
||||
Ошибочные строки отбрасываются, но не прерывают разбор: одиночная
|
||||
помеха в канале не должна ронять поток целиком.
|
||||
"""
|
||||
if isinstance(chunk, (bytes, bytearray)):
|
||||
chunk = chunk.decode("ascii", errors="replace")
|
||||
|
||||
self._buffer += chunk
|
||||
|
||||
# Защита от мусора без переводов строки: иначе буфер рос бы
|
||||
# неограниченно и приложение съело бы всю память.
|
||||
if len(self._buffer) > MAX_LINE_LENGTH:
|
||||
cut = self._buffer.rfind("\n")
|
||||
self._buffer = self._buffer[cut + 1 :] if cut >= 0 else ""
|
||||
self.errors += 1
|
||||
self.last_error = "строка длиннее допустимой, буфер очищен"
|
||||
|
||||
frames: list[SpectrumFrame | HarmonicsFrame] = []
|
||||
|
||||
while "\n" in self._buffer:
|
||||
line, _, self._buffer = self._buffer.partition("\n")
|
||||
line = line.strip("\r\n \t")
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
frames.append(parse_line(line))
|
||||
self.parsed += 1
|
||||
except SpectrumStreamError as exc:
|
||||
self.errors += 1
|
||||
self.last_error = str(exc)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def build_spectrum_line(
|
||||
seq: int,
|
||||
sample_rate_hz: int,
|
||||
fft_size: int,
|
||||
levels_db: list[float] | tuple[float, ...],
|
||||
top_dbmv: float,
|
||||
range_db: float,
|
||||
) -> str:
|
||||
"""@brief Собирает строку спектра.
|
||||
|
||||
@return Строка с переводом строки в конце.
|
||||
|
||||
Нужна для тестов и для демонстрационного источника, но главное — она
|
||||
задаёт формат в одном месте с разбором. Если формат изменят только
|
||||
в прошивке, круговой тест это заметит.
|
||||
"""
|
||||
bottom = top_dbmv - range_db
|
||||
parts: list[str] = []
|
||||
|
||||
for level in levels_db:
|
||||
ratio = (level - bottom) / range_db if range_db > 0.0 else 0.0
|
||||
raw = int(round(max(0.0, min(1.0, ratio)) * 255.0))
|
||||
parts.append(f"{raw:02X}")
|
||||
|
||||
payload = (
|
||||
f"{TAG_SPECTRUM},{seq},{sample_rate_hz},{fft_size},"
|
||||
f"{len(parts)},{top_dbmv:.1f},{range_db:.1f},{''.join(parts)}"
|
||||
)
|
||||
return f"{LINE_START}{payload}{CHECKSUM_MARK}{checksum(payload):02X}\n"
|
||||
|
||||
|
||||
def build_harmonics_line(frame: HarmonicsFrame) -> str:
|
||||
"""@brief Собирает строку гармоник.
|
||||
|
||||
@param frame Данные для передачи.
|
||||
@return Строка с переводом строки в конце.
|
||||
"""
|
||||
parts = [
|
||||
TAG_HARMONICS,
|
||||
str(frame.seq),
|
||||
f"{frame.fundamental_hz:.1f}",
|
||||
f"{frame.fundamental_mv:.1f}",
|
||||
f"{frame.thd_percent:.2f}",
|
||||
f"{frame.snr_db:.1f}",
|
||||
f"{frame.rms_mv:.1f}",
|
||||
str(len(frame.harmonics)),
|
||||
]
|
||||
for harmonic in frame.harmonics:
|
||||
parts.append(f"{harmonic.frequency_hz:.1f}")
|
||||
parts.append(f"{harmonic.amplitude_mv:.1f}")
|
||||
parts.append(f"{harmonic.relative_db:.1f}")
|
||||
|
||||
payload = ",".join(parts)
|
||||
return f"{LINE_START}{payload}{CHECKSUM_MARK}{checksum(payload):02X}\n"
|
||||
115
python/set_devices/tms_flash.py
Normal file
115
python/set_devices/tms_flash.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Legacy BALZAM/TMS parallel-memory dump protocol.
|
||||
|
||||
The TMS320F2812 addresses external XINTF memory in 16-bit words, while
|
||||
``CMD_UPLOAD`` transfers a byte count. This module keeps those units explicit
|
||||
so a complete dump cannot silently become half or twice the requested size.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from protocan import tms2812 as shared_tms2812
|
||||
|
||||
CMD_UPLOAD = 52
|
||||
DEFAULT_CONTROLLER_ADDRESS = 5
|
||||
PARALLEL_NVM_START_WORD = 0x100000
|
||||
PARALLEL_NVM_WORDS = 0x80000
|
||||
PARALLEL_NVM_BYTES = PARALLEL_NVM_WORDS * 2
|
||||
MT29F1G08_WORDS = 0x4000000
|
||||
MT29F1G08_BYTES = MT29F1G08_WORDS * 2
|
||||
DEFAULT_CHUNK_BYTES = 4096
|
||||
NAND_CHUNK_BYTES = 2048
|
||||
UPLOAD_TRAILER_BYTES = 6 # CRC16 + four legacy padding bytes
|
||||
|
||||
|
||||
class TmsFlashError(ValueError):
|
||||
"""Malformed command, reply, or dump range."""
|
||||
|
||||
|
||||
def build_upload_request(address: int, word_address: int, byte_count: int) -> bytes:
|
||||
"""Build the 12-byte legacy ``CMD_UPLOAD`` request."""
|
||||
if not 0 <= address <= 0xFF:
|
||||
raise TmsFlashError("адрес контроллера вне диапазона 0..255")
|
||||
if not 0 <= word_address <= 0xFFFFFFFF:
|
||||
raise TmsFlashError("адрес слова вне диапазона uint32")
|
||||
if not 1 <= byte_count <= 0xFFFFFFFF:
|
||||
raise TmsFlashError("размер блока вне диапазона 1..0xFFFFFFFF")
|
||||
try:
|
||||
return shared_tms2812.build_upload_request(
|
||||
address, word_address, byte_count
|
||||
)
|
||||
except ValueError as error:
|
||||
raise TmsFlashError(str(error)) from error
|
||||
|
||||
|
||||
def decode_upload_reply(raw: bytes, address: int, byte_count: int) -> bytes:
|
||||
"""Validate one complete upload reply and return its memory bytes."""
|
||||
expected = 2 + byte_count + UPLOAD_TRAILER_BYTES
|
||||
if len(raw) != expected:
|
||||
raise TmsFlashError(
|
||||
f"ответ CMD_UPLOAD: ожидалось {expected} байт, получено {len(raw)}"
|
||||
)
|
||||
try:
|
||||
return shared_tms2812.decode_upload_reply(raw, address, byte_count)
|
||||
except ValueError as error:
|
||||
raise TmsFlashError(str(error)) from error
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DumpChunk:
|
||||
"""One request in a word-addressed, byte-transferred dump."""
|
||||
|
||||
word_address: int
|
||||
byte_count: int
|
||||
|
||||
|
||||
class FlashDumpPlan:
|
||||
"""Deterministic chunk plan used by the GUI and host tests."""
|
||||
|
||||
def __init__(self, start_word: int, word_count: int, chunk_bytes: int) -> None:
|
||||
if start_word < 0 or start_word > 0xFFFFFFFF:
|
||||
raise TmsFlashError("начальный адрес слова вне диапазона uint32")
|
||||
if word_count <= 0:
|
||||
raise TmsFlashError("число слов должно быть положительным")
|
||||
if start_word + word_count > 0x100000000:
|
||||
raise TmsFlashError("конец диапазона выходит за uint32")
|
||||
if chunk_bytes < 2 or chunk_bytes > 0xFFFF or chunk_bytes % 2:
|
||||
raise TmsFlashError("блок должен быть чётным числом байт 2..65534")
|
||||
self.start_word = start_word
|
||||
self.word_count = word_count
|
||||
self.chunk_bytes = chunk_bytes
|
||||
self.total_bytes = word_count * 2
|
||||
|
||||
def chunks(self) -> list[DumpChunk]:
|
||||
result = []
|
||||
offset = 0
|
||||
while offset < self.total_bytes:
|
||||
size = min(self.chunk_bytes, self.total_bytes - offset)
|
||||
result.append(DumpChunk(self.start_word + offset // 2, size))
|
||||
offset += size
|
||||
return result
|
||||
|
||||
|
||||
class UploadReplyBuffer:
|
||||
"""Collect arbitrary serial fragments for exactly one upload reply."""
|
||||
|
||||
def __init__(self, address: int, byte_count: int) -> None:
|
||||
self.address = address
|
||||
self.byte_count = byte_count
|
||||
self.expected_size = 2 + byte_count + UPLOAD_TRAILER_BYTES
|
||||
self._buffer = bytearray()
|
||||
|
||||
@property
|
||||
def received_size(self) -> int:
|
||||
return len(self._buffer)
|
||||
|
||||
def feed(self, data: bytes) -> tuple[bytes | None, bytes]:
|
||||
"""Return ``(payload, trailing_data)`` when the reply is complete."""
|
||||
self._buffer.extend(data)
|
||||
if len(self._buffer) < self.expected_size:
|
||||
return None, b""
|
||||
raw = bytes(self._buffer[: self.expected_size])
|
||||
trailing = bytes(self._buffer[self.expected_size :])
|
||||
self._buffer.clear()
|
||||
return decode_upload_reply(raw, self.address, self.byte_count), trailing
|
||||
358
python/set_devices/tms_terminal.py
Normal file
358
python/set_devices/tms_terminal.py
Normal file
@@ -0,0 +1,358 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Посылка ПЧ -> терминал прошивки ТМС (``TMS_TO_TERMINAL``).
|
||||
|
||||
Это третий формат в проекте: не протокол SETGUI (``A5 5A``) из ``protocol.py``
|
||||
и не транспорт моста (``AA 55``) из ``can_transport.py``, а ответ контроллера
|
||||
преобразователя частоты на TMS320F281x, который он шлёт по RS485 в терминал.
|
||||
|
||||
Раскладка структуры (tempFold/message2.h, tempFold/RS_Functions.h)::
|
||||
|
||||
head Address, Number 2 байта
|
||||
digit_data ANS_DIGIT_DATA_TO_TERMINAL N байт
|
||||
analog_data TMS_ANALOG_DATA, каждое lo, hi 2*M байт
|
||||
crc_lo, crc_hi 2 байта
|
||||
add_byte 1 байт
|
||||
|
||||
CRC считается по всей посылке кроме последних трёх байт (``sizeof - 3``),
|
||||
начальное значение 0xFFFF, кладётся младшим байтом вперёд.
|
||||
|
||||
Числа N и M у разных сборок прошивки разные: в ``RS_Functions.h`` это 28 и 30,
|
||||
в ``message2.h`` того же прибора — уже 38 и 34 (в исходнике видно ``//add``
|
||||
перед byte29). Поэтому раскладка здесь — параметр, а не константа.
|
||||
|
||||
Байтов синхронизации в формате нет — прошивка полагается на паузу в линии.
|
||||
Поэтому разборщик ищет границу посылки перебором смещений по CRC, а не по
|
||||
сигнатуре; заодно это гасит лишний байт, который ``RS_Send`` шлёт сверх
|
||||
структуры (``sizeof(TMS_TO_TERMINAL)+1``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
#: Head (2) + CRC (2) + add_byte (1): всё, что не данные.
|
||||
FRAME_OVERHEAD = 5
|
||||
#: CRC не покрывает crc_lo, crc_hi и add_byte.
|
||||
CRC_TAIL = 3
|
||||
|
||||
#: Границы настройки в GUI: меньше одного поля смысла нет, больше не бывает.
|
||||
MIN_DIGIT_BYTES = 1
|
||||
MAX_DIGIT_BYTES = 64
|
||||
MIN_ANALOG_COUNT = 1
|
||||
MAX_ANALOG_COUNT = 64
|
||||
|
||||
#: Номера команд, enum в конце RS_Functions.h.
|
||||
COMMAND_NAMES: Dict[int, str] = {
|
||||
51: "CMD_LOAD",
|
||||
52: "CMD_UPLOAD",
|
||||
53: "CMD_RUN",
|
||||
54: "CMD_XFLASH",
|
||||
55: "CMD_TFLASH",
|
||||
56: "CMD_PEEK",
|
||||
57: "CMD_POKE",
|
||||
58: "CMD_INITLOAD",
|
||||
59: "CMD_INIT",
|
||||
60: "CMD_EXTEND",
|
||||
61: "CMD_VECTOR",
|
||||
62: "CMD_IMPULSE",
|
||||
65: "CMD_STD",
|
||||
66: "CMD_TEST_ALL",
|
||||
67: "CMD_STD_ANS",
|
||||
}
|
||||
|
||||
#: Назначение аналоговых величин по комментариям в message2.c (ReceiveCommand).
|
||||
ANALOG_NAMES = [
|
||||
"FREQ_OUT (Гц*100)",
|
||||
"FREQ_PWM",
|
||||
"K_MOD_INV_1",
|
||||
"K_MOD_INV_2",
|
||||
"SENS_ROT0",
|
||||
"SENS_ROT1",
|
||||
"U_ZPT_1+",
|
||||
"U_ZPT_1-",
|
||||
"I_OUT_INV1",
|
||||
"I_OUT_INV2",
|
||||
"F_ROT",
|
||||
"DIR_ROT",
|
||||
"U_ZPT_2+",
|
||||
"U_ZPT_2-",
|
||||
"SENS_ROT_2",
|
||||
"SENS_ROT_3",
|
||||
"УКСС8 рег. 30",
|
||||
"УКСС8 рег. 31",
|
||||
"TORQ",
|
||||
"log13",
|
||||
"log17",
|
||||
"УКСС3 рег. 40",
|
||||
"УКСС3 рег. 42",
|
||||
"УКСС2 рег. 40 (BTR)",
|
||||
"УКСС2 рег. 42 (BTR)",
|
||||
"TEMP_GED (Uin УМП1)",
|
||||
"TEMP_BEARING (Uin УМП2)",
|
||||
"POWER",
|
||||
"Uenter_1",
|
||||
"Uenter_2",
|
||||
"УМП1 задание 4-20",
|
||||
"УМП1 обратная связь 4-20",
|
||||
"УМП2 задание 4-20",
|
||||
"УМП2 обратная связь 4-20",
|
||||
]
|
||||
|
||||
|
||||
def analog_name(index: int) -> str:
|
||||
"""Имя аналоговой величины по индексу с нуля; за таблицей — просто номер."""
|
||||
if index < len(ANALOG_NAMES):
|
||||
return ANALOG_NAMES[index]
|
||||
return "аналог %d" % (index + 1)
|
||||
|
||||
|
||||
def crc16_modbus(data: bytes, crc: int = 0xFFFF) -> int:
|
||||
"""CRC-16/MODBUS: poly 0xA001 (рефлексия 0x8005), init 0xFFFF."""
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
||||
return crc & 0xFFFF
|
||||
|
||||
|
||||
def crc16_ccitt_false(data: bytes, crc: int = 0xFFFF) -> int:
|
||||
"""CRC-16/CCITT-FALSE: poly 0x1021, init 0xFFFF, без рефлексии."""
|
||||
for byte in data:
|
||||
crc ^= byte << 8
|
||||
for _ in range(8):
|
||||
crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF
|
||||
return crc
|
||||
|
||||
|
||||
#: Самого ``crc16.h`` прошивки под рукой нет, поэтому вариант выбирает оператор.
|
||||
CRC_VARIANTS: Dict[str, Callable[[bytes], int]] = {
|
||||
"MODBUS": crc16_modbus,
|
||||
"CCITT-FALSE": crc16_ccitt_false,
|
||||
}
|
||||
DEFAULT_CRC = "MODBUS"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Layout:
|
||||
"""@brief Раскладка посылки: сколько байт дискретных величин и аналогов."""
|
||||
|
||||
digit_bytes: int
|
||||
analog_count: int
|
||||
crc_name: str = DEFAULT_CRC
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not MIN_DIGIT_BYTES <= self.digit_bytes <= MAX_DIGIT_BYTES:
|
||||
raise ValueError("байт дискретных величин вне диапазона %d..%d"
|
||||
% (MIN_DIGIT_BYTES, MAX_DIGIT_BYTES))
|
||||
if not MIN_ANALOG_COUNT <= self.analog_count <= MAX_ANALOG_COUNT:
|
||||
raise ValueError("аналоговых величин вне диапазона %d..%d"
|
||||
% (MIN_ANALOG_COUNT, MAX_ANALOG_COUNT))
|
||||
if self.crc_name not in CRC_VARIANTS:
|
||||
raise ValueError("неизвестный вариант CRC: %s" % self.crc_name)
|
||||
|
||||
@property
|
||||
def frame_size(self) -> int:
|
||||
"""Полный размер ``TMS_TO_TERMINAL`` в байтах."""
|
||||
return FRAME_OVERHEAD + self.digit_bytes + 2 * self.analog_count
|
||||
|
||||
@property
|
||||
def analog_offset(self) -> int:
|
||||
return 2 + self.digit_bytes
|
||||
|
||||
def crc(self, data: bytes) -> int:
|
||||
return CRC_VARIANTS[self.crc_name](data)
|
||||
|
||||
|
||||
#: Готовые раскладки из исходников: оператору не нужно считать байты руками.
|
||||
PRESETS: Dict[str, Layout] = {
|
||||
"BALZAM_7 (28/34)": Layout(28, 34),
|
||||
"message2.h (38/34)": Layout(38, 34),
|
||||
"RS_Functions.h (28/30)": Layout(28, 30),
|
||||
"TEST_ALL (24/24)": Layout(24, 24),
|
||||
}
|
||||
DEFAULT_PRESET = "message2.h (38/34)"
|
||||
|
||||
|
||||
def build_std_request(address: int, analogs: List[int], digits: bytes) -> bytes:
|
||||
"""BALZAM_7 CMD_TO_TMS without add_byte (RS485.c: size_cmd15 - 1).
|
||||
|
||||
CMD_STD applies all four setpoints and six control bytes, even when the
|
||||
caller only wants a telemetry reply. Never substitute an empty poll.
|
||||
"""
|
||||
if not 0 <= address <= 255:
|
||||
raise ValueError("Адрес должен быть 0…255")
|
||||
if len(analogs) != 4 or len(digits) != 6:
|
||||
raise ValueError("CMD_STD: требуется 4 аналоговых значения и 6 байт дискретов")
|
||||
if any(not -32768 <= value <= 32767 for value in analogs):
|
||||
raise ValueError("Аналоговые значения должны быть -32768…32767")
|
||||
body = bytes((address, 65))
|
||||
body += b"".join(value.to_bytes(2, "little", signed=True) for value in analogs)
|
||||
body += digits
|
||||
return body + crc16_modbus(body).to_bytes(2, "little")
|
||||
|
||||
|
||||
def default_layout() -> Layout:
|
||||
"""Раскладка по умолчанию — самая свежая из исходников."""
|
||||
return PRESETS[DEFAULT_PRESET]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TmsRecord:
|
||||
"""@brief Одна разобранная посылка ПЧ -> терминал."""
|
||||
|
||||
address: int
|
||||
number: int
|
||||
digits: bytes
|
||||
analogs: List[int] # знаковые int16
|
||||
crc_frame: int
|
||||
crc_calc: int
|
||||
add_byte: int
|
||||
raw: bytes
|
||||
layout: Layout = field(default_factory=default_layout)
|
||||
|
||||
@property
|
||||
def crc_ok(self) -> bool:
|
||||
return self.crc_frame == self.crc_calc
|
||||
|
||||
@property
|
||||
def command(self) -> str:
|
||||
"""Номер команды словом; неизвестные остаются числом."""
|
||||
name = COMMAND_NAMES.get(self.number)
|
||||
return "%s (%d)" % (name, self.number) if name else str(self.number)
|
||||
|
||||
def bits(self, byte_index: int) -> List[int]:
|
||||
"""Биты одного байта дискретных величин от bit0 к bit7."""
|
||||
value = self.digits[byte_index]
|
||||
return [(value >> bit) & 1 for bit in range(8)]
|
||||
|
||||
def set_bits(self) -> List[str]:
|
||||
"""Имена взведённых битов вида ``byte05.bit3`` — для сводки в журнале."""
|
||||
return ["byte%02d.bit%d" % (index + 1, bit)
|
||||
for index, value in enumerate(self.digits)
|
||||
for bit in range(8) if value >> bit & 1]
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
head = "адрес %d, %s" % (self.address, self.command)
|
||||
if not self.crc_ok:
|
||||
return "%s, CRC не сошлась" % head
|
||||
return "%s, битов взведено: %d" % (head, len(self.set_bits()))
|
||||
|
||||
|
||||
def decode(raw: bytes, layout: Optional[Layout] = None) -> TmsRecord:
|
||||
"""@brief Раскладывает готовые байты посылки по полям.
|
||||
|
||||
@param raw Ровно ``layout.frame_size`` байт.
|
||||
@param layout Раскладка; по умолчанию из ``message2.h``.
|
||||
@throws ValueError если длина не совпадает с раскладкой.
|
||||
"""
|
||||
layout = layout or default_layout()
|
||||
if len(raw) != layout.frame_size:
|
||||
raise ValueError("ожидалось %d байт, получено %d"
|
||||
% (layout.frame_size, len(raw)))
|
||||
analogs = []
|
||||
for index in range(layout.analog_count):
|
||||
offset = layout.analog_offset + 2 * index
|
||||
analogs.append(int.from_bytes(raw[offset:offset + 2], "little", signed=True))
|
||||
return TmsRecord(
|
||||
address=raw[0],
|
||||
number=raw[1],
|
||||
digits=bytes(raw[2:layout.analog_offset]),
|
||||
analogs=analogs,
|
||||
crc_frame=int.from_bytes(raw[-3:-1], "little"),
|
||||
crc_calc=layout.crc(raw[:-CRC_TAIL]),
|
||||
add_byte=raw[-1],
|
||||
raw=bytes(raw),
|
||||
layout=layout,
|
||||
)
|
||||
|
||||
|
||||
def build(address: int, number: int, digits: bytes, analogs: List[int],
|
||||
layout: Optional[Layout] = None, add_byte: int = 0) -> bytes:
|
||||
"""@brief Собирает посылку — нужна демо-потоку и тестам разбора."""
|
||||
layout = layout or default_layout()
|
||||
if len(digits) != layout.digit_bytes:
|
||||
raise ValueError("ожидалось %d байт дискретных величин" % layout.digit_bytes)
|
||||
if len(analogs) != layout.analog_count:
|
||||
raise ValueError("ожидалось %d аналоговых величин" % layout.analog_count)
|
||||
body = bytearray([address & 0xFF, number & 0xFF])
|
||||
body += bytes(digits)
|
||||
for value in analogs:
|
||||
body += int(value).to_bytes(2, "little", signed=True)
|
||||
body += layout.crc(bytes(body)).to_bytes(2, "little")
|
||||
body.append(add_byte & 0xFF)
|
||||
return bytes(body)
|
||||
|
||||
|
||||
class TmsParser:
|
||||
"""@brief Потоковый разборщик посылок ТМС.
|
||||
|
||||
Сигнатуры у формата нет, поэтому границу ищем перебором смещений: посылка
|
||||
считается найденной там, где сходится CRC. Каждый пропущенный при поиске
|
||||
байт идёт в ``resync_bytes``.
|
||||
|
||||
Раскладка меняется на лету (``set_layout``) — оператор подбирает число
|
||||
полей прямо во время приёма.
|
||||
"""
|
||||
|
||||
def __init__(self, layout: Optional[Layout] = None) -> None:
|
||||
self._buf = bytearray()
|
||||
self.layout = layout or default_layout()
|
||||
self.stats = {"frames": 0, "resync_bytes": 0}
|
||||
|
||||
def set_layout(self, layout: Layout) -> None:
|
||||
"""Меняет раскладку и сбрасывает недособранный хвост буфера."""
|
||||
self.layout = layout
|
||||
self._buf.clear()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buf.clear()
|
||||
self.stats.update(frames=0, resync_bytes=0)
|
||||
|
||||
def feed(self, chunk: bytes) -> List[TmsRecord]:
|
||||
"""@brief Принимает кусок потока и отдаёт все распознанные посылки."""
|
||||
self._buf += chunk
|
||||
size = self.layout.frame_size
|
||||
records: List[TmsRecord] = []
|
||||
while len(self._buf) >= size:
|
||||
candidate = bytes(self._buf[:size])
|
||||
if self.layout.crc(candidate[:-CRC_TAIL]) == int.from_bytes(
|
||||
candidate[-3:-1], "little"):
|
||||
records.append(decode(candidate, self.layout))
|
||||
self.stats["frames"] += 1
|
||||
del self._buf[:size]
|
||||
continue
|
||||
del self._buf[:1]
|
||||
self.stats["resync_bytes"] += 1
|
||||
return records
|
||||
|
||||
|
||||
class TmsDemoSource:
|
||||
"""@brief Источник демонстрационных посылок ТМС: разбор без железа.
|
||||
|
||||
Повторяет то, что кладёт в линию ``RS_Send``, включая лишний байт сверх
|
||||
структуры, — на нём и проверяется ресинхронизация разборщика.
|
||||
|
||||
@param layout Раскладка выдаваемых посылок.
|
||||
@param rng Источник случайности; передаётся в тестах для повторяемости.
|
||||
"""
|
||||
|
||||
def __init__(self, layout: Optional[Layout] = None,
|
||||
rng: Optional[random.Random] = None) -> None:
|
||||
self.layout = layout or default_layout()
|
||||
self._rng = rng or random.Random()
|
||||
self._tick = 0
|
||||
|
||||
def next_frame(self) -> bytes:
|
||||
"""@brief Очередная посылка плюс хвостовой байт от ``RS_Send``."""
|
||||
self._tick += 1
|
||||
digits = bytes(self._rng.getrandbits(8)
|
||||
for _ in range(self.layout.digit_bytes))
|
||||
analogs = [self._rng.randint(-5000, 5000)
|
||||
for _ in range(self.layout.analog_count)]
|
||||
analogs[0] = 5000 + self._tick % 100 # FREQ_OUT ползёт заметно
|
||||
frame = build(1, 65, digits, analogs, self.layout,
|
||||
add_byte=self._tick & 0xFF)
|
||||
return frame + bytes([0x00])
|
||||
132
python/set_devices/ump_logger.py
Normal file
132
python/set_devices/ump_logger.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Протокол кольцевого логгера УМП v2.
|
||||
|
||||
Технологические регистры 0..127 не изменены. Расширение 0x1000 сначала
|
||||
проверяется по сигнатуре и версии: старая прошивка игнорировала старший
|
||||
байт адреса, поэтому диагностическое чтение безопасно попадёт в 0..15,
|
||||
но управляющие записи до успешной проверки запрещены в панели.
|
||||
CRC и проверка ответов остаются в общей библиотеке протокола ПМ35.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from protocan import periph28335 as protocol
|
||||
|
||||
BASE = 0x1000
|
||||
DATA = 0x1010
|
||||
LIVE = 0x1100
|
||||
EMU_CONTROL = 0x1200
|
||||
FAST_CAPABILITY = 0x1220
|
||||
FAST_DATA = 0x2000
|
||||
WORDS = 40
|
||||
CAPACITY = 64000 // (2 * WORDS)
|
||||
FIELDS = (
|
||||
('off_trigger', 'Событие отключения', 2, 8),
|
||||
('post_truncated', 'Усечение постинтервала', 2, 9),
|
||||
('start', 'Команда пуск УМП', 3, 7),
|
||||
('reset', 'Команда сброс УМП', 3, 6),
|
||||
('omega', 'Вход OMEGA', 4, 0),
|
||||
('start_in', 'Вход Start_1', 4, 1),
|
||||
('res_in', 'Вход Res_in', 4, 2),
|
||||
('start_out', 'Выход START_DPTB', 5, 0),
|
||||
('stop_out', 'Выход STOP_DPTB', 5, 1),
|
||||
('res_out', 'Выход RES_OUT_2', 5, 2),
|
||||
('impulse', 'Остаток импульса, мс', 6, None),
|
||||
('wake', 'Инициализация ЦАП, тики ЦАП', 7, None),
|
||||
('dac', 'Код ЦАП', 8, None),
|
||||
('current', 'Расчётное задание тока, мА', 9, None),
|
||||
('sensor0', 'Измерение 0', 10, None),
|
||||
('sensor1', 'Измерение 1', 11, None),
|
||||
('sensor2', 'Измерение 2', 12, None),
|
||||
('sensor3', 'Измерение 3', 13, None),
|
||||
('error0', 'Ошибки 0 (маска)', 14, None),
|
||||
('error1', 'Ошибки 1 (маска)', 15, None),
|
||||
('error2', 'Ошибки 2 (маска)', 16, None),
|
||||
('error3', 'Ошибки 3 (маска)', 17, None),
|
||||
('can_header', 'CAN адрес/маска', 18, None),
|
||||
('can0', 'CAN слово 0', 19, None),
|
||||
('can1', 'CAN слово 1', 20, None),
|
||||
('can2', 'CAN слово 2', 21, None),
|
||||
('can_count', 'Количество CAN RX (mod 65536)', 22, None),
|
||||
('can_timeout', 'Таймауты CAN TX (mod 65536)', 23, None),
|
||||
('commands', 'Команды (маска)', 3, None),
|
||||
('go', 'Начальная уставка, мА', 24, None),
|
||||
('stop', 'Конечная уставка, мА', 25, None),
|
||||
('dac_min', 'Код калибровки 4 мА', 26, None),
|
||||
('dac_max', 'Код калибровки 20 мА', 27, None),
|
||||
('dac_cal', 'Калибровочный код ЦАП', 28, None),
|
||||
('dac04', 'DAC_04 (регистр)', 29, None),
|
||||
('dac20', 'DAC_20 (регистр)', 30, None),
|
||||
('ramp', 'Счётчик разгона', 31, None),
|
||||
('duration', 'Длительность разгона, тики ЦАП', 32, None),
|
||||
('effective_go', 'Начальная уставка после ограничений, мА', 33, None),
|
||||
('effective_stop', 'Конечная уставка после ограничений, мА', 34, None),
|
||||
('position', 'Положение разгона (kod)', 35, None),
|
||||
('adc0', 'АЦП канал 0', 36, None),
|
||||
('adc1', 'АЦП канал 1', 37, None),
|
||||
('adc2', 'АЦП канал 2', 38, None),
|
||||
('adc3', 'АЦП канал 3', 39, None),
|
||||
)
|
||||
|
||||
|
||||
def request(controller: int, function: int, address: int, value: int) -> bytes:
|
||||
if not 0 <= controller <= 255 or not 0 <= value <= 65535:
|
||||
raise ValueError('Недопустимый адрес контроллера или значение')
|
||||
if function == 3:
|
||||
valid = ((address == BASE and value == 16) or
|
||||
(address == DATA and 1 <= value <= 120 and value % WORDS == 0) or (address == LIVE and value == 124) or (address == EMU_CONTROL and value == 4)
|
||||
or (address == FAST_CAPABILITY and value == 4)
|
||||
or (FAST_DATA <= address < FAST_DATA + CAPACITY * WORDS
|
||||
and (address - FAST_DATA) % WORDS == 0
|
||||
and 1 <= value <= 120 and value % WORDS == 0
|
||||
and address + value <= FAST_DATA + CAPACITY * WORDS))
|
||||
else:
|
||||
valid = function == 6 and (address in (BASE, BASE + 1, BASE + 2, BASE + 3) or
|
||||
(address == EMU_CONTROL + 2 and value in (0, 1)))
|
||||
if not valid:
|
||||
raise ValueError('Недопустимый запрос логгера УМП')
|
||||
return protocol.with_crc(bytes((controller, function)) + address.to_bytes(2, 'big') + value.to_bytes(2, 'big'))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Status:
|
||||
state: int
|
||||
count: int
|
||||
period: int
|
||||
mode: int
|
||||
frequency: int
|
||||
generation: int
|
||||
archive_count: int
|
||||
post_ms: int
|
||||
live_state: int
|
||||
|
||||
@classmethod
|
||||
def decode(cls, words):
|
||||
if (len(words) != 16 or words[0:2] != (0x554D, 2) or
|
||||
words[4] != WORDS or words[5] != CAPACITY):
|
||||
raise ValueError('Прошивка не поддерживает логгер УМП v2')
|
||||
if words[9] != 6:
|
||||
raise ValueError('Логгер УМП доступен только на плате нагрузки dsk_LOAD')
|
||||
if words[2] not in (0, 1, 2, 3) or words[3] > CAPACITY or not 1 <= words[6] <= 1000 or words[11] != 1000 or words[13] > CAPACITY or words[14] > 1000 or words[15] not in (0, 1, 3):
|
||||
raise ValueError('Повреждён статус логгера')
|
||||
return cls(words[2], words[3], words[6], words[10], words[11], *words[12:16])
|
||||
|
||||
|
||||
def sample(words, frequency=1000):
|
||||
if len(words) != WORDS or frequency <= 0:
|
||||
raise ValueError('Неверная запись логгера')
|
||||
result = {'time_ms': (words[0] | words[1] << 16) * 1000 / frequency, 'event': words[2]}
|
||||
for key, _label, index, bit in FIELDS:
|
||||
value = words[index]
|
||||
if bit is not None:
|
||||
value = (value >> bit) & 1
|
||||
elif 10 <= index <= 13 and value >= 32768:
|
||||
value -= 65536
|
||||
# Физические единицы приводим здесь, одинаково для CSV и графика.
|
||||
if key in ('current', 'effective_go', 'effective_stop'):
|
||||
value /= 10.0
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def emulator_enabled(words):
|
||||
if len(words) != 4 or tuple(words[:2]) != (0x454D, 1) or words[2] not in (0, 1):
|
||||
raise ValueError('Плата не поддерживает управление эмуляцией STM')
|
||||
return bool(words[2])
|
||||
73
python/set_devices/ump_logger_can.py
Normal file
73
python/set_devices/ump_logger_can.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""CAN-транспорт расширения регистратора periph_28335.
|
||||
|
||||
Служебные запросы идут на штатный RX ID платы 0xBA0000 + Mode - 1.
|
||||
Ответы имеют отдельный ID 0xBA0020 + Mode - 1. Технологическая телеметрия
|
||||
не принимается за ответ логгера. Данные кадра следуют порядку MDL, MDH
|
||||
существующей прошивки; каждое слово передаётся старшим байтом вперёд.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
import struct
|
||||
from set_devices.ump_logger import BASE, DATA, LIVE, EMU_CONTROL
|
||||
|
||||
MARKER = 0x1F00
|
||||
MAX_WORDS = 124
|
||||
|
||||
|
||||
def request_ids(mode):
|
||||
if not 1 <= mode <= 16:
|
||||
raise ValueError('Номер CAN-платы должен быть 1…16 (Mode прошивки)')
|
||||
return 0xBA0000 + mode - 1, 0xBA0020 + mode - 1
|
||||
|
||||
|
||||
def build_request(mode, token, function, address, value):
|
||||
if not 1 <= token <= 255:
|
||||
raise ValueError('Неверный номер CAN-запроса')
|
||||
valid = ((function == 3 and ((address == BASE and value == 16) or
|
||||
(address == DATA and value in (40, 80, 120)) or
|
||||
(address == LIVE and value == MAX_WORDS) or (address == EMU_CONTROL and value == 4))) or
|
||||
(function == 6 and ((BASE <= address <= BASE+3 and 0 <= value <= 65535) or
|
||||
(address == EMU_CONTROL+2 and value in (0, 1)))))
|
||||
if not valid:
|
||||
raise ValueError('Недопустимый запрос CAN-логгера')
|
||||
can_id, _ = request_ids(mode)
|
||||
return can_id, struct.pack('>4H', address, value, MARKER, token << 8 | function)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Response:
|
||||
"""Собирает один снимок. Дубликаты допустимы, противоречащие — ошибка.
|
||||
|
||||
Номер фрагмента позволяет восстановить порядок при задержках адаптера.
|
||||
До получения всех фрагментов снимок не передаётся в график/архив.
|
||||
"""
|
||||
mode: int
|
||||
token: int
|
||||
function: int
|
||||
address: int
|
||||
value: int
|
||||
fragments: dict = field(default_factory=dict)
|
||||
|
||||
def feed(self, can_id, data):
|
||||
if can_id != request_ids(self.mode)[1] or len(data) != 8:
|
||||
return None
|
||||
first, second, header, tag = struct.unpack('>4H', bytes(data))
|
||||
if not MARKER <= header < MARKER + 62 or tag >> 8 != self.token:
|
||||
return None
|
||||
if tag & 255:
|
||||
raise ValueError(f'Плата отклонила CAN-запрос логгера: ошибка {tag & 255}')
|
||||
count = self.value if self.function == 3 else 2
|
||||
index = header - MARKER
|
||||
if index >= count // 2:
|
||||
raise ValueError('Лишний фрагмент CAN-ответа логгера')
|
||||
words = (first, second)
|
||||
if index in self.fragments and self.fragments[index] != words:
|
||||
raise ValueError('Противоречащие фрагменты CAN-ответа')
|
||||
self.fragments[index] = words
|
||||
if len(self.fragments) != count // 2:
|
||||
return None
|
||||
result = tuple(word for n in range(count // 2) for word in self.fragments[n])
|
||||
if self.function == 6:
|
||||
if result != (self.address, self.value):
|
||||
raise ValueError('CAN-плата подтвердила другую запись')
|
||||
return ()
|
||||
return result
|
||||
57
python/set_devices/ump_logger_demo.py
Normal file
57
python/set_devices/ump_logger_demo.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Синтетические записи УМП для проверки графиков без подключения платы."""
|
||||
import math
|
||||
|
||||
from set_devices import ump_logger as codec
|
||||
|
||||
|
||||
def sample(time_ms, period_ms=20):
|
||||
"""Повторяемый цикл: пуск, разгон 6–16 мА, отключение и спад тока."""
|
||||
phase = time_ms % 6000
|
||||
started = 1000 <= phase < 5000
|
||||
reset = 5000 <= phase < 5300
|
||||
# Как в прошивке: фронт команды включает выход на 1000 мс.
|
||||
start_out = 1000 <= phase < 2000
|
||||
stop_out = 5000 <= phase < 6000
|
||||
ramp = max(0, min(2500, phase - 1000))
|
||||
if started:
|
||||
current = 6 + 10 * ramp / 2500 + 0.2 * math.sin(phase / 90)
|
||||
elif phase >= 5000:
|
||||
current = 4 + 12 * max(0, 1 - (phase - 5000) / 300)
|
||||
else:
|
||||
current = 4
|
||||
|
||||
words = [0] * codec.WORDS
|
||||
words[0], words[1] = time_ms & 65535, (time_ms >> 16) & 65535
|
||||
words[2] = 1 | (0x100 if 5000 <= phase < 5000 + period_ms else 0)
|
||||
words[3] = (int(started) << 7) | (int(reset) << 6)
|
||||
words[4] = int(1200 <= phase < 5100) | (int(started) << 1) | (int(reset) << 2)
|
||||
words[5] = int(start_out) | (int(stop_out) << 1) | (int(started) << 2)
|
||||
words[6] = (2000 - phase) if start_out else ((6000 - phase) if stop_out else 0)
|
||||
words[7] = max(0, 250 - phase)
|
||||
words[8] = round(500 + (current - 4) * 3000 / 16)
|
||||
words[9] = round(current * 10)
|
||||
for channel in range(4):
|
||||
measured = round(120 * math.sin(phase / (180 + channel * 70) + channel))
|
||||
words[10 + channel] = measured & 65535
|
||||
words[14 + channel] = (1 << channel) if 5100 <= phase < 5400 else 0
|
||||
words[36 + channel] = 2048 + measured * 4
|
||||
words[18:24] = [0xE07F, words[3], 6, 16, (time_ms // 100) & 65535, 0]
|
||||
words[24:31] = [6, 16, 500, 3500, 2000, 500, 3500]
|
||||
words[31:36] = [ramp if started else 0, 2500, 60, 160, round(ramp * 1000 / 2500)]
|
||||
return codec.sample(words)
|
||||
|
||||
|
||||
def capture(period_ms):
|
||||
"""Пример архива: до 399 точек предыстории, отключение и 1 с после него."""
|
||||
before = list(range(5000, -1, -period_ms))[1:400][::-1]
|
||||
after = list(range(5000 + period_ms, 6000, period_ms)) + [6000]
|
||||
available = codec.CAPACITY - len(before) - 1
|
||||
truncated = len(after) > available
|
||||
if truncated:
|
||||
after = after[:available - 1] + after[-1:]
|
||||
records = [sample(t, period_ms) for t in before + [5000] + after]
|
||||
# Последний снимок относится к концу постинтервала, а не новому циклу.
|
||||
records[-1]['event'] = 5 | (0x200 if truncated else 0)
|
||||
records[-1]['post_truncated'] = int(truncated)
|
||||
records[-1]['off_trigger'] = 0
|
||||
return records
|
||||
Reference in New Issue
Block a user