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:
317
python/protocan/gas_catalog.py
Normal file
317
python/protocan/gas_catalog.py
Normal 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
|
||||
Reference in New Issue
Block a user