# -*- 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)