Files
templates/python/protocan/protocan.py
T
Andrey 876b58517c feat(set-protocol): Раздели GAS и добавь пакетный сбор регистров
Сохрани адресацию отдельных регистров GAS 0x3 и выдели слотам тип 0x4 с маской Word0. Добавь единое C-ядро GAS_WATCH2, тонкие Python/JNI-порты и проверки общих эталонных байтов.

Совместимость: тип 0x4 больше не обслуживает Modbus Coil. Потребителям PCAN_MSG_MODBUS_COIL и ProtoCanMsgToModbusCoil нужно перейти на GAS_SLOT и отдельный API слотов; регистровый GAS остаётся на 0x3.
2026-10-23 14:14:48 +03:00

796 lines
36 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.
# -*- coding: utf-8 -*-
# Явная упаковка полей в 29-битный идентификатор ProtoCAN и их извлечение. Маски и сдвиги
# задают переносимый wire-формат без зависимости от расположения битовых полей компилятора.
"""Разбор и сборка сообщений ProtoCAN — прикладного уровня шины CAN.
Раскладка полей повторяет ``ProtoCanId_t`` из SETCAN/Inc/protocan.h:
битовые поля укладываются от младших бит к старшим.
Модуль не импортирует Qt: разбор переносим и проверяется host-тестами.
Источник контракта — ``templates/c/set-protocol``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
from typing import List, Optional
try:
from .native import get_native_core
_SHARED_PROTOCOL = get_native_core()
except (ImportError, OSError, RuntimeError):
_SHARED_PROTOCOL = None
EXTENDED_ID_MASK = 0x1FFFFFFF
# --------------------------------------------------------------------------
# Перечисления протокола
# --------------------------------------------------------------------------
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
GAS_SLOT = 0b0100 # Тип 0x4 однозначно выбирает слотный GAS с маской вместо прежнего Modbus Coil.
MODBUS_DISCRETE = 0b0101
MODBUS_HOLDING = 0b0110
MODBUS_INPUT = 0b0111
ERROR = 0b1000
BOOT_CONTROL = 0b1001
BOOT_DATA_A = 0b1010
BOOT_DATA_B = 0b1011
BOOT_STATUS = 0b1100
BOOT_DISCOVERY = 0b1101
SETTINGS = 0b1110
PULSE = 0b1111
class SettingsResult(IntEnum):
OK = 0x00
INVALID_DLC = 0x01
INVALID_ROM_CRC = 0x02
NOT_FOUND = 0x03
ALREADY_ASSIGNED = 0x04
EEPROM_ERROR = 0x05
INVALID_LOCATION = 0x06
BUSY = 0x07
INVALID_VALUE = 0x08
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.GAS_SLOT: "GAS Slot", # Отдельное имя показывает, что адрес в Body относится к слоту, а не к Modbus-регистру.
MsgType.MODBUS_DISCRETE: "Modbus Discrete",
MsgType.MODBUS_HOLDING: "Modbus Holding",
MsgType.MODBUS_INPUT: "Modbus Input",
MsgType.ERROR: "Error",
MsgType.BOOT_CONTROL: "Boot Control",
MsgType.BOOT_DATA_A: "Boot Data A",
MsgType.BOOT_DATA_B: "Boot Data B",
MsgType.BOOT_STATUS: "Boot Status",
MsgType.BOOT_DISCOVERY: "Boot Discovery",
MsgType.SETTINGS: "Settings",
MsgType.PULSE: "Pulse",
}
#: Имена адресов из листа «Интерфейс протокола» исходного XLSX.
#: Для остальных Device Type таблица задаёт только диапазон Device 0x0..0xF,
#: но не присваивает приборам имён.
DEVICE_TYPE_NAMES = {
0x0: "Верхний уровень",
0x1: "PERIPH 0–15", # Excel «Интерфейс протокола», C6/F6: первый тип содержит младшие 16 номеров периферии.
0x2: "PERIPH 16–31", # Excel C7/F7: второй тип продолжает нумерацию, сохраняя четырёхбитный Device ID.
0x6: "Логические анализаторы",
}
DEVICE_ADDRESS_NAMES = {
(0x1, 0x5): "УМП1", # Excel B50/C50/F50 закрепляет отдельный адрес первого модуля нагрузки.
(0x1, 0x6): "УМП2", # Excel B51/C51/F51 отделяет второй модуль от первого и от задатчика.
(0x1, 0x8): "Задатчик", # Excel B53/C53/F53 задаёт адрес задатчика без сдвига старого CAN Mode на единицу.
(0x6, 0xE): "Altera Logic",
(0x7, 0xD): "configurator",
(0x7, 0xE): "STM Invertor", # Отдельный ПЧ STM32G474VET6: адрес 7/E проверен по портам Templates; 7/D и 7/F заняты.
}
def periph_address(periph_id: int) -> tuple[int, int]: # Возвращает адрес ProtoCAN для номера из 32 строк обновлённой таблицы PERIPH.
if not 0 <= periph_id < 32: # Число 32 задаёт полный диапазон двух типов по 16 экземпляров; за его пределами адрес не назначается.
raise ValueError("ID PERIPH должен быть в диапазоне 0…31") # Запрещает незаметное оборачивание ошибочного номера на другую плату.
return 1 + periph_id // 16, periph_id % 16 # Старшая группа выбирает Type 1/2, младшие четыре бита — Device ID 0…15.
SETTINGS_RESULT_RU = {
SettingsResult.OK: "успех",
SettingsResult.INVALID_DLC: "неверный DLC",
SettingsResult.INVALID_ROM_CRC: "неверный family code или CRC ROM",
SettingsResult.NOT_FOUND: "ROM не найден на 1-Wire",
SettingsResult.ALREADY_ASSIGNED: "ROM уже назначен другой локации",
SettingsResult.EEPROM_ERROR: "ошибка EEPROM",
SettingsResult.INVALID_LOCATION: "неверная локация",
SettingsResult.BUSY: "устройство занято",
SettingsResult.INVALID_VALUE: "значение настройки вне диапазона",
}
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, ""))
def device_name(device_type: int, device: int) -> str:
"""Имя пары Device Type/Device из исходной таблицы протокола."""
return DEVICE_ADDRESS_NAMES.get(
(device_type, device), DEVICE_TYPE_NAMES.get(device_type, ""))
# --------------------------------------------------------------------------
# Идентификатор
# --------------------------------------------------------------------------
@dataclass
class ProtoCanId:
"""Разобранный 29-битный идентификатор.
Имена полей повторяют исходную таблицу ``Протокол CAN и ОАП.xlsx``:
``Priority | ПМ | Device Type | Device | Msg Type | Body``. Свойства
``route``, ``device_id`` и ``msg_body`` оставлены как совместимые
псевдонимы для старого кода.
"""
raw: int
body: int
msg_type: int
device: int
device_type: int
pm: int
priority: int
@staticmethod
def parse(raw: int) -> "ProtoCanId":
raw &= EXTENDED_ID_MASK
if _SHARED_PROTOCOL is not None:
priority, pm, device_type, device, msg_type, body = (
_SHARED_PROTOCOL.id_unpack(raw))
return ProtoCanId(raw, body, msg_type, device, device_type, pm,
priority)
return ProtoCanId(
raw=raw,
body=raw & 0xFFFF,
msg_type=(raw >> 16) & 0xF,
device=(raw >> 20) & 0xF,
device_type=(raw >> 24) & 0x7,
pm=(raw >> 27) & 0x1,
priority=(raw >> 28) & 0x1,
)
@staticmethod
def build(priority: int, pm: Optional[int] = None,
device_type: Optional[int] = None, device: Optional[int] = None,
msg_type: Optional[int] = None, body: Optional[int] = None,
**legacy: int) -> int:
"""Собирает ID из полей протокола.
Старые именованные аргументы ``route``, ``device_id`` и
``msg_body`` принимаются для обратной совместимости.
"""
aliases = (
("route", "pm", pm),
("device_id", "device", device),
("msg_body", "body", body),
)
resolved = []
for old_name, new_name, value in aliases:
old_value = legacy.pop(old_name, None)
if value is not None and old_value is not None:
raise TypeError("одновременно заданы %s и %s" %
(new_name, old_name))
resolved.append(value if value is not None else old_value)
if legacy:
raise TypeError("неизвестное поле: %s" % next(iter(legacy)))
pm, device, body = resolved
required = {
"pm": pm,
"device_type": device_type,
"device": device,
"msg_type": msg_type,
"body": body,
}
missing = [name for name, value in required.items() if value is None]
if missing:
raise TypeError("не заданы поля: %s" % ", ".join(missing))
assert pm is not None and device_type is not None and device is not None
assert msg_type is not None and body is not None
if _SHARED_PROTOCOL is not None:
return _SHARED_PROTOCOL.id_pack(
priority, pm, device_type, device, msg_type, body)
return (((priority & 0x1) << 28)
| ((pm & 0x1) << 27)
| ((device_type & 0x7) << 24)
| ((device & 0xF) << 20)
| ((msg_type & 0xF) << 16)
| (body & 0xFFFF))
@property
def route(self) -> int:
"""Совместимый псевдоним поля ``ПМ``."""
return self.pm
@property
def device_id(self) -> int:
"""Совместимый псевдоним поля ``Device``."""
return self.device
@property
def msg_body(self) -> int:
"""Совместимый псевдоним поля ``Body``."""
return self.body
@property
def msg_type_name(self) -> str:
try:
return MSGTYPE_RU[MsgType(self.msg_type)]
except ValueError:
return "резерв 0x%X" % self.msg_type
@property
def device_name(self) -> str:
"""Табличное имя адреса Device Type/Device, если оно задано."""
return device_name(self.device_type, self.device)
# --------------------------------------------------------------------------
# Раскладки 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)
#: Имя прикладного протокола для GUI, когда это не ProtoCAN.
protocol: str = "ProtoCAN"
#: UI labels for protocols whose identifier is not a ProtoCAN bit field.
device_label: str = ""
message_label: str = ""
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: # Тип 0x3 всегда адресует регистры, тип 0x4 всегда адресует слоты; настройка профиля не требуется.
"""Разбирает кадр ProtoCAN в структуру Decoded."""
pid = ProtoCanId.parse(raw_id)
source_data = bytes(data) # Сохраняем фактическую длину кадра до отображения ограниченного classic CAN payload.
data = bytes(data[:8])
res = Decoded(id=pid, dlc=len(data), data=data, ide=ide, rtr=rtr)
res.fields.append(("Priority [28]", "%d — %s" % (
pid.priority, PRIORITY_RU.get(Priority(pid.priority), ""))))
res.fields.append(("ПМ [27]", "%d — %s" % (
pid.pm, ROUTE_RU.get(Route(pid.pm), ""))))
res.fields.append(("Device Type [26:24]", "%d (0b{:03b})".format(
pid.device_type) % pid.device_type))
res.fields.append(("Device [23:20]", "%d (0b{:04b})".format(
pid.device) % pid.device))
res.fields.append(("Msg Type [19:16]", "0x%X — %s" % (
pid.msg_type, pid.msg_type_name)))
res.fields.append(("Body [15:0]", "0x%04X" % pid.body))
if not ide:
res.warnings.append("Стандартный ID: ProtoCAN использует расширенный (29 бит)")
if rtr:
res.warnings.append("Remote frame: полезная нагрузка отсутствует")
if pid.msg_type == MsgType.GAS_SLOT: # Слотный кодек вызывается только для своего типа 0x4, без угадывания по содержимому.
from .gas_slots import GasSlots # Тонкая модель вызывает единственный C-кодек формата и проверок.
try: # Ошибочный слот остаётся видимым в журнале, не превращаясь в регистровый кадр.
values = GasSlots().decode_address(raw_id, source_data, (1 if ide else 0) | (2 if rtr else 0)) # Общий C-кодек проверяет маску и точную длину слота любого узла, включая отдельный инвертор.
except ValueError as error: # Отказ протокола отображаем как ошибку кадра, а не подменяем legacy-декодированием.
res.summary = "GAS SLOT: неверный кадр" # Короткий текст подходит для строки CAN-лога.
res.warnings.append(str(error)) # Подробности доступны в панели выбранного сообщения.
return res # Отвергнутый слот не получает вымышленных адресов регистров.
res.fields.append(("GAS SLOT", "MsgType 0x4")) # Отдельный номер сообщения определяет формат без настройки профиля соединения.
if values: # Запрос чтения не содержит маски или значений.
res.fields.append(("Word0: маска", "0x%04X" % values[0])) # Нулевая позиция отображается как служебная маска, а не как регистр.
for index in range(1, 4): # Три значения имеют фиксированные позиции Word1…Word3 в восьмибайтовом кадре.
if values[0] & (1 << index): # Невыбранные значения не отображаются как записанные или достоверные измерения.
res.fields.append(("Слот 0x%04X, Word%d" % (pid.body, index), "0x%04X (%d)" % (values[index], values[index]))) # Полная координата сохраняет адрес слота и выбранную позицию.
res.summary = "GAS SLOT slot=0x%04X%s" % (pid.body, " mask=0x%04X" % values[0] if values else " (запрос)") # CAN-журнал явно показывает маску частичного изменения.
return res # Не передаём четыре слова старому обработчику адреса первого регистра.
handler = {
MsgType.BROADCAST: _decode_broadcast,
MsgType.DISCRETE: _decode_discrete,
MsgType.ANALOG: _decode_analog,
MsgType.GENERAL_ADDRESS_SPACE: _decode_gas,
MsgType.MODBUS_DISCRETE: _decode_modbus_bit,
MsgType.MODBUS_HOLDING: _decode_modbus_reg,
MsgType.MODBUS_INPUT: _decode_modbus_reg,
MsgType.ERROR: _decode_error,
MsgType.BOOT_CONTROL: _decode_boot,
MsgType.BOOT_DATA_A: _decode_boot,
MsgType.BOOT_DATA_B: _decode_boot,
MsgType.BOOT_STATUS: _decode_boot,
MsgType.BOOT_DISCOVERY: _decode_boot,
MsgType.SETTINGS: _decode_settings,
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_boot(res: Decoded) -> None:
"""Расшифровывает служебные кадры ProtoCAN Boot Protocol 1.0."""
kind = MsgType(res.id.msg_type)
if kind == MsgType.BOOT_CONTROL:
command = res.id.body & 0xFF
session = res.id.body >> 8
names = {1: "IDENTIFY", 2: "ENTER_BOOT", 3: "BEGIN_IMAGE",
4: "BEGIN_COMPAT", 5: "ERASE", 6: "VERIFY", 7: "COMMIT",
8: "CONFIRM", 9: "REBOOT", 10: "ABORT", 11: "QUERY_PROGRESS"}
res.summary = "BOOT CONTROL %s, session=%d" % (
names.get(command, "0x%02X" % command), session)
elif kind in (MsgType.BOOT_DATA_A, MsgType.BOOT_DATA_B):
res.summary = "BOOT DATA slot %s, block=%d" % (
"A" if kind == MsgType.BOOT_DATA_A else "B", res.id.body)
elif kind == MsgType.BOOT_STATUS:
command = res.id.body & 0xFF
session = res.id.body >> 8
status = res.data[0] if res.data else None
res.summary = "BOOT STATUS command=0x%02X, session=%d%s" % (
command, session, " status=0x%02X" % status if status is not None else "")
else:
res.summary = "BOOT DISCOVERY"
if res.data[:4] == b"PONG" and len(res.data) == 8:
res.summary = "PONG, FW %d.%d, protocol %d" % tuple(res.data[4:7])
def _decode_broadcast(res: Decoded) -> None:
body, btype = split_broadcast(res.id.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.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.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.body
res.fields.append((" Адрес первого регистра", "0x%04X (%d)" % (start, start)))
if res.dlc % 2:
res.warnings.append("DLC = %d нечётный: регистры GAS передаются парами байт" % res.dlc)
res.summary = "GAS: неверная длина данных" # Усечённое слово не должно становиться достоверным значением или уставкой в GUI.
return # Не публикуем частичный список регистров повреждённого кадра.
if start + res.dlc // 2 > 0x10000: # Адрес FFFF допустим для одного слова, следующий адрес уже выходит за 16-битное пространство.
res.warnings.append("Диапазон GAS выходит за адрес 0xFFFF") # Не создаём вымышленные адреса 0x10000 и выше.
res.summary = "GAS: неверный диапазон регистров" # Ошибка остаётся видимой в CAN-журнале.
return # Не передаём некорректные значения потребителям трендов или карты данных.
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.body)
kind = "DISCRETE" # После назначения 0x4 слотному GAS битовым Modbus-сообщением остаётся только тип 0x5.
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.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.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_settings(res: Decoded) -> None:
"""SETTINGS: Body[15:8] = сборка Z, Body[7:0] = позиция Y."""
assembly = (res.id.body >> 8) & 0xFF
position = res.id.body & 0xFF
res.fields += [
(" SETTINGS.Assembly Z", "%d (0x%02X)" % (assembly, assembly)),
(" SETTINGS.Position Y", "%d (0x%02X)" % (position, position)),
]
# Зарезервированная локация прибора KONOR: runtime-настройки не являются
# ROM датчика, хотя используют тот же однокадровый SETTINGS-транспорт.
if assembly == 0xFF and position == 0x01:
if res.dlc == 0 and res.id.pm == Route.FROM_PM:
res.summary = "SETTINGS RUNTIME GET"
return
if res.dlc == 8:
pulse_seconds = res.data[0]
led_hz = int.from_bytes(res.data[1:3], "little")
res.fields += [
(" SETTINGS.Pulse period",
"%d s" % pulse_seconds if pulse_seconds else "выключен"),
(" SETTINGS.LED frequency", "%d Hz" % led_hz),
]
if led_hz > 500:
res.warnings.append("Частота LED должна быть 0..500 Гц")
if any(res.data[3:]):
res.warnings.append("Резерв SETTINGS Data[3:8] должен быть нулевым")
direction = "WRITE" if res.id.pm == Route.FROM_PM else "RESPONSE"
period_text = "%d s" % pulse_seconds if pulse_seconds else "выключен"
res.summary = ("SETTINGS RUNTIME %s: PULSE %s, LED %d Hz"
% (direction, period_text, led_hz))
return
if res.id.pm == Route.FROM_PM:
if res.dlc == 0:
operation = "GET"
elif res.dlc == 8 and not any(res.data):
operation = "CLEAR"
res.fields.append((" ROM", "00 00 00 00 00 00 00 00"))
elif res.dlc == 8:
operation = "WRITE/REPLACE"
res.fields.append((" ROM", " ".join("%02X" % b for b in res.data)))
else:
operation = "INVALID"
res.warnings.append("Запрос SETTINGS должен иметь DLC = 0 или 8")
else:
if res.dlc == 8:
operation = "RESPONSE"
res.fields.append((" ROM", " ".join("%02X" % b for b in res.data)))
elif res.dlc == 1:
operation = "ERROR"
code = res.data[0]
try:
result = SettingsResult(code)
result_name = "%s — %s" % (result.name, SETTINGS_RESULT_RU[result])
except ValueError:
result_name = "неизвестный код"
res.fields.append((" SETTINGS.Result", "0x%02X — %s" %
(code, result_name)))
else:
operation = "INVALID"
res.warnings.append("Ответ SETTINGS должен иметь DLC = 1 или 8")
res.summary = "SETTINGS %s: сборка %d, позиция %d" % (
operation, assembly, position)
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.body:
res.warnings.append("Body у PULSE должен быть 0, получено 0x%04X" % res.id.body)