"""Совместимое с ``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