Объедини Modbus в один тип с функциями и количеством 1–4
This commit is contained in:
@@ -6,8 +6,8 @@
|
||||
from .set_uart import number, parse_frame
|
||||
|
||||
TYPES = {0: 'BROADCAST', 1: 'DISCRETE', 2: 'ANALOG', 3: 'GAS',
|
||||
4: 'GAS_SLOT', 5: 'MODBUS_DISCRETE', 6: 'MODBUS_HOLDING', # Тип 0x4 теперь означает масочный слотный GAS, номера остальных Modbus-типов сохранены.
|
||||
7: 'MODBUS_INPUT', 8: 'ERROR', 9: 'BOOT_CONTROL', 10: 'BOOT_DATA_A',
|
||||
4: 'GAS_SLOT', 5: 'MODBUS', # Единый Modbus занимает 0x5, прежние коды 0x6 и 0x7 освобождены.
|
||||
8: 'ERROR', 9: 'BOOT_CONTROL', 10: 'BOOT_DATA_A', # Остальные сервисы сохраняют числовые коды.
|
||||
11: 'BOOT_DATA_B', 12: 'BOOT_STATUS', 13: 'BOOT_DISCOVERY', 15: 'PULSE'}
|
||||
BOOT = dict(enumerate(('IDENTIFY ENTER_BOOT BEGIN_IMAGE BEGIN_COMPAT ERASE VERIFY '
|
||||
'COMMIT CONFIRM REBOOT ABORT QUERY_PROGRESS').split(), 1))
|
||||
@@ -49,8 +49,14 @@ def legacy(ident, data, profile='protocan'):
|
||||
result['mask'] = values[0] if values else None # Пустой запрос чтения не содержит фиктивной маски.
|
||||
result['slot_words'] = [(index, values[index]) for index in range(1, 4) if values and values[0] & (1 << index)] # Публикуем только выбранные или достоверные значения, без служебного Word0.
|
||||
text += ' slot=%04X' % body + (' READ' if not values else ' mask=%04X' % values[0]) # В логе ясно различаются адрес и маска частичной записи.
|
||||
elif 5 <= msg <= 7: # Диапазон Modbus больше не включает бывший Coil 0x4.
|
||||
text += ' address=%03X count=%d' % (body >> 4, body & 15)
|
||||
elif msg == 5: # Modbus использует один тип сообщения и поле функции внутри Body.
|
||||
from protocan.protocan import decode # Один подробный декодер проверяет длину, диапазон адресов и битовые маски.
|
||||
decoded = decode(ident, bytes(data)) # Передаём исходный payload без повторной реализации протокольных проверок.
|
||||
if decoded.warnings: # Некорректные значения должны быть видны вызывающему анализатору.
|
||||
raise ValueError('; '.join(decoded.warnings)) # Сохраняем подробные сообщения общей реализации.
|
||||
result['address'], result['count'], result['function'] = body >> 4, (body & 3) + 1, ((body >> 2) & 3) + 1 # Внешний результат содержит физические значения полей 1…4.
|
||||
result['registers'] = decoded.registers # Для Coil/Discrete публикуются отдельные биты, для Holding/Input — 16-битные слова.
|
||||
text += ' FC%d address=%03X count=%d' % (result['function'], result['address'], result['count']) # Журнал явно различает четыре банка единого Modbus.
|
||||
elif msg == 8:
|
||||
text += ' info=%02X code=%02X' % (body >> 8, body & 255)
|
||||
elif msg in (9, 12):
|
||||
|
||||
+50
-55
@@ -47,9 +47,7 @@ class MsgType(IntEnum):
|
||||
ANALOG = 0b0010
|
||||
GENERAL_ADDRESS_SPACE = 0b0011
|
||||
GAS_SLOT = 0b0100 # Тип 0x4 однозначно выбирает слотный GAS с маской вместо прежнего Modbus Coil.
|
||||
MODBUS_DISCRETE = 0b0101
|
||||
MODBUS_HOLDING = 0b0110
|
||||
MODBUS_INPUT = 0b0111
|
||||
MODBUS = 0b0101 # Единый тип 0x5 выбирает банк функцией 1…4 в Body; номера 0x6 и 0x7 освобождены.
|
||||
ERROR = 0b1000
|
||||
BOOT_CONTROL = 0b1001
|
||||
BOOT_DATA_A = 0b1010
|
||||
@@ -60,6 +58,13 @@ class MsgType(IntEnum):
|
||||
PULSE = 0b1111
|
||||
|
||||
|
||||
class ModbusFunction(IntEnum): # Функция единого типа Modbus выбирает один из четырёх банков.
|
||||
COIL = 1 # Дискретные выходы соответствуют функции 1.
|
||||
DISCRETE = 2 # Дискретные входы соответствуют функции 2.
|
||||
HOLDING = 3 # Изменяемые 16-битные регистры соответствуют функции 3.
|
||||
INPUT = 4 # Входные 16-битные регистры соответствуют функции 4.
|
||||
|
||||
|
||||
class SettingsResult(IntEnum):
|
||||
OK = 0x00
|
||||
INVALID_DLC = 0x01
|
||||
@@ -116,9 +121,7 @@ MSGTYPE_RU = {
|
||||
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.MODBUS: "Modbus", # Один тип охватывает Coil, Discrete, Holding и Input через поле функции.
|
||||
MsgType.ERROR: "Error",
|
||||
MsgType.BOOT_CONTROL: "Boot Control",
|
||||
MsgType.BOOT_DATA_A: "Boot Data A",
|
||||
@@ -348,9 +351,8 @@ def split_analog(body: int):
|
||||
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_modbus(body: int): # Возвращает адрес, количество и функцию в тех же единицах, что принимает упаковщик.
|
||||
return (body >> 4) & 0xFFF, (body & 3) + 1, ((body >> 2) & 3) + 1 # Адрес занимает 12 бит, два остальных поля кодируются значением минус один.
|
||||
|
||||
|
||||
def split_error(body: int):
|
||||
@@ -370,8 +372,10 @@ 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_modbus(str_adr: int, reg_count: int, function: int) -> int: # Функция 1…4 явно задаёт банк единого сообщения Modbus.
|
||||
if not 0 <= str_adr <= 0xFFF or not 1 <= reg_count <= 4 or not 1 <= function <= 4 or str_adr + reg_count > 0x1000: # Проверяем окно целиком, чтобы не усекать адреса и значения двухбитных полей.
|
||||
raise ValueError("Modbus: адрес 0…4095, количество и функция 1…4; окно не должно выходить за 4095") # Ошибочный ввод не превращается в допустимый кадр другого банка.
|
||||
return (str_adr << 4) | ((function - 1) << 2) | (reg_count - 1) # Функция занимает биты 3:2, количество — 1:0; оба wire-кода лежат в диапазоне 0…3.
|
||||
|
||||
|
||||
def merge_error(info: int, code: int) -> int:
|
||||
@@ -460,14 +464,16 @@ def decode(raw_id: int, data: bytes, ide: bool = True, rtr: bool = False) -> Dec
|
||||
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 # Не передаём четыре слова старому обработчику адреса первого регистра.
|
||||
if pid.msg_type == MsgType.MODBUS and len(source_data) > 8: # Проверяем реальную длину до усечения отображаемого classic CAN payload.
|
||||
res.summary = "MODBUS: неверная длина кадра" # Нельзя выдавать девять и более октетов за допустимые восемь.
|
||||
res.warnings.append("Modbus: classic CAN допускает не более 8 байт") # Сохраняем причину отказа для журнала и анализатора захвата.
|
||||
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.MODBUS: _decode_modbus, # Банк выбирается функцией из Body внутри общего обработчика.
|
||||
MsgType.ERROR: _decode_error,
|
||||
MsgType.BOOT_CONTROL: _decode_boot,
|
||||
MsgType.BOOT_DATA_A: _decode_boot,
|
||||
@@ -663,47 +669,36 @@ def _decode_gas(res: Decoded) -> None:
|
||||
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_modbus(res: Decoded) -> None: # Единый тип 0x5 разбирается по номеру функции, а не по прежним трём MsgType.
|
||||
if not res.ide or res.rtr: # Modbus поверх ProtoCAN принимает только расширенные data frames.
|
||||
return # Общий декодер уже добавил предупреждение о неверном формате; данные такого кадра не публикуются.
|
||||
str_adr, reg_count, function = split_modbus(res.id.body) # Извлекаем физические значения функции и количества 1…4.
|
||||
kind = ("COIL", "DISCRETE", "HOLDING", "INPUT")[function - 1] # Порядок банков соответствует функциям Modbus 1, 2, 3 и 4.
|
||||
res.fields.extend([(" Modbus.Function", "%d (%s)" % (function, kind)), (" Modbus.StrAdr", "0x%03X (%d)" % (str_adr, str_adr)), (" Modbus.RegCount", str(reg_count))]) # Журнал показывает функцию отдельно от единого типа сообщения.
|
||||
res.summary = "MODBUS FC%d %s @0x%03X x%d" % (function, kind, str_adr, reg_count) # Общая подпись сохраняется также для ошибочного payload.
|
||||
if str_adr + reg_count > 0x1000: # Проверяем конец окна относительно 12-битного адресного пространства.
|
||||
res.warnings.append("Modbus: окно выходит за адрес 0xFFF") # Не отображаем выход за границу как корректные регистры.
|
||||
return # Неверное окно не интерпретируется дальше.
|
||||
expected = 1 if function <= 2 else reg_count * 2 # До четырёх битов помещаются в октет; каждому 16-битному регистру нужны два октета.
|
||||
if not res.dlc: # Пустой payload обозначает запрос чтения от ПМ.
|
||||
if res.id.route == Route.FROM_DEVICE: # Ответ устройства обязан содержать запрошенные значения.
|
||||
res.warnings.append("Modbus: пустой ответ устройства") # Пустой ответ не маскируется под запрос.
|
||||
res.summary += " (запрос)" # Запрос отображается без вымышленных нулевых значений.
|
||||
return # Чтение не содержит регистров для декодирования.
|
||||
if res.dlc != expected: # Длина должна точно соответствовать функции и количеству в Body.
|
||||
res.warnings.append("Modbus FC%d: ожидается DLC=%d, получено %d" % (function, expected, res.dlc)) # Сообщаем ожидаемую длину без частичного разбора.
|
||||
return # Некорректный payload не выдаётся за действительные данные.
|
||||
if function <= 2: # Coil и Discrete передают значения младшими битами одного октета.
|
||||
if res.data[0] >> reg_count: # Биты вне запрошенного количества должны оставаться нулевыми.
|
||||
res.warnings.append("Modbus: установлены биты вне запрошенного количества") # Выявляем неверную битовую маску.
|
||||
return # Не публикуем неверные значения.
|
||||
values = [(res.data[0] >> index) & 1 for index in range(reg_count)] # Каждый следующий адрес соответствует следующему младшему биту.
|
||||
else: # Holding и Input сохраняют порядок little-endian внутри каждого регистра.
|
||||
values = regs_le(res.data) # Общий помощник извлекает проверенное число 16-битных значений.
|
||||
res.registers = [(str_adr + index, value) for index, value in enumerate(values)] # Возвращаем отдельную координату для каждого бита или регистра.
|
||||
for address, value in res.registers: # Детальная панель показывает все значения выбранного банка.
|
||||
res.fields.append((" Reg 0x%04X" % address, "0x%04X (%d)" % (value, value))) # Одновременно доступны HEX и десятичное представление.
|
||||
res.summary += ": " + " ".join("%04X" % value for value in values) # Краткая строка журнала включает полученные значения.
|
||||
|
||||
|
||||
def _decode_error(res: Decoded) -> None:
|
||||
|
||||
@@ -53,8 +53,8 @@ class BusDemoSource:
|
||||
data = tag + ("%04d" % sid).encode()
|
||||
elif kind == "modbus":
|
||||
adr, cnt = self._rng.randint(0, 0x200), self._rng.randint(1, 4)
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.MODBUS_HOLDING,
|
||||
pc.merge_modbus(adr, cnt))
|
||||
cid = pc.ProtoCanId.build(0, 1, dev_type, dev_id, pc.MsgType.MODBUS, # Демонстрация использует единый тип 0x5 вместо прежнего Holding 0x6.
|
||||
pc.merge_modbus(adr, cnt, pc.ModbusFunction.HOLDING)) # Функция 3 обозначает банк Holding, количество по-прежнему находится в диапазоне 1…4.
|
||||
data = b"".join(self._rng.randint(0, 0xFFFF).to_bytes(2, "little")
|
||||
for _ in range(cnt))
|
||||
elif kind == "gas":
|
||||
|
||||
@@ -64,7 +64,7 @@ class CanFrameFields(QWidget): # Компонент содержит тольк
|
||||
arg_layout.addWidget(self.settings_body) # Два байта SETTINGS заменяют единое поле аргумента.
|
||||
arg_layout.addStretch(1) # Свободная ширина остаётся справа от редакторов.
|
||||
self.count = QSpinBox() # RegCount используется протокольными типами Modbus.
|
||||
self.count.setRange(0, 15) # Поле количества занимает четыре бита Body Modbus.
|
||||
self.count.setRange(1, 4) # Два младших бита Body кодируют количество минус один, допустимы 1…4 регистра или бита.
|
||||
self.count.setValue(1) # Исходный запрос относится к одному регистру.
|
||||
self.sub_label = QLabel("Подтип") # Подпись уточняется при выборе типа сообщения.
|
||||
self.arg_label = QLabel("SensorID / адрес") # Назначение аргумента зависит от MsgType.
|
||||
@@ -116,10 +116,11 @@ class CanFrameFields(QWidget): # Компонент содержит тольк
|
||||
settings = msg_type == pc.MsgType.SETTINGS # Только SETTINGS редактируется отдельными байтами Z и Y.
|
||||
self.arg.setVisible(not settings) # Единый числовой аргумент скрывается для SETTINGS.
|
||||
self.settings_body.setVisible(settings) # Двухбайтовый редактор появляется вместо числового аргумента.
|
||||
table = {pc.MsgType.BROADCAST: (pc.BroadcastType, "Broadcast.Type", "Body (0..15)"), pc.MsgType.DISCRETE: (pc.DiscreteType, "Discrete.Type", "Body (0..4095)"), pc.MsgType.ANALOG: (pc.AnalogType, "Analog.Type", "SensorID (0..4095)")} # Таблица определяет визуальные подписи существующих протокольных перечислений.
|
||||
table = {pc.MsgType.BROADCAST: (pc.BroadcastType, "Broadcast.Type", "Body (0..15)"), pc.MsgType.DISCRETE: (pc.DiscreteType, "Discrete.Type", "Body (0..4095)"), pc.MsgType.ANALOG: (pc.AnalogType, "Analog.Type", "SensorID (0..4095)"), pc.MsgType.MODBUS: (pc.ModbusFunction, "Функция Modbus (1..4)", "StrAdr (0..4095)")} # Для единого Modbus селектор подтипа выбирает функцию Coil/Discrete/Holding/Input.
|
||||
self.count.setEnabled(msg_type == pc.MsgType.MODBUS) # Количество доступно только для типа, использующего это поле Body.
|
||||
current = self.sub.currentText() # Сохраняем выбранный подтип, если он допустим для нового сообщения.
|
||||
self.sub.clear() # Старые варианты не должны остаться в новом типе сообщения.
|
||||
self.sub.setEnabled(msg_type in table) # Отдельный подтип доступен только у трёх перечисленных типов.
|
||||
self.sub.setEnabled(msg_type in table) # Подтип доступен для Broadcast/Discrete/Analog, а у Modbus выбирается функция 1…4.
|
||||
if msg_type in table: # Подписи и значения перечислений берутся из одного места.
|
||||
enum, sub_label, arg_label = table[msg_type] # Получаем визуальное описание выбранного MsgType.
|
||||
self.sub.addItems([item.name for item in enum]) # Значения берутся из общего протокольного каталога.
|
||||
@@ -128,9 +129,7 @@ class CanFrameFields(QWidget): # Компонент содержит тольк
|
||||
else: # Типы без отдельного подтипа используют Body по собственному назначению.
|
||||
sub_label = "—" # Подпись явно показывает отсутствие подтипа.
|
||||
arg_label = "Body (обычно 0)" # Для PULSE сохраняем исходное описание аргумента.
|
||||
if msg_type in (pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # Только сохранённые типы 0x5…0x7 используют Modbus-адрес и количество.
|
||||
arg_label = "StrAdr (0..4095)" # Двенадцатибитный адрес показан в прежних единицах регистров.
|
||||
elif msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE: # GAS использует всё поле Body под адрес.
|
||||
if msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE: # GAS использует всё поле Body под адрес.
|
||||
arg_label = "Адрес регистра (0..65535)" # Полный 16-битный диапазон указан в подписи.
|
||||
elif msg_type == pc.MsgType.GAS_SLOT: # Новый тип 0x4 использует весь Body под номер слота, а не StrAdr/RegCount.
|
||||
arg_label = "Адрес слота (0..65535), Data: маска + 3 слова" # Напоминаем оператору о Word0 с маской и фиксированных позициях трёх значений.
|
||||
@@ -148,8 +147,8 @@ class CanFrameFields(QWidget): # Компонент содержит тольк
|
||||
return pc.merge_discrete(pc.DiscreteType[self.sub.currentText()], arg) # Поле собирается общим протокольным модулем.
|
||||
if msg_type == pc.MsgType.ANALOG: # Аналоговое сообщение передаёт SensorID в Body.
|
||||
return pc.merge_analog(pc.AnalogType[self.sub.currentText()], arg) # Существующий кодек задаёт расположение подтипа и SensorID.
|
||||
if msg_type in (pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # Формат Modbus применяется только к трём оставшимся типам 0x5…0x7.
|
||||
return pc.merge_modbus(arg, count) # Адрес и количество объединяются общей функцией протокола.
|
||||
if msg_type == pc.MsgType.MODBUS: # Один MsgType содержит адрес, количество 1…4 и выбранную функцию.
|
||||
return pc.merge_modbus(arg, count, pc.ModbusFunction[self.sub.currentText()]) # Общий упаковщик проверяет диапазоны и кодирует функцию и количество со смещением на единицу.
|
||||
if msg_type == pc.MsgType.ERROR: # Сохраняем существующую категорию ошибки с нулевым кодом источника.
|
||||
return pc.merge_error(0, arg) # Размещение кода ошибки остаётся ответственностью общего ядра.
|
||||
return arg & 0xFFFF # Остальные типы сохраняют прежнее ограничение Body шестнадцатью битами.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import unittest # Стандартный тестовый раннер проверяет wire-контракт без зависимости от GUI.
|
||||
from protocan import protocan as pc # Используем публичный построитель и декодер CAN-сообщений.
|
||||
from logic_analyzer.decoders.set_can import legacy # Тот же формат должен распознаваться при анализе аппаратного захвата.
|
||||
|
||||
|
||||
class ModbusTests(unittest.TestCase): # Проверяем объединение банков, битовую упаковку и отбраковку неверных окон.
|
||||
def test_functions_and_counts(self): # Все шестнадцать сочетаний должны сохранять функцию, количество и значения.
|
||||
for function in range(1, 5): # Номера 1…4 соответствуют Coil, Discrete, Holding и Input.
|
||||
for count in range(1, 5): # Количество ограничено 1…4 независимо от размера элемента банка.
|
||||
body = pc.merge_modbus(0x123, count, function) # Адрес выбран так, чтобы старшие биты отличались от младших служебных полей.
|
||||
self.assertEqual(body, 0x1230 | ((function - 1) << 2) | (count - 1)) # Эталон явно задаёт адрес [15:4], функцию [3:2] и количество [1:0].
|
||||
self.assertEqual(pc.split_modbus(body), (0x123, count, function)) # Обратное преобразование возвращает физические значения 1…4.
|
||||
values = [index % 2 for index in range(count)] if function <= 2 else [0x1234 + index for index in range(count)] # Битовые и регистровые банки содержат различимые последовательности.
|
||||
data = bytes([sum(value << index for index, value in enumerate(values))]) if function <= 2 else b''.join(value.to_bytes(2, 'little') for value in values) # Биты начинаются с младшего, слова передаются little-endian.
|
||||
identifier = pc.ProtoCanId.build(1, 1, 7, 3, pc.MsgType.MODBUS, body) # Каждый ответ имеет единый MsgType=0x5 и направление от устройства.
|
||||
decoded = pc.decode(identifier, data) # Проверяем штатный путь CAN-журнала приложения.
|
||||
self.assertFalse(decoded.warnings, decoded.warnings) # Допустимое окно не должно сопровождаться предупреждениями.
|
||||
self.assertEqual(decoded.registers, list(zip(range(0x123, 0x123 + count), values))) # Для каждого бита и слова публикуется точный адрес.
|
||||
captured = legacy(identifier, data) # Проверяем согласованность анализатора захватов с протокольной библиотекой.
|
||||
self.assertEqual((captured['function'], captured['count'], captured['registers']), (function, count, decoded.registers)) # Оба публичных пути возвращают одинаковые физические значения.
|
||||
|
||||
def test_invalid_inputs_and_payloads(self): # Ошибки должны отклоняться до превращения в другой допустимый wire-код.
|
||||
for args in ((0, 0, 1), (0, 5, 1), (0, 1, 0), (0, 1, 5), (-1, 1, 1), (4096, 1, 1), (4095, 2, 4)): # Охватываем обе границы полей и переполнение окна.
|
||||
with self.assertRaises(ValueError): pc.merge_modbus(*args) # Неверные значения не маскируются и не оборачиваются.
|
||||
self.assertEqual(pc.merge_modbus(4095, 1, 4), 0xFFFC) # Последний адрес остаётся доступным для одного регистра Input.
|
||||
for body, data in ((0x1233, b'\x10'), (0x123B, b'\x01\x02'), (0xFFFF, b'\0' * 8), (0x123B, b'\0' * 9)): # Проверяем лишний бит, короткие данные, выход окна за 0xFFF и запрет молчаливой обрезки девятого октета.
|
||||
decoded = pc.decode(0x1F350000 | body, data) # Формируем ответ устройства с единым типом 0x5.
|
||||
self.assertTrue(decoded.warnings) # Каждая ошибка должна быть видима в журнале.
|
||||
self.assertFalse(decoded.registers) # Некорректный payload не становится источником значений для тренда.
|
||||
with self.assertRaises(ValueError): legacy(0x1F350000 | body, data) # Анализатор захвата использует те же проверки.
|
||||
self.assertFalse(pc.decode(0x1735123F, b'').warnings) # Запрос ПМ с функцией 4 и количеством 4 не содержит данных.
|
||||
self.assertTrue(pc.decode(0x1F35123F, b'').warnings) # Пустой ответ прибора является ошибкой протокола.
|
||||
for msg_type in (6, 7): # Бывшие отдельные типы Modbus освобождены.
|
||||
self.assertTrue(pc.decode(0x1F300000 | (msg_type << 16), b'').warnings) # Декодер не трактует старые MsgType как единый Modbus.
|
||||
|
||||
|
||||
if __name__ == '__main__': # Допускаем прямой запуск теста на хосте.
|
||||
unittest.main() # Стандартный код завершения отражает результат проверок.
|
||||
Reference in New Issue
Block a user