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

519
python/protocan/protocan.py Normal file
View File

@@ -0,0 +1,519 @@
# -*- coding: utf-8 -*-
"""Разбор и сборка сообщений ProtoCAN — прикладного уровня шины CAN.
Раскладка полей повторяет ``ProtoCanId_t`` из SETCAN/Inc/protocan.h:
битовые поля укладываются от младших бит к старшим.
Модуль не импортирует Qt: разбор переносим и проверяется host-тестами.
Источник контракта — CAN_to_RS485/docs/PROTOCOL.md, §1.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from typing import List, Optional
# --------------------------------------------------------------------------
# Перечисления протокола
# --------------------------------------------------------------------------
class Priority(IntEnum):
CRITICAL = 0
STANDARD = 1
class Route(IntEnum):
FROM_PM = 0
FROM_DEVICE = 1
class MsgType(IntEnum):
BROADCAST = 0b0000
DISCRETE = 0b0001
ANALOG = 0b0010
GENERAL_ADDRESS_SPACE = 0b0011
MODBUS_COIL = 0b0100
MODBUS_DISCRETE = 0b0101
MODBUS_HOLDING = 0b0110
MODBUS_INPUT = 0b0111
ERROR = 0b1000
PULSE = 0b1111
class BroadcastType(IntEnum):
STATUS = 0
ONOFF = 1
RESTARTDEVICE = 2
RTCSETUP = 3
END = 0xFFF
class DiscreteType(IntEnum):
ACCIDENT = 0
WARNING = 1
CONTROL_SIGNALS = 2
FLAGS = 3
RESET = 4
CHANGE_MODE = 5
REQUEST_LIST_OF_PARAMETERS = 6
END = 0xF
class AnalogType(IntEnum):
UNIVERSAL = 0
SETTINGS = 1
U = 2
I = 3
T = 4
END = 0xF
PRIORITY_RU = {
Priority.CRITICAL: "критический",
Priority.STANDARD: "стандартный",
}
ROUTE_RU = {
Route.FROM_PM: "от мастера (PM)",
Route.FROM_DEVICE: "от устройства",
}
MSGTYPE_RU = {
MsgType.BROADCAST: "Broadcast",
MsgType.DISCRETE: "Discrete",
MsgType.ANALOG: "Analog",
MsgType.GENERAL_ADDRESS_SPACE: "General Address Space",
MsgType.MODBUS_COIL: "Modbus Coil",
MsgType.MODBUS_DISCRETE: "Modbus Discrete",
MsgType.MODBUS_HOLDING: "Modbus Holding",
MsgType.MODBUS_INPUT: "Modbus Input",
MsgType.ERROR: "Error",
MsgType.PULSE: "Pulse",
}
BROADCAST_RU = {
BroadcastType.STATUS: "запрос статуса",
BroadcastType.ONOFF: "вкл/выкл пульса",
BroadcastType.RESTARTDEVICE: "перезапуск устройства",
BroadcastType.RTCSETUP: "установка RTC",
BroadcastType.END: "конец диапазона",
}
DISCRETE_RU = {
DiscreteType.ACCIDENT: "авария",
DiscreteType.WARNING: "предупреждение",
DiscreteType.CONTROL_SIGNALS: "управляющие сигналы",
DiscreteType.FLAGS: "флаги",
DiscreteType.RESET: "сброс",
DiscreteType.CHANGE_MODE: "смена режима",
DiscreteType.REQUEST_LIST_OF_PARAMETERS: "запрос списка параметров",
DiscreteType.END: "конец диапазона",
}
ANALOG_RU = {
AnalogType.UNIVERSAL: "универсальный",
AnalogType.SETTINGS: "уставки",
AnalogType.U: "напряжение U",
AnalogType.I: "ток I",
AnalogType.T: "температура T",
AnalogType.END: "конец диапазона",
}
WEEKDAY_RU = {
1: "Пн", 2: "Вт", 3: "Ср", 4: "Чт", 5: "Пт", 6: "Сб", 7: "Вс", 0: "?",
}
def _name(enum_cls, value, table):
"""Имя элемента перечисления либо «резерв» для неизвестного кода."""
try:
item = enum_cls(value)
except ValueError:
return "резерв 0x%X" % value
return "%s (%s)" % (item.name, table.get(item, ""))
# --------------------------------------------------------------------------
# Идентификатор
# --------------------------------------------------------------------------
@dataclass
class ProtoCanId:
"""Разобранный 29-битный идентификатор."""
raw: int
msg_body: int
msg_type: int
device_id: int
device_type: int
route: int
priority: int
@staticmethod
def parse(raw: int) -> "ProtoCanId":
raw &= 0x1FFFFFFF
return ProtoCanId(
raw=raw,
msg_body=raw & 0xFFFF,
msg_type=(raw >> 16) & 0xF,
device_id=(raw >> 20) & 0xF,
device_type=(raw >> 24) & 0x7,
route=(raw >> 27) & 0x1,
priority=(raw >> 28) & 0x1,
)
@staticmethod
def build(priority: int, route: int, device_type: int, device_id: int,
msg_type: int, msg_body: int) -> int:
return (((priority & 0x1) << 28)
| ((route & 0x1) << 27)
| ((device_type & 0x7) << 24)
| ((device_id & 0xF) << 20)
| ((msg_type & 0xF) << 16)
| (msg_body & 0xFFFF))
@property
def msg_type_name(self) -> str:
try:
return MSGTYPE_RU[MsgType(self.msg_type)]
except ValueError:
return "резерв 0x%X" % self.msg_type
# --------------------------------------------------------------------------
# Раскладки MsgBody
# --------------------------------------------------------------------------
def split_broadcast(body: int):
"""BROADCAST: Body[3:0], Type[15:4]."""
return body & 0xF, (body >> 4) & 0xFFF
def split_discrete(body: int):
"""DISCRETE: Body[11:0], Type[15:12]."""
return body & 0xFFF, (body >> 12) & 0xF
def split_analog(body: int):
"""ANALOG: SensorID[11:0], Type[15:12]."""
return body & 0xFFF, (body >> 12) & 0xF
def split_modbus(body: int):
"""MODBUS: RegCount[3:0], StrAdr[15:4]."""
return (body >> 4) & 0xFFF, body & 0xF
def split_error(body: int):
"""ERROR: ErrorCode[7:0], Info[15:8]."""
return body & 0xFF, (body >> 8) & 0xFF
def merge_broadcast(bcast_type: int, body: int) -> int:
return ((bcast_type & 0xFFF) << 4) | (body & 0xF)
def merge_discrete(disc_type: int, body: int) -> int:
return ((disc_type & 0xF) << 12) | (body & 0xFFF)
def merge_analog(an_type: int, sensor_id: int) -> int:
return ((an_type & 0xF) << 12) | (sensor_id & 0xFFF)
def merge_modbus(str_adr: int, reg_count: int) -> int:
return ((str_adr & 0xFFF) << 4) | (reg_count & 0xF)
def merge_error(info: int, code: int) -> int:
return ((info & 0xFF) << 8) | (code & 0xFF)
def sensor_to_modbus_register(sensor_type: int, sensor_id: int) -> int:
"""Макрос SensorToModbusRegister из protocan.h."""
return ((sensor_type << 11) | sensor_id) & 0xFFFF
def regs_le(data: bytes) -> List[int]:
"""Пары байт -> регистры u16 little-endian (порядок из PROTOCAN_SEND_*)."""
return [data[i] | (data[i + 1] << 8) for i in range(0, len(data) - 1, 2)]
# --------------------------------------------------------------------------
# Результат разбора
# --------------------------------------------------------------------------
@dataclass
class Decoded:
"""Полный разбор одного кадра."""
id: ProtoCanId
dlc: int
data: bytes
ide: bool = True
rtr: bool = False
#: Короткая сводка для колонки таблицы
summary: str = ""
#: Пары (поле, значение) для панели подробностей
fields: List[tuple] = field(default_factory=list)
#: Регистры, если сообщение их несёт
registers: Optional[List[tuple]] = None
#: Замечания о нарушениях протокола
warnings: List[str] = field(default_factory=list)
def _ascii(data: bytes) -> str:
return "".join(chr(b) if 32 <= b < 127 else "." for b in data)
def decode(raw_id: int, data: bytes, ide: bool = True, rtr: bool = False) -> Decoded:
"""Разбирает кадр ProtoCAN в структуру Decoded."""
pid = ProtoCanId.parse(raw_id)
data = bytes(data[:8])
res = Decoded(id=pid, dlc=len(data), data=data, ide=ide, rtr=rtr)
res.fields.append(("Priority", "%d — %s" % (
pid.priority, PRIORITY_RU.get(Priority(pid.priority), ""))))
res.fields.append(("Route", "%d — %s" % (
pid.route, ROUTE_RU.get(Route(pid.route), ""))))
res.fields.append(("DeviceType", "%d (0b{:03b})".format(pid.device_type) % pid.device_type))
res.fields.append(("DeviceID", "%d (0b{:04b})".format(pid.device_id) % pid.device_id))
res.fields.append(("MsgType", "0x%X — %s" % (pid.msg_type, pid.msg_type_name)))
res.fields.append(("MsgBody", "0x%04X" % pid.msg_body))
if not ide:
res.warnings.append("Стандартный ID: ProtoCAN использует расширенный (29 бит)")
if rtr:
res.warnings.append("Remote frame: полезная нагрузка отсутствует")
handler = {
MsgType.BROADCAST: _decode_broadcast,
MsgType.DISCRETE: _decode_discrete,
MsgType.ANALOG: _decode_analog,
MsgType.GENERAL_ADDRESS_SPACE: _decode_gas,
MsgType.MODBUS_COIL: _decode_modbus_bit,
MsgType.MODBUS_DISCRETE: _decode_modbus_bit,
MsgType.MODBUS_HOLDING: _decode_modbus_reg,
MsgType.MODBUS_INPUT: _decode_modbus_reg,
MsgType.ERROR: _decode_error,
MsgType.PULSE: _decode_pulse,
}
try:
fn = handler[MsgType(pid.msg_type)]
except ValueError:
res.summary = "Неизвестный MsgType 0x%X" % pid.msg_type
res.warnings.append("MsgType 0x%X не описан в protocan.h" % pid.msg_type)
if data:
res.fields.append(("Data ASCII", _ascii(data)))
return res
fn(res)
return res
def _decode_broadcast(res: Decoded) -> None:
body, btype = split_broadcast(res.id.msg_body)
res.fields.append((" Broadcast.Type", "0x%03X — %s" % (
btype, _name(BroadcastType, btype, BROADCAST_RU))))
res.fields.append((" Broadcast.Body", "0x%X (%d)" % (body, body)))
if btype == BroadcastType.STATUS:
res.summary = "BROADCAST STATUS"
if res.dlc == 7:
h, m, s, yy, mo, dd, wd = res.data
res.summary = "BROADCAST STATUS: 20%02d-%02d-%02d %02d:%02d:%02d" % (
yy, mo, dd, h, m, s)
res.fields += [
(" Время", "%02d:%02d:%02d" % (h, m, s)),
(" Дата", "20%02d-%02d-%02d" % (yy, mo, dd)),
(" День недели", "%d (%s)" % (wd, WEEKDAY_RU.get(wd, "?"))),
]
_check_datetime(res, h, m, s, yy, mo, dd, wd)
elif res.dlc == 0:
res.summary = "BROADCAST STATUS (запрос)"
else:
res.warnings.append("STATUS-ответ должен иметь DLC = 7, получено %d" % res.dlc)
elif btype == BroadcastType.ONOFF:
res.summary = "BROADCAST ONOFF (инверсия флага пульса)"
elif btype == BroadcastType.RESTARTDEVICE:
page = int.from_bytes(res.data, "little") if res.data else 0
ids = [i for i in range(res.dlc * 8) if (page >> i) & 1]
res.summary = "BROADCAST RESTART: устройства %s" % (ids if ids else "нет")
res.fields.append((" Битовая карта", "0x%X" % page))
res.fields.append((" Перезапустить ID", ", ".join(map(str, ids)) or "—"))
if res.dlc == 0:
res.warnings.append("RESTART с DLC = 0 отвергается устройством")
elif btype == BroadcastType.RTCSETUP:
res.summary = "BROADCAST RTCSETUP"
if res.dlc == 7:
h, m, s, yy, mo, dd, wd = res.data
res.summary = "BROADCAST RTCSETUP: 20%02d-%02d-%02d %02d:%02d:%02d" % (
yy, mo, dd, h, m, s)
res.fields += [
(" Время", "%02d:%02d:%02d" % (h, m, s)),
(" Дата", "20%02d-%02d-%02d" % (yy, mo, dd)),
(" День недели", "%d (%s)" % (wd, WEEKDAY_RU.get(wd, "?"))),
]
_check_datetime(res, h, m, s, yy, mo, dd, wd)
else:
res.warnings.append("RTCSETUP принимается только при DLC = 7, получено %d" % res.dlc)
else:
res.summary = "BROADCAST 0x%03X" % btype
def _is_leap(year: int) -> bool:
"""Совпадает с IsLeapYear() в protocan.c: год двузначный, 2000-е."""
y = 2000 + year
return (y % 4 == 0 and y % 100 != 0) or y % 400 == 0
_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
def _check_datetime(res: Decoded, h, m, s, yy, mo, dd, wd) -> None:
if h > 23:
res.warnings.append("Часы %d > 23" % h)
if m > 59:
res.warnings.append("Минуты %d > 59" % m)
if s > 59:
res.warnings.append("Секунды %d > 59" % s)
if yy > 99:
res.warnings.append("Год %d > 99" % yy)
if mo == 0 or mo > 12:
res.warnings.append("Месяц %d вне диапазона 1..12" % mo)
else:
limit = _DAYS[mo - 1] + (1 if (mo == 2 and _is_leap(yy)) else 0)
if dd == 0 or dd > limit:
res.warnings.append("Число %d вне диапазона 1..%d" % (dd, limit))
if wd > 6:
res.warnings.append("День недели %d > 6" % wd)
def _decode_discrete(res: Decoded) -> None:
body, dtype = split_discrete(res.id.msg_body)
res.fields.append((" Discrete.Type", "0x%X — %s" % (
dtype, _name(DiscreteType, dtype, DISCRETE_RU))))
res.fields.append((" Discrete.Body", "0x%03X (%d)" % (body, body)))
res.summary = "DISCRETE %s, body=%d, %d байт" % (
_name(DiscreteType, dtype, DISCRETE_RU), body, res.dlc)
if res.data:
bits = " ".join("{:08b}".format(b) for b in reversed(res.data))
res.fields.append((" Data биты (MSB..LSB)", bits))
res.fields.append((" Data ASCII", _ascii(res.data)))
if dtype == DiscreteType.REQUEST_LIST_OF_PARAMETERS:
mask = int.from_bytes(res.data, "little") if res.data else 0
idx = [i for i in range(res.dlc * 8) if (mask >> i) & 1]
res.fields.append((" Запрошены параметры", ", ".join(map(str, idx)) or "—"))
def _decode_analog(res: Decoded) -> None:
sensor_id, atype = split_analog(res.id.msg_body)
res.fields.append((" Analog.Type", "0x%X — %s" % (
atype, _name(AnalogType, atype, ANALOG_RU))))
res.fields.append((" Analog.SensorID", "%d (0x%03X)" % (sensor_id, sensor_id)))
res.fields.append((" Modbus-регистр", "0x%04X" % sensor_to_modbus_register(atype, sensor_id)))
text = _ascii(res.data)
res.summary = "ANALOG %s, sensor %d" % (_name(AnalogType, atype, ANALOG_RU), sensor_id)
if res.data:
res.fields.append((" Data ASCII", text))
res.summary += ", '%s'" % text
if len(res.data) >= 6 and res.data[:2] in (b"US", b"IS", b"TS"):
digits = res.data[2:6].decode("ascii", "replace")
if digits.isdigit():
res.fields.append((" Ответ по датчику", "%s №%d" % (
res.data[:2].decode(), int(digits))))
if int(digits) != sensor_id:
res.warnings.append(
"SensorID в ASCII (%d) не совпадает с ID в MsgBody (%d)"
% (int(digits), sensor_id))
if res.dlc == 0:
res.summary += " (запрос)"
def _decode_gas(res: Decoded) -> None:
start = res.id.msg_body
res.fields.append((" Адрес первого регистра", "0x%04X (%d)" % (start, start)))
if res.dlc % 2:
res.warnings.append("DLC = %d нечётный: регистры GAS передаются парами байт" % res.dlc)
vals = regs_le(res.data)
if len(vals) > 4:
res.warnings.append("В одном кадре GAS не более 4 регистров, получено %d" % len(vals))
res.registers = [(start + i, v) for i, v in enumerate(vals)]
if res.data[:4] == b"GAS-":
res.summary = "GAS отклик '%s'" % _ascii(res.data)
res.fields.append((" Data ASCII", _ascii(res.data)))
res.registers = None
return
for adr, v in res.registers:
res.fields.append((" Reg 0x%04X" % adr, "0x%04X (%d)" % (v, v)))
if vals:
res.summary = "GAS: %d рег. с 0x%04X = %s" % (
len(vals), start, " ".join("%04X" % v for v in vals))
else:
res.summary = "GAS: запрос по адресу 0x%04X" % start
def _decode_modbus_bit(res: Decoded) -> None:
str_adr, reg_count = split_modbus(res.id.msg_body)
kind = "COIL" if res.id.msg_type == MsgType.MODBUS_COIL else "DISCRETE"
res.fields.append((" Modbus.StrAdr", "0x%03X (%d)" % (str_adr, str_adr)))
res.fields.append((" Modbus.RegCount", "%d" % reg_count))
expected = reg_count % 8 + 1
if res.dlc and res.dlc != expected:
res.warnings.append(
"Для %s ожидается DLC = RegCount %% 8 + 1 = %d, получено %d"
% (kind, expected, res.dlc))
if len(res.data) >= 2:
val = res.data[0] | (res.data[1] << 8)
bits = ["%d" % ((val >> i) & 1) for i in range(16)]
res.registers = [(str_adr, val)]
res.fields.append((" Значение", "0x%04X" % val))
res.fields.append((" Биты 0..15", " ".join(bits)))
res.summary = "MODBUS %s @0x%03X x%d = 0x%04X" % (kind, str_adr, reg_count, val)
else:
res.summary = "MODBUS %s @0x%03X x%d (запрос)" % (kind, str_adr, reg_count)
def _decode_modbus_reg(res: Decoded) -> None:
str_adr, reg_count = split_modbus(res.id.msg_body)
kind = "HOLDING" if res.id.msg_type == MsgType.MODBUS_HOLDING else "INPUT"
res.fields.append((" Modbus.StrAdr", "0x%03X (%d)" % (str_adr, str_adr)))
res.fields.append((" Modbus.RegCount", "%d" % reg_count))
if res.dlc and res.dlc != reg_count * 2:
res.warnings.append(
"Для %s ожидается DLC = 2 * RegCount = %d, получено %d"
% (kind, reg_count * 2, res.dlc))
if reg_count > 4:
res.warnings.append("В одном кадре не более 4 регистров, в ID указано %d" % reg_count)
vals = regs_le(res.data)
res.registers = [(str_adr + i, v) for i, v in enumerate(vals)]
for adr, v in res.registers:
res.fields.append((" Reg 0x%04X" % adr, "0x%04X (%d)" % (v, v)))
if vals:
res.summary = "MODBUS %s @0x%03X: %s" % (
kind, str_adr, " ".join("%04X" % v for v in vals))
else:
res.summary = "MODBUS %s @0x%03X x%d (запрос)" % (kind, str_adr, reg_count)
def _decode_error(res: Decoded) -> None:
code, info = split_error(res.id.msg_body)
res.fields.append((" Error.Code", "0x%02X (%d)" % (code, code)))
res.fields.append((" Error.Info", "0x%02X (%d)" % (info, info)))
res.summary = "ERROR code=0x%02X info=0x%02X" % (code, info)
if code == 0xFF and info == 0x00:
res.summary += " — необработанный запрос (PROTOCAN_RequestError)"
if res.dlc:
res.warnings.append("ERROR передаётся с DLC = 0, получено %d" % res.dlc)
def _decode_pulse(res: Decoded) -> None:
if res.dlc >= 1:
res.summary = "PULSE, счётчик %d" % res.data[0]
res.fields.append((" Счётчик пульса", "%d (0x%02X)" % (res.data[0], res.data[0])))
else:
res.summary = "PULSE (без счётчика)"
res.warnings.append("PULSE передаётся с DLC = 1")
if res.id.msg_body:
res.warnings.append("MsgBody у PULSE должен быть 0, получено 0x%04X" % res.id.msg_body)