feat(protocan-py): хостовые кодеки ProtoCAN и каталога на Python

Собрано из 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 не собиралась.
This commit is contained in:
2026-08-23 01:15:36 +03:00
parent 34cbd92808
commit 02da823a94
6 changed files with 1388 additions and 0 deletions

View File

@@ -0,0 +1,317 @@
"""Каталог общего адресного пространства и поток выбранных значений.
Прибор объявляет, какие регистры у него есть и как они называются, 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