"""Каталог общего адресного пространства и поток выбранных значений. Прибор объявляет, какие регистры у него есть и как они называются, GUI выбирает подмножество и получает его пакетами. Схема повторяет реестр регистров ST Motor Control Workbench. Модуль не импортирует Qt: кодеки переносимы и проверяются host-тестами. Двоичный контракт описан в ``protocan-transport/docs/GUI_CATALOG.md`` и продублирован на C в ``gui/gui_catalog.c``. """ from __future__ import annotations from dataclasses import dataclass from enum import IntEnum from .protocol import MAX_PAYLOAD_SIZE, ProtocolError, decode_u16, decode_u32, encode_u16 #: Длина одной записи каталога на линии. ENTRY_SIZE = 32 #: Длина заголовка ответа GAS_CATALOG. CATALOG_HEADER_SIZE = 6 #: Длина поля имени в записи. 24 байта - это 12 кириллических #: символов в UTF-8; на 16 байтах не помещалось даже "Температура". NAME_SIZE = 24 #: Максимум адресов в одной подписке. WATCH_MAX = 64 #: Сколько записей каталога помещается в один кадр. ENTRIES_PER_FRAME = (MAX_PAYLOAD_SIZE - CATALOG_HEADER_SIZE) // ENTRY_SIZE #: Сколько значений помещается в один кадр потока. VALUES_PER_FRAME = (MAX_PAYLOAD_SIZE - 6) // 2 class ObjectType(IntEnum): """Формат значения регистра.""" U16 = 0 I16 = 1 U32 = 2 I32 = 3 BITS = 4 @property def registers(self) -> int: """Сколько подряд идущих адресов занимает значение.""" return 2 if self in (ObjectType.U32, ObjectType.I32) else 1 @property def signed(self) -> bool: return self in (ObjectType.I16, ObjectType.I32) #: Доступ и признаки записи каталога. FLAG_READABLE = 0x01 FLAG_WRITABLE = 0x02 FLAG_DEFAULT_WATCH = 0x04 #: Единицы измерения; коды входят в wire-контракт и не перенумеровываются. UNITS: dict[int, str] = { 0: "", 1: "В", 2: "А", 3: "°C", 4: "%", 5: "Гц", 6: "мс", 7: "с", 8: "кбит/с", 9: "шт.", 10: "об/мин", 11: "Вт", } @dataclass(frozen=True, slots=True) class ObjectEntry: """@brief Одна запись каталога общего адресного пространства. @param address Адрес первого регистра значения. @param type Формат значения. @param flags Биты доступа ``FLAG_*``. @param scale_pow10 Показатель степени: физическое = raw * 10^scale. @param unit Код единицы измерения из ``UNITS``. @param name Имя для оператора, до 24 байт в UTF-8. """ address: int type: ObjectType flags: int scale_pow10: int unit: int name: str @property def readable(self) -> bool: return bool(self.flags & FLAG_READABLE) @property def writable(self) -> bool: return bool(self.flags & FLAG_WRITABLE) @property def default_watch(self) -> bool: return bool(self.flags & FLAG_DEFAULT_WATCH) @property def unit_text(self) -> str: return UNITS.get(self.unit, "?") @property def registers(self) -> int: return self.type.registers def scale(self, raw: int) -> float: """@brief Переводит сырое значение в физическую величину. @param raw Слово или пара слов, уже собранные в целое. @return Значение с учётом знака и множителя ``10^scale_pow10``. """ width = 32 if self.registers == 2 else 16 if self.type.signed and raw >= (1 << (width - 1)): raw -= 1 << width return raw * (10.0 ** self.scale_pow10) def format(self, raw: int) -> str: """@brief Готовая подпись значения для таблицы GUI.""" if self.type is ObjectType.BITS: width = 4 * self.registers return f"0x{raw:0{width}X}" value = self.scale(raw) decimals = max(0, -self.scale_pow10) text = f"{value:.{decimals}f}" return f"{text} {self.unit_text}".strip() def encode_entry(entry: ObjectEntry) -> bytes: """@brief Кодирует запись каталога в 32 байта. @raises ProtocolError Если имя не влезает в 24 байта UTF-8. """ name = entry.name.encode("utf-8") if len(name) > NAME_SIZE: raise ProtocolError(f"имя '{entry.name}' длиннее {NAME_SIZE} байт в UTF-8") if not 0 <= entry.address <= 0xFFFF: raise ProtocolError("адрес вне диапазона 0..0xFFFF") if not -128 <= entry.scale_pow10 <= 127: raise ProtocolError("scale_pow10 вне диапазона int8") return ( encode_u16(entry.address) + bytes((int(entry.type) & 0xFF, entry.flags & 0xFF, entry.scale_pow10 & 0xFF, entry.unit & 0xFF)) + b"\x00\x00" + name.ljust(NAME_SIZE, b"\x00") ) def decode_entry(data: bytes, offset: int = 0) -> ObjectEntry: """@brief Разбирает 32 байта записи каталога. Неизвестный код типа не роняет разбор: запись остаётся видимой оператору как ``U16``, иначе новая прошивка сделала бы старый GUI полностью слепым. """ if offset + ENTRY_SIZE > len(data): raise ProtocolError("payload короче записи каталога") chunk = data[offset:offset + ENTRY_SIZE] raw_type = chunk[2] try: object_type = ObjectType(raw_type) except ValueError: object_type = ObjectType.U16 scale = chunk[4] - 256 if chunk[4] >= 128 else chunk[4] name = chunk[8:8 + NAME_SIZE].split(b"\x00", 1)[0].decode("utf-8", "replace") return ObjectEntry( address=decode_u16(chunk), type=object_type, flags=chunk[3], scale_pow10=scale, unit=chunk[5], name=name, ) def build_catalog_request(start_index: int = 0, max_count: int = 0) -> bytes: """@brief Payload запроса каталога. @param start_index Порядковый номер записи, а не адрес. @param max_count Сколько записей вернуть; 0 — сколько влезет в кадр. """ return encode_u16(start_index) + encode_u16(max_count) def build_catalog_response(total: int, start_index: int, entries: list[ObjectEntry]) -> bytes: """@brief Payload ответа каталога (нужен mock-режиму и тестам).""" if len(entries) > ENTRIES_PER_FRAME: raise ProtocolError(f"в кадр входит не более {ENTRIES_PER_FRAME} записей") return (encode_u16(total) + encode_u16(start_index) + encode_u16(len(entries)) + b"".join(encode_entry(item) for item in entries)) @dataclass(frozen=True, slots=True) class CatalogChunk: """@brief Часть каталога из одного кадра. @param total Полный размер каталога прибора. @param start_index Индекс первой записи в этом куске. @param entries Записи в порядке прибора. """ total: int start_index: int entries: tuple[ObjectEntry, ...] @property def complete(self) -> bool: """Дошёл ли каталог до конца именно этим куском.""" return self.start_index + len(self.entries) >= self.total def decode_catalog(payload: bytes) -> CatalogChunk: """@brief Разбирает кадр ``GAS_CATALOG`` от прибора.""" if len(payload) < CATALOG_HEADER_SIZE: raise ProtocolError("GAS_CATALOG короче 6 байт") total = decode_u16(payload) start_index = decode_u16(payload, 2) count = decode_u16(payload, 4) if len(payload) != CATALOG_HEADER_SIZE + count * ENTRY_SIZE: raise ProtocolError("GAS_CATALOG содержит неверное число записей") if start_index + count > total: raise ProtocolError("GAS_CATALOG выходит за объявленный размер каталога") entries = tuple( decode_entry(payload, CATALOG_HEADER_SIZE + index * ENTRY_SIZE) for index in range(count) ) return CatalogChunk(total=total, start_index=start_index, entries=entries) def build_watch_set(period_ms: int, addresses: list[int]) -> bytes: """@brief Payload подписки на поток значений. @param period_ms Период потока; 0 останавливает поток. @param addresses Адреса в нужном порядке, не больше ``WATCH_MAX``. """ if not 0 <= period_ms <= 0xFFFF: raise ProtocolError("период вне диапазона 0..65535 мс") if len(addresses) > WATCH_MAX: raise ProtocolError(f"в подписке не больше {WATCH_MAX} адресов") return (encode_u16(period_ms) + encode_u16(len(addresses)) + b"".join(encode_u16(item) for item in addresses)) def decode_watch_set(payload: bytes) -> tuple[int, list[int]]: """@brief Разбирает подписку; на стороне прибора и в mock-режиме.""" if len(payload) < 4: raise ProtocolError("GAS_WATCH_SET короче 4 байт") period_ms = decode_u16(payload) count = decode_u16(payload, 2) if len(payload) != 4 + count * 2: raise ProtocolError("GAS_WATCH_SET содержит неверное число адресов") return period_ms, [decode_u16(payload, 4 + index * 2) for index in range(count)] def decode_watch_ack(payload: bytes) -> tuple[int, int]: """@brief Разбирает эхо прибора: принятый период и число адресов.""" if len(payload) < 4: raise ProtocolError("эхо GAS_WATCH_SET короче 4 байт") return decode_u16(payload), decode_u16(payload, 2) def build_watch_data(timestamp_ms: int, values: list[int]) -> bytes: """@brief Payload пакета значений (нужен mock-режиму и тестам).""" if len(values) > VALUES_PER_FRAME: raise ProtocolError(f"в кадр входит не более {VALUES_PER_FRAME} значений") return (int(timestamp_ms & 0xFFFFFFFF).to_bytes(4, "little") + encode_u16(len(values)) + b"".join(encode_u16(item & 0xFFFF) for item in values)) def decode_watch_data(payload: bytes) -> tuple[int, list[int]]: """@brief Разбирает пакет потока. @return Время прибора в миллисекундах и значения в порядке подписки. """ if len(payload) < 6: raise ProtocolError("GAS_WATCH_DATA короче 6 байт") timestamp_ms = decode_u32(payload) count = decode_u16(payload, 4) if len(payload) != 6 + count * 2: raise ProtocolError("GAS_WATCH_DATA содержит неверное число значений") return timestamp_ms, [decode_u16(payload, 6 + index * 2) for index in range(count)] def assemble(entries: list[ObjectEntry], values: dict[int, int]) -> dict[int, int]: """@brief Собирает многословные значения из потока сырых слов. @param entries Записи каталога, описывающие ширину каждого значения. @param values Сырые слова по адресам, как пришли в потоке. @return Значение по адресу первого слова; ширина учтена. """ result: dict[int, int] = {} for entry in entries: low = values.get(entry.address) if low is None: continue if entry.registers == 1: result[entry.address] = low continue high = values.get(entry.address + 1) if high is None: # Старшее слово не подписано - показывать половину числа хуже, # чем не показывать ничего. continue result[entry.address] = low | (high << 16) return result