Files
templates/python/protocan/gas_catalog.py
Andrey Kruchinkin 02da823a94 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 не собиралась.
2026-08-23 01:15:36 +03:00

318 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Каталог общего адресного пространства и поток выбранных значений.
Прибор объявляет, какие регистры у него есть и как они называются, 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