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