Собрано из SETGUI/src/gui_desktop/core (protocan, can_transport, protocol, gas_catalog); в CAN_to_RS485/template/python лежала такая же копия. Только stdlib: ни Qt, ни pyserial — модулям передают bytes, порт и таймауты остаются делом вызывающего кода. Кодировщики совпадают побайтово с c/protocan-transport, что зафиксировано эталонами в его тестах. Добавлен __init__.py: gas_catalog импортирует protocol относительным импортом, без пакета копия в CAN_to_RS485/template не собиралась.
286 lines
10 KiB
Python
286 lines
10 KiB
Python
"""Совместимое с ``lib/gui_transport`` кадрирование GUI protocol v1."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import binascii
|
||
from dataclasses import dataclass
|
||
from enum import IntEnum
|
||
|
||
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
|
||
# Каталог общего адресного пространства и поток выбранных значений,
|
||
# см. protocan-transport/docs/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
|
||
|
||
|
||
#: Причины отказа прибора на языке оператора.
|
||
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: "внешняя память недоступна: отсутствует, переполнена или не приняла запись",
|
||
}
|
||
|
||
|
||
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, slots=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-битное значение контрольной суммы.
|
||
"""
|
||
return binascii.crc32(data) & 0xFFFFFFFF
|
||
|
||
|
||
def build_frame(frame: Frame) -> bytes:
|
||
"""@brief Кодирует полный wire-кадр транспортного протокола.
|
||
|
||
@param frame Предварительно проверенная модель кадра.
|
||
@return SOF, big-endian header, payload и little-endian CRC32.
|
||
"""
|
||
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.crc_errors = 0
|
||
self.version_errors = 0
|
||
self.length_errors = 0
|
||
|
||
def reset(self) -> None:
|
||
"""@brief Удаляет незавершённые входные данные.
|
||
|
||
Счётчики диагностики сохраняются для анализа качества соединения.
|
||
"""
|
||
self._buffer.clear()
|
||
|
||
def feed(self, data: bytes) -> list[Frame]:
|
||
"""@brief Добавляет произвольный фрагмент последовательного потока.
|
||
|
||
@param data Новые байты; границы фрагмента не обязаны совпадать с кадром.
|
||
@return Ноль или несколько полностью проверенных кадров.
|
||
"""
|
||
if not data:
|
||
return []
|
||
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
|