Extract reusable device protocols, ports and logic analyzers from SETGUI

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

View File

@@ -0,0 +1 @@
"""Reusable device libraries from setcorp/templates."""

View 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

View 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()

View File

@@ -0,0 +1,623 @@
"""@file can485_board.py
@brief Плата WeAct CAN485 DevBoard V1 (ESP32): её текстовый вывод и пакеты RS485.
Прошивка ``CAN_Parser`` печатает в USB-UART разобранные кадры CAN и RS485
строками, а CAN-кадры также выкладывает в RS485 пакетами по 17 байт. Здесь
разбор обоих форматов, сборка команд управления платой и генератор демо-трафика.
Перенесено из отдельного tkinter-приложения ``Tools/CanMonitorGUI``
репозитория платы. Модуль не импортирует Qt: разбор переносим и проверяется
host-тестами.
Строка кадра::
[ 12.345] EXT 0x18FEE000 DLC=8 DATA=01 02 03 04 05 06 07 08 |........|
Пакет RS485::
AA 55 | FLAGS | ID[4] big-endian | DLC | DATA[8] | CRC8
"""
from __future__ import annotations
import csv
import random
import re
import time
from collections import deque
from dataclasses import dataclass, field
from itertools import chain
from typing import Iterable
#: Распространённые скорости UART для подключения платы и RS485-приёмника.
UART_BAUDS = (
"1200", "2400", "4800", "9600", "14400", "19200", "38400", "57600",
"115200", "128000", "230400", "256000", "460800", "500000", "512000",
"576000", "921600", "1000000", "1152000", "1500000", "2000000",
"3000000", "4000000",
)
#: Скорости USB-UART платы и скорость по умолчанию для ESP32.
USB_BAUDS = UART_BAUDS[8:]
DEFAULT_USB_BAUD = "921600"
#: Скорости шины CAN, кбит/с. Список закрытый: прошивка проверяет значение.
CAN_BITRATES = ["25", "50", "100", "125", "250", "500", "800", "1000"]
#: Режимы контроллера: имя и код команды ``M``.
MODES = (("normal", 0), ("listen-only", 1), ("no-ack", 2))
#: Маршруты прошивки 1.6: бит 0 — CAN→RS485, бит 1 — RS485→CAN.
ROUTES = (("Выкл", 0), ("CAN→RS485", 1), ("RS485→CAN", 2), ("Оба", 3))
#: Скорости встроенного UART RS485 платы и значение по умолчанию.
RS485_BAUDS = UART_BAUDS
DEFAULT_RS485_BAUD = "512000"
#: Синхробайты и длина пакета RS485.
RS485_SYNC = b"\xAA\x55"
RS485_LEN = 17
#: Строка кадра: время, тип идентификатора, сам идентификатор и хвост.
FRAME_RE = re.compile(
r"^\[\s*(\d+)\.(\d{3})\]\s+(?:(RS485)\s+)?"
r"(EXT|STD)\s+0x([0-9A-Fa-f]+)\s+(.*)$"
)
RTR_RE = re.compile(r"^RTR\s+DLC=(\d)")
DATA_RE = re.compile(r"^DLC=(\d)\s*(?:DATA=([0-9A-Fa-f ]*))?")
HEX_BYTE_RE = re.compile(r"(?i)(?<![0-9a-f])[0-9a-f]{2}(?![0-9a-f])")
def crc8(data: bytes) -> int:
"""@brief CRC-8, полином 0x07, начальное значение 0 — как в прошивке."""
crc = 0
for byte in data:
crc ^= byte
for _ in range(8):
crc = ((crc << 1) ^ 0x07) & 0xFF if crc & 0x80 else (crc << 1) & 0xFF
return crc
def to_ascii(data: bytes) -> str:
"""Печатные байты как есть, остальные точкой."""
return "".join(chr(byte) if 0x20 <= byte < 0x7F else "." for byte in data)
def j1939(can_id: int) -> str:
"""@brief Короткий разбор 29-битного идентификатора по J1939.
@param can_id Идентификатор кадра.
@return Приоритет, PGN и адрес источника одной строкой.
"""
priority = (can_id >> 26) & 0x07
pdu_format = (can_id >> 16) & 0xFF
source = can_id & 0xFF
pgn = (can_id >> 8) & 0x3FFFF
if pdu_format < 240:
# PDU1: младший байт PGN — адрес получателя, а не часть номера группы.
pgn &= 0x3FF00
return "P%d PGN %05X SA %02X" % (priority, pgn, source)
@dataclass
class BoardFrame:
"""@brief Один кадр шины, пришедший от платы."""
timestamp: float
extended: bool
identifier: int
rtr: bool
dlc: int
data: bytes
source: str = "CAN"
@property
def key(self) -> tuple[bool, int]:
"""Ключ агрегирования в таблице «по ID»."""
return (self.extended, self.identifier)
def id_str(self) -> str:
return ("0x%08X" if self.extended else "0x%03X") % self.identifier
def data_str(self) -> str:
return "RTR" if self.rtr else " ".join("%02X" % byte for byte in self.data)
def ascii_str(self) -> str:
return "" if self.rtr else to_ascii(self.data)
def rs485_packet(self) -> bytes:
"""@brief Пакет RS485, каким его выложила бы плата.
Нужен и для показа сырых байт, и для демо-потока: генератор отдаёт
те же байты, что придут с железа, поэтому проверяется весь разбор.
"""
flags = (1 if self.extended else 0) | (2 if self.rtr else 0)
body = (bytes([flags])
+ (self.identifier & 0x1FFFFFFF).to_bytes(4, "big")
+ bytes([self.dlc])
+ self.data.ljust(8, b"\x00")[:8])
return RS485_SYNC + body + bytes([crc8(body)])
def parse_frame_line(line: str, timestamp: float) -> BoardFrame | None:
"""@brief Разбирает строку кадра из USB-вывода прошивки.
@param line Строка без завершающего перевода строки.
@param timestamp Время приёма на стороне ПК.
@return Кадр либо ``None``, если строка не про кадр.
"""
match = FRAME_RE.match(line)
if not match:
return None
source = "RS485" if match.group(3) else "CAN"
extended = match.group(4) == "EXT"
identifier = int(match.group(5), 16)
rest = match.group(6)
remote = RTR_RE.match(rest)
if remote:
return BoardFrame(timestamp, extended, identifier, True,
int(remote.group(1)), b"", source=source)
payload = DATA_RE.match(rest)
if not payload:
return None
dlc = int(payload.group(1))
# Хвост строки может нести ASCII-колонку в вертикальных чертах.
hex_part = (payload.group(2) or "").split("|")[0]
try:
data = bytes(int(item, 16) for item in hex_part.split())
except ValueError:
return None
return BoardFrame(timestamp, extended, identifier, False, dlc, data[:8],
source=source)
@dataclass
class SdLogImport:
"""Результат безопасного импорта журнала, снятого с TF/SD-карты."""
frames: list[BoardFrame]
rows_seen: int
skipped_rows: int
truncated: bool
format_name: str
def _header_key(value: str) -> str:
"""Нормализует русские и английские заголовки CSV для сопоставления."""
return re.sub(r"[^a-zа-я0-9]+", "", value.strip().lower().lstrip("\ufeff"))
def _column_index(header: list[str], *aliases: str) -> int | None:
keys = [_header_key(value) for value in header]
wanted = {_header_key(alias) for alias in aliases}
return next((index for index, key in enumerate(keys) if key in wanted), None)
def _parse_log_timestamp(value: str, reference: float, milliseconds: bool) -> float:
"""Читает epoch, millis() либо часы из CSV без требования даты в файле."""
text = value.strip()
clock = re.fullmatch(r"(\d{1,2}):(\d{2}):(\d{2})(?:[.,](\d{1,6}))?", text)
if clock:
fraction = float("0." + (clock.group(4) or "0"))
local = time.localtime(reference)
midnight = time.mktime((local.tm_year, local.tm_mon, local.tm_mday,
0, 0, 0, local.tm_wday, local.tm_yday,
local.tm_isdst))
return midnight + int(clock.group(1)) * 3600 + int(clock.group(2)) * 60 \
+ int(clock.group(3)) + fraction
numeric = float(text.replace(",", "."))
if numeric >= 100_000_000_000:
return numeric / 1000.0
if numeric >= 1_000_000_000:
return numeric
return numeric / 1000.0 if milliseconds else numeric
def _parse_bool(value: str) -> bool:
return value.strip().lower() in {"1", "true", "yes", "ext", "e", "29"}
def _parse_csv_frame(
row: list[str], header: list[str] | None, reference: float,
) -> tuple[BoardFrame, bool]:
"""Разбирает одну строку SD CSV; второй результат отмечает относительное время."""
if header is None:
if len(row) < 5:
raise ValueError("слишком мало колонок")
time_index, type_index, id_index, dlc_index, data_index = 0, 1, 2, 3, 4
rtr_index = None
time_key = "ms"
else:
time_index = _column_index(header, "Время", "time", "timestamp", "ms",
"millis", "time_ms", "timestamp_ms",
"uptime_ms", "elapsed_ms", "t_ms")
type_index = _column_index(header, "Тип", "type", "format", "fmt", "ide", "ext")
id_index = _column_index(header, "ID", "identifier", "can_id", "canid",
"ID (29 бит)")
dlc_index = _column_index(header, "DLC", "len", "length")
data_index = _column_index(header, "Данные (hex)", "data", "payload",
"data_hex", "bytes", "DATA (8 байт)")
rtr_index = _column_index(header, "RTR", "remote")
if None in (time_index, id_index, dlc_index, data_index):
raise ValueError("нет обязательных колонок")
time_key = _header_key(header[time_index])
required = [time_index, id_index, dlc_index, data_index]
if any(index is None or index >= len(row) for index in required):
raise ValueError("короткая строка")
type_text = row[type_index].strip() if type_index is not None and type_index < len(row) else ""
id_text = row[id_index].strip().lower().replace("0x", "")
identifier = int(id_text, 16)
type_upper = type_text.upper()
if type_upper in {"EXT", "E", "29", "TRUE", "YES", "1"}:
extended = True
elif type_upper in {"STD", "S", "11", "FALSE", "NO", "0"}:
extended = False
else:
extended = identifier > 0x7FF
if not 0 <= identifier <= (0x1FFFFFFF if extended else 0x7FF):
raise ValueError("ID вне диапазона")
dlc_text = row[dlc_index].strip()
dlc = int(dlc_text, 16 if dlc_text.lower().startswith("0x") else 10)
if not 0 <= dlc <= 8:
raise ValueError("DLC вне диапазона")
rtr = (type_text.upper() == "RTR" or row[data_index].strip().upper() == "RTR"
or (rtr_index is not None and rtr_index < len(row) and _parse_bool(row[rtr_index])))
data = b""
if not rtr:
# У SD-прошивки байты могут идти одной колонкой или отдельными полями.
joined = (row[data_index] if header is not None
else " ".join(row[data_index:]))
compact = re.sub(r"(?i)0x|[^0-9a-f]", "", joined)
if compact and len(compact) % 2 == 0:
data = bytes.fromhex(compact)
else:
data = bytes(int(value, 16) for value in HEX_BYTE_RE.findall(joined))
if len(data) < dlc:
raise ValueError("данных меньше DLC")
data = data[:dlc]
milliseconds = time_key in {
"ms", "millis", "timems", "timestampms", "uptimems", "elapsedms",
"tms",
}
stamp = _parse_log_timestamp(row[time_index], reference, milliseconds)
relative = stamp < 1_000_000_000 and not ":" in row[time_index]
return BoardFrame(stamp, extended, identifier, rtr, dlc, data, source="SD"), relative
def parse_sd_log(
lines: Iterable[str], reference_timestamp: float | None = None,
limit: int = 5000,
) -> SdLogImport:
"""Разбирает SD-журнал CAN485 без загрузки неограниченного файла в память.
Поддерживаются текстовые строки ``CAN_Parser``, его компактный CSV
``ms,type,id,dlc,data`` и CSV, сохранённый самим SETGUI. В память попадают
только последние ``limit`` корректных кадров.
"""
if limit <= 0:
raise ValueError("limit должен быть положительным")
reference = time.time() if reference_timestamp is None else reference_timestamp
source_lines = iter(lines)
first = ""
found_data = False
for candidate in source_lines:
first = candidate.strip("\r\n\ufeff")
if first.strip() and not first.lstrip().startswith("#"):
found_data = True
break
if not found_data:
return SdLogImport([], 0, 0, False, "empty")
rows_seen = 0
skipped = 0
total_frames = 0
relative_flags: deque[bool] = deque(maxlen=limit)
frames: deque[BoardFrame] = deque(maxlen=limit)
def add(frame: BoardFrame | None, relative: bool = False) -> None:
nonlocal skipped, total_frames
if frame is None:
skipped += 1
return
frames.append(frame)
relative_flags.append(relative)
total_frames += 1
if first.lstrip().startswith("["):
format_name = "CAN_Parser text"
for line in chain((first,), source_lines):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
rows_seen += 1
match = FRAME_RE.match(stripped)
relative = False
stamp = reference
if match:
stamp = int(match.group(1)) + int(match.group(2)) / 1000.0
relative = True
add(parse_frame_line(stripped, stamp), relative)
else:
delimiter = ";" if first.count(";") >= first.count(",") else ","
reader = csv.reader(chain((first,), source_lines), delimiter=delimiter)
first_row = next(reader, [])
id_column = _column_index(first_row, "ID", "identifier", "can_id", "canid",
"ID (29 бит)")
header = first_row if id_column is not None else None
format_name = "SETGUI CSV" if header and _column_index(
header, "Данные (hex)") is not None else "CAN_Parser CSV"
data_rows = reader if header is not None else chain((first_row,), reader)
for row in data_rows:
if not row or not any(value.strip() for value in row):
continue
rows_seen += 1
try:
frame, relative = _parse_csv_frame(row, header, reference)
except (ValueError, TypeError, IndexError):
add(None)
else:
add(frame, relative)
imported = list(frames)
flags = list(relative_flags)
relative_stamps = [frame.timestamp for frame, relative in zip(imported, flags) if relative]
if relative_stamps:
# millis()/время от старта не имеет календаря: конец журнала привязываем
# ко времени изменения файла на карте, сохраняя интервалы между кадрами.
shift = reference - max(relative_stamps)
imported = [BoardFrame(frame.timestamp + shift if relative else frame.timestamp,
frame.extended, frame.identifier, frame.rtr,
frame.dlc, frame.data, frame.source)
for frame, relative in zip(imported, flags)]
return SdLogImport(imported, rows_seen, skipped, total_frames > limit, format_name)
def parse_status(line: str) -> dict[str, str]:
"""@brief Разбирает поля ``#STAT`` или ``#OK`` в словарь «ключ — значение»."""
values: dict[str, str] = {}
for item in line.split()[1:]:
if "=" in item:
key, value = item.split("=", 1)
values[key] = value
return values
class LineAssembler:
"""@brief Собирает строки из кусков потока USB-UART.
Куски приходят как попало — QSerialPort отдаёт то, что успело прийти,
поэтому остаток без перевода строки хранится до следующего вызова.
"""
def __init__(self) -> None:
self._buffer = bytearray()
def feed(self, chunk: bytes) -> list[str]:
"""@brief Добавляет кусок и возвращает завершённые строки."""
self._buffer += chunk
lines: list[str] = []
while True:
end = self._buffer.find(b"\n")
if end < 0:
break
raw = bytes(self._buffer[:end])
del self._buffer[:end + 1]
lines.append(raw.decode("utf-8", "replace").rstrip("\r"))
return lines
def reset(self) -> None:
self._buffer.clear()
class Rs485PacketParser:
"""@brief Выделяет из потока RS485 пакеты по 17 байт и проверяет CRC."""
def __init__(self) -> None:
self._buffer = bytearray()
#: Счётчики: принято пакетов и отброшено по несходимости CRC.
self.stats = {"packets": 0, "crc_errors": 0}
def feed(self, chunk: bytes, timestamp: float | None = None) -> list[BoardFrame]:
"""@brief Добавляет кусок потока и возвращает разобранные кадры."""
self._buffer += chunk
now = time.time() if timestamp is None else timestamp
frames: list[BoardFrame] = []
while len(self._buffer) >= RS485_LEN:
start = self._buffer.find(RS485_SYNC)
if start < 0:
# Синхробайт может быть разорван между кусками: последний байт
# оставляем, вдруг это первая половина AA 55.
del self._buffer[:-1]
break
if start:
del self._buffer[:start]
continue
packet = bytes(self._buffer[:RS485_LEN])
if crc8(packet[2:16]) != packet[16]:
self.stats["crc_errors"] += 1
del self._buffer[:2]
continue
del self._buffer[:RS485_LEN]
self.stats["packets"] += 1
flags = packet[2]
identifier = int.from_bytes(packet[3:7], "big") & 0x1FFFFFFF
dlc = min(packet[7], 8)
frames.append(BoardFrame(now, bool(flags & 1), identifier,
bool(flags & 2), dlc, packet[8:8 + dlc],
source="RS485"))
return frames
def reset(self) -> None:
self._buffer.clear()
self.stats.update(packets=0, crc_errors=0)
# ==========================================================================
# Команды прошивки
# ==========================================================================
def command_bitrate(kbps: str | int) -> str:
"""Скорость шины CAN, кбит/с."""
return "S%s" % kbps
def command_mode(mode: int) -> str:
"""Режим контроллера: 0 normal, 1 listen-only, 2 no-ack."""
return "M%d" % mode
def command_rs485(enabled: bool) -> str:
"""Только CAN→RS485; в прошивке 1.6 обратный маршрут не меняется."""
return "R1" if enabled else "R0"
def command_route(route: int) -> str:
"""Маршрут прошивки 1.6: 0 выкл, 1 CAN→RS485, 2 RS485→CAN, 3 оба."""
if route not in (0, 1, 2, 3):
raise ValueError("маршрут должен быть 0..3")
return "Q%d" % route
def command_rs485_baud(baud: str | int) -> str:
"""Скорость физического интерфейса RS485 на плате."""
value = int(baud)
if not 1200 <= value <= 3_000_000:
raise ValueError("скорость RS485 должна быть 1200..3000000 бод")
return "B%d" % value
def command_rs485_transmit(packet: bytes) -> str:
"""Передача готового 17-байтового пакета в RS485 через USB платы."""
if len(packet) != RS485_LEN or not packet.startswith(RS485_SYNC):
raise ValueError("ожидается 17-байтовый пакет RS485 AA 55")
if crc8(packet[2:16]) != packet[16]:
raise ValueError("неверный CRC8 пакета RS485")
return "X," + packet.hex().upper()
def command_sd_logging(enabled: bool) -> str:
"""Запуск или остановка записи CAN-кадров на TF-карту."""
return "L1" if enabled else "L0"
def command_sd_list() -> str:
"""Запрос списка файлов TF-карты через USB-UART платы."""
return "D"
def normalize_sd_log_name(name: str) -> str:
"""Разрешает загрузку только корневых CSV-журналов ``can_*``."""
value = name.strip()
if value.startswith("/"):
value = value[1:]
if not re.fullmatch(r"(?i)can_[a-z0-9_-]+\.csv", value):
raise ValueError("допустим только корневой журнал can_*.csv")
return "/" + value
def command_sd_download(name: str, offset: int = 0) -> str:
"""Запрос содержимого выбранного файла карты через текстовый протокол."""
if not 0 <= offset <= 0xFFFFFFFF:
raise ValueError("смещение SD-журнала вне uint32")
command = "G," + normalize_sd_log_name(name)
return command if offset == 0 else command + "," + str(offset)
def parse_sd_file_entry(line: str) -> tuple[str, int] | None:
"""Разбирает строку списка ``#SD file <name> <size>`` прошивки."""
match = re.fullmatch(r"#SD file\s+(\S+)\s+(\d+)\s*", line)
if match is None:
return None
return normalize_sd_log_name(match.group(1)), int(match.group(2))
def command_filter(identifier: str = "", mask: str = "") -> str:
"""@brief Аппаратный фильтр по идентификатору и маске (hex).
Без аргументов фильтр снимается: прошивка так и понимает голую ``F``.
"""
identifier = identifier.strip()
mask = mask.strip()
if not identifier and not mask:
return "F"
return "F%s,%s" % (identifier or "0", mask or "0")
def command_transmit(extended: bool, identifier: str, dlc: int, data: str) -> str:
"""@brief Передача кадра в шину.
@param extended 29-битный идентификатор вместо 11-битного.
@param identifier Идентификатор в hex, с ``0x`` или без.
@param dlc Число байт данных, 0..8.
@param data Байты данных в hex; пробелы игнорируются.
"""
clean_id = identifier.strip().replace("0x", "").replace("0X", "") or "0"
clean_data = "".join(data.split())
return "T,%s,%s,%d,%s" % ("E" if extended else "S", clean_id, dlc, clean_data)
#: Команда обнуления счётчиков прошивки.
COMMAND_CLEAR_COUNTERS = "Z"
#: Команда «представься»: версия, распиновка и текущее состояние.
COMMAND_VERSION = "V"
#: Запрос текущего маршрута (прошивка 1.6 и новее).
COMMAND_ROUTE = "Q"
#: Команда вывода списка команд.
COMMAND_HELP = "?"
# ==========================================================================
# Демонстрационный поток
# ==========================================================================
@dataclass
class DemoTraffic:
"""Порция демо-трафика: строки USB и пакеты RS485 за один такт."""
lines: list[str] = field(default_factory=list)
packets: list[bytes] = field(default_factory=list)
class BoardDemoSource:
"""@brief Генератор трафика платы: интерфейс проверяется без железа.
Строки собираются ровно в формате прошивки, пакеты RS485 — с настоящим
CRC, поэтому демо-режим проходит через тот же разбор, что и железо.
"""
#: Идентификаторы, по которым генератор идёт по кругу.
IDS = ((True, 0x18FEE000), (True, 0x0CF00400), (False, 0x123), (False, 0x7DF))
def __init__(self, rng: random.Random | None = None) -> None:
self._rng = rng or random.Random()
self._count = 0
self._start = time.time()
def next_traffic(self) -> DemoTraffic:
"""@brief Очередной такт: кадр в обоих видах, раз в 20 тактов — ``#STAT``."""
extended, identifier = self.IDS[self._count % len(self.IDS)]
dlc = 8 if extended else 3
data = bytes(self._rng.randrange(256) for _ in range(dlc))
elapsed_ms = int((time.time() - self._start) * 1000)
id_text = ("0x%08X" if extended else "0x%03X ") % identifier
line = "[%4d.%03d] %s %s DLC=%d DATA=%s" % (
elapsed_ms // 1000, elapsed_ms % 1000, "EXT" if extended else "STD",
id_text, dlc, " ".join("%02X" % byte for byte in data))
frame = BoardFrame(time.time(), extended, identifier, False, dlc, data,
source="RS485")
traffic = DemoTraffic(lines=[line], packets=[frame.rs485_packet()])
self._count += 1
if self._count % 20 == 0:
traffic.lines.append(
"#STAT state=RUNNING bitrate=500 mode=normal rx=%d shown=%d tx=0 "
"buserr=0 miss=0 txerr=0 rxerr=0 dropusb=0 droprs=0 rs485=on "
"route=3 rsrx=%d rscrc=0 rstx=%d dropcan=0 "
"baud=512000 vin=12180 up=%d sd=rec sdfile=/can_demo.csv "
"sdkb=1 sdlines=%d dropsd=0"
% (self._count, self._count, self._count, self._count,
int(time.time() - self._start),
self._count))
return traffic

View 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

View 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

View 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"))

View 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)

View 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-шине")

View 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"

View 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))

View 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

View 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
View 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)))

View 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

View 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)

View 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 []

View File

@@ -0,0 +1 @@
"""Reusable device libraries from setcorp/templates."""

View 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()

View 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))

View 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

View 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())

View 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()

View 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)

View 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)

View 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)

View 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)

View 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

View 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)

View 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"

View 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

View 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])

View 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])

View 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

View 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