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 не собиралась.
This commit is contained in:
285
python/protocan/protocol.py
Normal file
285
python/protocan/protocol.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""Совместимое с ``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
|
||||
Reference in New Issue
Block a user