Files
templates/python/protocan/protocol.py
Andrey Kruchinkin 02da823a94 feat(protocan-py): хостовые кодеки ProtoCAN и каталога на Python
Собрано из 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 не собиралась.
2026-08-23 01:15:36 +03:00

286 lines
10 KiB
Python
Raw Blame History

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