Собрано из 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 не собиралась.
318 lines
13 KiB
Python
318 lines
13 KiB
Python
"""Каталог общего адресного пространства и поток выбранных значений.
|
||
|
||
Прибор объявляет, какие регистры у него есть и как они называются, 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
|