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,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