"""Эталонная Python-реализация SET protocol v2. Источник wire-контракта находится в ``templates/c/set-protocol/PROTOCOL.md``. Модуль не импортирует Qt и используется SETGUI, host-тестами и утилитами. """ from __future__ import annotations import binascii import hashlib import struct from dataclasses import dataclass from enum import IntEnum, IntFlag from typing import Iterable SOF = b"\xA5\x5A" VERSION = 0x02 HEADER_SIZE = 14 CRC_SIZE = 4 MAX_PAYLOAD_SIZE = 512 FRAME_MAX = HEADER_SIZE + MAX_PAYLOAD_SIZE + CRC_SIZE NODE_LOCAL = 0x0000 NODE_BROADCAST = 0xFFFF class SetProtocolError(ValueError): """Кадр или payload нарушает обязательный контракт SETP v2.""" class FrameFlag(IntFlag): RESPONSE = 0x01 EVENT = 0x02 ERROR = 0x04 ACK_REQUIRED = 0x08 MORE = 0x10 PRIORITY = 0x20 KNOWN_FLAGS = int( FrameFlag.RESPONSE | FrameFlag.EVENT | FrameFlag.ERROR | FrameFlag.ACK_REQUIRED | FrameFlag.MORE | FrameFlag.PRIORITY ) class MessageType(IntEnum): PING = 0x0001 DEVICE_INFO = 0x0002 CAPABILITIES = 0x0003 DIAGNOSTICS = 0x0008 READ = 0x0009 WRITE = 0x000A LOG_READ = 0x0010 CATALOG = 0x0011 SUBSCRIBE = 0x0012 PUBLISH = 0x0013 UNSUBSCRIBE = 0x0014 FW_BEGIN = 0x0100 FW_DATA = 0x0101 FW_END = 0x0102 FW_ABORT = 0x0103 FW_STATUS = 0x0104 FW_ACTIVATE = 0x0105 class Status(IntEnum): OK = 0 INVALID_ARGUMENT = 1 INVALID_LENGTH = 2 NOT_FOUND = 3 ACCESS_DENIED = 4 BUSY = 5 NO_PROVIDER = 6 INTERNAL = 7 CRC = 8 SEQUENCE = 9 NO_SPACE = 10 UNSUPPORTED = 11 VERIFY_FAILED = 12 AUTH_FAILED = 13 TIMEOUT = 14 WRONG_STATE = 15 class Interface(IntEnum): NONE = 0 RS232 = 1 RS485 = 2 CAN = 3 USB_CDC = 4 ETHERNET_TCP = 5 ETHERNET_UDP = 6 class Feature(IntFlag): READ = 1 << 0 WRITE = 1 << 1 CATALOG = 1 << 2 SUBSCRIBE = 1 << 3 LOG_READ = 1 << 4 FIRMWARE = 1 << 5 DIAGNOSTICS = 1 << 6 class ValueEncoding(IntEnum): U16 = 1 I16 = 2 U32 = 3 I32 = 4 F32 = 5 BYTES = 6 class FirmwareFlag(IntFlag): RESUME = 0x01 SIGNED = 0x02 ERASE_SLOT = 0x04 ACTIVATE = 0x08 def crc32_ieee(data: bytes | bytearray | memoryview) -> int: return binascii.crc32(data) & 0xFFFFFFFF def _u16(value: int, name: str) -> int: if not 0 <= value <= 0xFFFF: raise SetProtocolError(f"{name} вне диапазона u16") return value def _u32(value: int, name: str) -> int: if not 0 <= value <= 0xFFFFFFFF: raise SetProtocolError(f"{name} вне диапазона u32") return value @dataclass(frozen=True) class Frame: message_type: int sequence: int payload: bytes = b"" flags: FrameFlag = FrameFlag(0) source: int = NODE_LOCAL destination: int = NODE_LOCAL def __post_init__(self) -> None: _u16(int(self.message_type), "message_type") _u16(self.sequence, "sequence") _u16(self.source, "source") _u16(self.destination, "destination") if int(self.flags) & ~KNOWN_FLAGS: raise SetProtocolError("зарезервированные флаги должны быть нулевыми") if len(self.payload) > MAX_PAYLOAD_SIZE: raise SetProtocolError(f"payload превышает {MAX_PAYLOAD_SIZE} байт") def build_frame(frame: Frame) -> bytes: protected = struct.pack( " Frame: """Разбирает ровно один SETP-кадр из UDP или другого datagram-носителя.""" packet = bytes(data) if len(packet) < HEADER_SIZE + CRC_SIZE or packet[:2] != SOF: raise SetProtocolError("датаграмма не содержит полный SETP-кадр") if packet[2] != VERSION: raise SetProtocolError(f"неподдерживаемая версия {packet[2]}") if packet[3] & ~KNOWN_FLAGS: raise SetProtocolError("зарезервированные флаги должны быть нулевыми") payload_length = int.from_bytes(packet[12:14], "little") if payload_length > MAX_PAYLOAD_SIZE: raise SetProtocolError("payload датаграммы превышает лимит") expected_length = HEADER_SIZE + payload_length + CRC_SIZE if len(packet) != expected_length: raise SetProtocolError("длина UDP-датаграммы не совпадает с SETP header") if crc32_ieee(packet[2:-CRC_SIZE]) != int.from_bytes(packet[-CRC_SIZE:], "little"): raise SetProtocolError("неверный CRC32 датаграммы") _, flags, message_type, source, destination, sequence, _ = struct.unpack_from( " None: self._buffer = bytearray() self.stats = ParserStats() def reset(self) -> None: self._buffer.clear() @property def buffered_bytes(self) -> int: return len(self._buffer) def feed(self, data: bytes | bytearray | memoryview) -> list[Frame]: if not data: return [] self._buffer.extend(data) frames: list[Frame] = [] while True: sof_index = self._buffer.find(SOF) if sof_index < 0: keep = 1 if self._buffer[-1:] == SOF[:1] else 0 self.stats.stray_bytes += len(self._buffer) - keep self._buffer[:] = self._buffer[-1:] if keep else b"" break if sof_index: self.stats.stray_bytes += sof_index del self._buffer[:sof_index] if len(self._buffer) < HEADER_SIZE: break if self._buffer[2] != VERSION: self.stats.version_errors += 1 del self._buffer[0] continue flags = self._buffer[3] if flags & ~KNOWN_FLAGS: self.stats.flag_errors += 1 del self._buffer[0] continue payload_length = int.from_bytes(self._buffer[12:14], "little") if payload_length > MAX_PAYLOAD_SIZE: self.stats.length_errors += 1 del self._buffer[0] continue total = HEADER_SIZE + payload_length + CRC_SIZE if len(self._buffer) < total: break packet = bytes(self._buffer[:total]) protected = packet[2:-CRC_SIZE] received_crc = int.from_bytes(packet[-CRC_SIZE:], "little") if crc32_ieee(protected) != received_crc: self.stats.crc_errors += 1 del self._buffer[0] continue ( _version, _flags, message_type, source, destination, sequence, _payload_length, ) = struct.unpack_from(" bytes: return struct.pack(" tuple[int, bytes]: if not frame.flags & FrameFlag.RESPONSE: raise SetProtocolError("кадр не является ответом") if len(frame.payload) < 2: raise SetProtocolError("ответ не содержит status") return int.from_bytes(frame.payload[:2], "little"), frame.payload[2:] DEVICE_INFO_SCHEMA_VERSION = 1 DEVICE_INFO_FIXED_SIZE = 25 DEVICE_MODEL_MAX = 63 CAPABILITIES_SCHEMA_VERSION = 1 CAPABILITIES_SIZE = 20 @dataclass(frozen=True) class DeviceInfo: """Канонический body ответа DEVICE_INFO, без двухбайтового status.""" device_class: int hardware_version: int firmware_version: int dictionary_version: int serial_number: int model: str schema_version: int = DEVICE_INFO_SCHEMA_VERSION def encode(self) -> bytes: _u16(self.schema_version, "schema_version") if self.schema_version != DEVICE_INFO_SCHEMA_VERSION: raise SetProtocolError("неподдерживаемая схема DEVICE_INFO") _u16(self.device_class, "device_class") _u32(self.hardware_version, "hardware_version") _u32(self.firmware_version, "firmware_version") _u32(self.dictionary_version, "dictionary_version") if not 0 <= self.serial_number <= 0xFFFFFFFFFFFFFFFF: raise SetProtocolError("serial_number вне диапазона u64") model = self.model.encode("utf-8") if len(model) > DEVICE_MODEL_MAX: raise SetProtocolError("model длиннее 63 байт UTF-8") return struct.pack( " "DeviceInfo": if len(payload) < DEVICE_INFO_FIXED_SIZE: raise SetProtocolError("DEVICE_INFO короче фиксированного заголовка") values = struct.unpack_from(" DEVICE_MODEL_MAX or len(payload) != DEVICE_INFO_FIXED_SIZE + model_length: raise SetProtocolError("DEVICE_INFO содержит неверную длину model") try: model = payload[DEVICE_INFO_FIXED_SIZE:].decode("utf-8") except UnicodeDecodeError as error: raise SetProtocolError("DEVICE_INFO model не является UTF-8") from error return cls( device_class=values[1], hardware_version=values[2], firmware_version=values[3], dictionary_version=values[4], serial_number=values[5], model=model, schema_version=values[0], ) @dataclass(frozen=True) class Capabilities: """Канонический body ответа CAPABILITIES, без status.""" max_payload: int interface_mask: int feature_flags: int max_read_items: int max_write_items: int max_subscriptions: int max_publish_items: int schema_version: int = CAPABILITIES_SCHEMA_VERSION def encode(self) -> bytes: if self.schema_version != CAPABILITIES_SCHEMA_VERSION: raise SetProtocolError("неподдерживаемая схема CAPABILITIES") if not 1 <= self.max_payload <= MAX_PAYLOAD_SIZE: raise SetProtocolError("max_payload вне базового профиля") _u32(self.interface_mask, "interface_mask") _u32(self.feature_flags, "feature_flags") for name in ( "max_read_items", "max_write_items", "max_subscriptions", "max_publish_items", ): _u16(getattr(self, name), name) return struct.pack( " "Capabilities": if len(payload) != CAPABILITIES_SIZE: raise SetProtocolError("CAPABILITIES должен содержать ровно 20 байт") values = struct.unpack(" tuple[Interface, ...]: return tuple( interface for interface in Interface if interface is not Interface.NONE and self.interface_mask & (1 << interface) ) @property def features(self) -> Feature: return Feature(self.feature_flags) FW_BEGIN_FIXED_SIZE = 58 FW_DATA_HEADER_SIZE = 12 FW_END_SIZE = 40 @dataclass(frozen=True) class FirmwareBegin: image_size: int image_crc32: int image_version: int base_address: int slot: int block_size: int sha256: bytes flags: FirmwareFlag = FirmwareFlag(0) signing_key_id: int = 0 signature: bytes = b"" def encode(self) -> bytes: _u32(self.image_size, "image_size") _u32(self.image_crc32, "image_crc32") _u32(self.image_version, "image_version") _u32(self.base_address, "base_address") _u32(self.signing_key_id, "signing_key_id") if not 0 <= self.slot <= 0xFF: raise SetProtocolError("slot вне диапазона u8") if not 1 <= self.block_size <= MAX_PAYLOAD_SIZE - FW_DATA_HEADER_SIZE: raise SetProtocolError("block_size не помещается в FW_DATA") if len(self.sha256) != 32: raise SetProtocolError("SHA-256 должен содержать 32 байта") if self.flags & FirmwareFlag.SIGNED and not self.signature: raise SetProtocolError("SIGNED требует подпись") _u16(len(self.signature), "signature_length") payload = struct.pack( " MAX_PAYLOAD_SIZE: raise SetProtocolError("FW_BEGIN не помещается в payload") return payload @classmethod def decode(cls, payload: bytes) -> "FirmwareBegin": if len(payload) < FW_BEGIN_FIXED_SIZE: raise SetProtocolError("FW_BEGIN короче фиксированного заголовка") values = struct.unpack_from(" bytes: """Канонические байты, которые подписывает поставщик прошивки.""" return b"SETPFW2\0" + struct.pack( " FirmwareBegin: return FirmwareBegin( image_size=len(image), image_crc32=crc32_ieee(image), image_version=image_version, base_address=base_address, slot=slot, block_size=block_size, sha256=hashlib.sha256(image).digest(), flags=FirmwareFlag.RESUME, ) def encode_firmware_data(offset: int, data: bytes, flags: int = 0) -> bytes: _u32(offset, "offset") _u16(flags, "flags") if not data or len(data) > MAX_PAYLOAD_SIZE - FW_DATA_HEADER_SIZE: raise SetProtocolError("неверный размер блока FW_DATA") return struct.pack(" tuple[int, int, bytes]: if len(payload) < FW_DATA_HEADER_SIZE: raise SetProtocolError("FW_DATA короче заголовка") offset, length, flags, expected_crc = struct.unpack_from(" bytes: items = tuple(addresses) _u16(subscription_id, "subscription_id") _u32(period_ms, "period_ms") _u16(len(items), "address_count") for address in items: _u32(address, "address") payload = struct.pack(" MAX_PAYLOAD_SIZE: raise SetProtocolError("SUBSCRIBE не помещается в payload") return payload def decode_subscribe(payload: bytes) -> tuple[int, int, tuple[int, ...]]: if len(payload) < 8: raise SetProtocolError("SUBSCRIBE короче заголовка") subscription_id, period_ms, count = struct.unpack_from(" bytes: values = tuple(items) _u16(subscription_id, "subscription_id") _u16(sample_sequence, "sample_sequence") _u32(timestamp_ms, "timestamp_ms") _u16(len(values), "item_count") chunks = [struct.pack(" 0xFFFF: raise SetProtocolError("элемент PUBLISH не содержит данные") chunks.append( struct.pack( " MAX_PAYLOAD_SIZE: raise SetProtocolError("PUBLISH не помещается в один payload") return payload def decode_publish(payload: bytes) -> tuple[int, int, int, tuple[PublishItem, ...]]: if len(payload) < 10: raise SetProtocolError("PUBLISH короче заголовка") subscription_id, sample_sequence, timestamp_ms, count = struct.unpack_from( " len(payload): raise SetProtocolError("PUBLISH обрывается в заголовке элемента") address, encoding, element_count, data_length = struct.unpack_from( " len(payload): raise SetProtocolError("PUBLISH содержит повреждённый элемент") try: value_encoding = ValueEncoding(encoding) except ValueError as error: raise SetProtocolError(f"неизвестный encoding {encoding}") from error items.append( PublishItem( address=address, encoding=value_encoding, element_count=element_count, data=payload[offset : offset + data_length], ) ) offset += data_length if offset != len(payload): raise SetProtocolError("PUBLISH содержит хвост после элементов") return subscription_id, sample_sequence, timestamp_ms, tuple(items)