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.
This commit is contained in:
2026-10-23 14:14:48 +03:00
parent 6ecf057a86
commit 876b58517c
51 changed files with 2129 additions and 116 deletions
+4
View File
@@ -55,3 +55,7 @@ assert decode_entry(raw) == entry
`SETGUI` (вкладки «Шина CAN» и «Адресное пространство»), `CAN_to_RS485`.
Там пакет лежит внутри `gui_desktop/core`, здесь — источник истины.
## GAS: регистры 0x3 и слоты 0x4
[Адресация GAS](../../doc/setcan/protocan/OAP.md): `0x3` — отдельные 16-битные регистры `0…65535`, запись 1…4 слов. `0x4` — слот с Word0-маской и тремя значениями; PERIPH сохраняет 200 слов в 67 слотах `0xMM00…0xMM42`. Формат определяется MsgType, без настройки профиля.
+79
View File
@@ -0,0 +1,79 @@
import ctypes # Передаём построение ID, проверку маски и порядок октетов единственному C-кодеку.
from .native import get_native_core # Используем штатный поиск библиотеки без резервного Python-кодека.
class GasSlots: # Тип 0x4 содержит Word0 с маской и три значения в фиксированных позициях.
def __init__(self, library=None): # Тест или приложение может передать ту же собранную библиотеку явно.
self.lib = library or get_native_core().lib # Отсутствующее общее ядро проявляется штатной ошибкой загрузки.
self.lib.pcan_gas_slot_request_id.argtypes = [ctypes.c_uint] * 3 # Обёртка PERIPH принимает mode, локальный слот и Route.
self.lib.pcan_gas_slot_request_id.restype = ctypes.c_uint32 # Extended ID возвращается без знакового преобразования.
self.lib.pcan_gas_slot_id.argtypes = [ctypes.c_uint] * 4 # Общий построитель принимает DeviceType, DeviceID, полный адрес и Route.
self.lib.pcan_gas_slot_id.restype = ctypes.c_uint32 # Любой корректный ID типа 0x4 отличен от нулевого маркера ошибки.
self.lib.pcan_gas_slot_encode.argtypes = [ctypes.POINTER(ctypes.c_uint16), ctypes.c_uint, ctypes.POINTER(ctypes.c_uint8)] # Четыре wire-слова включают маску, а не четвёртый регистр.
self.lib.pcan_gas_slot_encode.restype = ctypes.c_int # Кодек возвращает длину 0/8 либо отрицательный отказ.
self.lib.pcan_gas_slot_decode.argtypes = [ctypes.c_uint32, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint8), ctypes.c_uint, ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint), ctypes.POINTER(ctypes.c_uint16)] # PERIPH-декодер дополнительно проверяет принадлежность странице и последний слот.
self.lib.pcan_gas_slot_decode.restype = ctypes.c_int # Результат 0/4 включает маску при наличии данных.
self.lib.pcan_gas_slot_decode_words.argtypes = [ctypes.c_uint32, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint8), ctypes.c_uint, ctypes.POINTER(ctypes.c_uint16)] # Общий декодер поддерживает остальные классы узлов и все 16 бит адреса.
self.lib.pcan_gas_slot_decode_words.restype = ctypes.c_int # Отрицательное значение означает отказ без публикации частичного результата.
@staticmethod # Проверка представимости Python-чисел не дублирует wire-логику.
def _unsigned(value, maximum): # Проверяем границы до возможного сужения ctypes.
if type(value) is not int or not 0 <= value <= maximum: # bool, дробь и отрицательное число не являются полями протокола.
raise ValueError('Недопустимое целое значение протокола') # Не отправляем усечённое значение к другому адресу.
def _payload(self, words, route): # Общая упаковка используется адресным и PERIPH-построителями.
values = None if words is None else tuple(words) # Фиксируем входной iterable до преобразования к C-массиву.
if values is not None: # Запись или ответ содержат ровно маску и три значения.
if len(values) != 4: # Позиции не сдвигаются при частичной записи.
raise ValueError('Нужны Word0-маска и три значения Word1…Word3') # Не дополняем отсутствующие позиции нулями неявно.
for value in values: # Все wire-слова имеют одинаковую ширину 16 бит.
self._unsigned(value, 0xFFFF) # Корректные ноль и 65535 остаются допустимыми значениями.
if values is None and route != 0: # Пустой ответ прибора не имеет определённого смысла.
raise ValueError('Ответ должен содержать маску и значения') # Отказ возникает до отправки в транспорт.
source = None if values is None else (ctypes.c_uint16 * 4)(*values) # Маску и значения проверяет C до кодирования.
data = (ctypes.c_uint8 * 8)() # Буфер ограничен одним classic CAN кадром.
length = self.lib.pcan_gas_slot_encode(source, 0 if values is None else 4, data) # Единственный кодек проверяет разрешённые биты маски и порядок октетов.
if length < 0: # Неверная маска не заменяется другой командой.
raise ValueError('Неверная маска GAS SLOT: допустимы непустые биты 1…3') # Бит 0 относится к служебному слову и запрещён.
return bytes(data[:length]) # Возвращаем только готовые wire-октеты.
def frame(self, mode, slot, words=None, route=0): # Удобная обёртка для 200 регистров каждого mode PERIPH.
for value in (mode, slot, route): # Исключаем переполнение host ABI до вызова C.
self._unsigned(value, 0xFFFFFFFF) # Семантические пределы mode и local проверяет общий построитель.
can_id = self.lib.pcan_gas_slot_request_id(mode, slot, route) # C формирует MsgType=0x4 и HEX-страницу mode.
if can_id == 0: # Не создаём адрес для резерва страницы или неверного направления.
raise ValueError('Неверный mode, слот или направление') # Исходная ошибка не превращается в обращение к соседнему прибору.
payload = self._payload(words, route) # Данные одинаковы для обоих адресных API.
self.decode(can_id, payload) # Дополнительно проверяем маску двух действующих значений последнего слота PERIPH.
return can_id, payload # Транспорт получает готовые CAN ID и данные.
def frame_address(self, device_type, device_id, address, words=None, route=0): # Полный слотный API для любого узла, включая STM Invertor.
for value in (device_type, device_id, address, route): # Python-числа не должны оборачиваться в C unsigned.
self._unsigned(value, 0xFFFFFFFF) # Конкретную ширину полей идентификатора проверяет C.
can_id = self.lib.pcan_gas_slot_id(device_type, device_id, address, route) # Общий построитель сохраняет все адреса 0…65535.
if can_id == 0: # Нулевой результат означает ошибку адреса узла или слота.
raise ValueError('Неверный адрес узла, слота или направление') # Не скрываем ошибку входа маскированием.
return can_id, self._payload(words, route) # Не привязываем общий формат к странице PERIPH.
def _input(self, can_id, data, flags): # Проверяем размеры и представимость до создания ctypes-буферов.
self._unsigned(can_id, 0xFFFFFFFF) # C затем проверит фактические 29 бит CAN ID.
self._unsigned(flags, 0xFFFFFFFF) # Флаги IDE/RTR/ERR проверяются общим wire-кодеком.
payload = bytes(data) # Сохраняем точную длину без усечения.
if len(payload) > 8: # CAN FD не включается неявно.
raise ValueError('Classic CAN допускает до восьми октетов') # Лишние октеты остаются видимой ошибкой.
return (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload), len(payload) # Нулевой запрос не требует фиктивных данных.
def decode(self, can_id, data, flags=1): # Возвращаем mode, локальный слот и wire-слова PERIPH с маской.
raw, length = self._input(can_id, data, flags) # Получаем проверенный ограниченный буфер.
mode, slot, words = ctypes.c_uint(), ctypes.c_uint(), (ctypes.c_uint16 * 4)() # Отдельные выходы сохраняют ширину host ABI.
count = self.lib.pcan_gas_slot_decode(can_id, flags, raw, length, ctypes.byref(mode), ctypes.byref(slot), words) # C проверяет полный формат и геометрию PERIPH.
if count < 0: # Чужая страница или несуществующая позиция не становятся регистрами другого профиля.
raise ValueError('Неверный кадр GAS SLOT PERIPH') # Никакого автоматического перехода к типу 0x3.
return mode.value, slot.value, tuple(words[:count]) # Пустой кортеж означает запрос чтения.
def decode_address(self, can_id, data, flags=1): # Декодируем маску и три значения любого адресуемого узла.
raw, length = self._input(can_id, data, flags) # Проверяем точную длину до native-вызова.
words = (ctypes.c_uint16 * 4)() # Четыре wire-слова включают служебную маску.
count = self.lib.pcan_gas_slot_decode_words(can_id, flags, raw, length, words) # Общий C-кодек проверяет тип 0x4 и все транспортные условия.
if count < 0: # Ошибка не выдаётся за допустимый пустой слот.
raise ValueError('Неверный кадр GAS SLOT') # Сообщение пригодно для CAN-журнала.
return tuple(words[:count]) # Адрес и класс узла уже доступны вызывающему коду в CAN ID.
+115
View File
@@ -0,0 +1,115 @@
# Тонкий порт GAS_WATCH2: форматы запросов, подтверждений и выборок проверяет общее C-ядро.
# Python хранит неизменяемые модели и состояние подтверждённой подписки без второго кодека.
from __future__ import annotations # Откладываем вычисление аннотаций для совместимости с Python 3.8.
import ctypes # Передаём типизированные буферы единственной реализации протокола в общей библиотеке.
from dataclasses import dataclass # Неизменяемые модели фиксируют порядок адресов и значений одной конфигурации.
MAX_SIGNALS = 64 # Общий контракт GAS_WATCH2 ограничивает подписку 64 регистрами по 16 бит.
MAX_PAYLOAD_BYTES = 512 # Верхняя граница payload GUI protocol v1 позволяет C выбрать фактическую длину запроса.
def _unsigned(value: int, maximum: int, name: str) -> None: # Проверяем представимость Python-числа перед сужением через ctypes.
if type(value) is not int or not 0 <= value <= maximum: # Исключаем bool, дроби и переполнение целевого беззнакового типа.
raise ValueError(f"{name}: требуется целое число от 0 до {maximum}") # Не допускаем незаметного усечения C-аргумента.
@dataclass(frozen=True) # Изменение списка после отправки не должно менять смысл ожидаемого подтверждения.
class GasWatchConfig: # Описываем подписку отдельно от её визуального оформления и истории графика.
config_id: int # Ненулевой 32-битный идентификатор связывает выборки с конкретным порядком адресов.
period_ms: int # Беззнаковый 16-битный период задаётся в миллисекундах; ноль используется для отключения.
addresses: tuple[int, ...] # Порядок 16-битных GAS-адресов совпадает с порядком значений в выборке.
def __post_init__(self) -> None: # Нормализуем коллекцию и защищаем границу Python/C от сужения чисел.
_unsigned(self.config_id, 0xFFFFFFFF, "config_id") # Диапазон соответствует uint32_t в публичном C ABI.
if self.config_id == 0: # Нулевой идентификатор зарезервирован для состояния без установленной подписки.
raise ValueError("config_id должен быть ненулевым") # Не создаём модель, которая не может обозначать конфигурацию.
_unsigned(self.period_ms, 0xFFFF, "period_ms") # Диапазон соответствует uint16_t; логическое сочетание с адресами проверяет C.
addresses = tuple(self.addresses) # Отделяем модель от изменяемого списка, переданного вызывающим приложением.
if len(addresses) > MAX_SIGNALS: # Не передаём C массив сверх согласованного предела подписки.
raise ValueError("GAS_WATCH2 поддерживает не более 64 адресов") # Объясняем ограничение до выделения буфера ctypes.
for address in addresses: # Проверяем каждый элемент до записи в массив uint16_t.
_unsigned(address, 0xFFFF, "address") # GAS использует нулевые 16-битные индексы регистров, без расширенных адресов.
object.__setattr__(self, "addresses", addresses) # Устанавливаем неизменяемую копию внутри frozen dataclass только при создании.
@dataclass(frozen=True) # Полученная выборка остаётся стабильной при передаче между рабочим потоком и GUI.
class GasWatchSample: # Возвращаем проверенную модель, а не поля бинарного сообщения.
config_id: int # Идентификатор принадлежит уже подтверждённой конфигурации, проверенной C-декодером.
sequence: int # 32-битный номер выборки позволяет приложению обнаруживать пропуски доставки.
timestamp_ms: int # 32-битная метка устройства передаётся в миллисекундах без подмены временем GUI.
values: tuple[int | None, ...] # None обозначает ошибку источника и отличается от корректного нулевого значения.
def __post_init__(self) -> None: # Не допускаем сохранения изменяемого списка в публичной frozen-модели.
_unsigned(self.config_id, 0xFFFFFFFF, "config_id") # Сохраняем представимость идентификатора в C uint32_t.
_unsigned(self.sequence, 0xFFFFFFFF, "sequence") # Переполнение счётчика обрабатывается устройством по модулю 2^32.
_unsigned(self.timestamp_ms, 0xFFFFFFFF, "timestamp_ms") # Ограничиваем метку диапазоном миллисекундного uint32_t.
values = tuple(self.values) # Копируем контейнер, чтобы внешний список не изменял уже принятую выборку.
if len(values) > MAX_SIGNALS: # Размер результата должен помещаться в общий предел числа каналов.
raise ValueError("GAS_WATCH2 поддерживает не более 64 значений") # Отвергаем публичную модель с недопустимой геометрией.
for value in values: # Проверяем только модель данных, не выполняя повторного разбора wire-пакета.
if value is not None: # Недоступный источник представляется None и не требует числового преобразования.
_unsigned(value, 0xFFFF, "value") # Достоверное значение регистра сохраняется как беззнаковое 16-битное слово.
object.__setattr__(self, "values", values) # Закрепляем независимый кортеж в неизменяемом экземпляре.
class NativeGasWatch: # Управляем ожидаемой и активной подписками поверх единственного C-кодека GAS_WATCH2.
def __init__(self, library: ctypes.CDLL) -> None: # Принимаем уже загруженную общую библиотеку без поиска или fallback.
self.lib = library # Сохраняем владельца C-функций на весь срок жизни адаптера.
try: # Проверяем наличие всей новой возможности до настройки сигнатур ABI.
self._request = library.set_gas_watch_request # C формирует запрос и проверяет допустимость полной конфигурации.
self._ack = library.set_gas_watch_ack # C сравнивает подтверждение со всем запрошенным списком адресов.
self._decode = library.set_gas_watch_decode # C проверяет версию, идентификатор, геометрию и маску достоверности.
except AttributeError as error: # Старая DLL может оставаться пригодной для других возможностей протокола.
raise RuntimeError("Пересоберите SETProtocol с set_gas_watch.c для поддержки GAS_WATCH2") from error # Явно сообщаем о необходимой сборке вместо Python-кодека.
self._request.argtypes = [ctypes.c_uint32, ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t] # Передаём id, период, адреса и явные размеры входа/выхода по C ABI.
self._request.restype = ctypes.c_size_t # Ноль означает отказ C-валидатора, положительное значение — длину payload.
self._ack.argtypes = [ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.c_uint32, ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16), ctypes.c_size_t] # Сопоставляем байты ACK с полным содержимым ожидаемой конфигурации.
self._ack.restype = ctypes.c_int # Ненулевой результат подтверждает точное принятие подписки устройством.
self._decode.argtypes = [ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t, ctypes.c_uint32, ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16), ctypes.POINTER(ctypes.c_uint8), ctypes.c_size_t] # C получает ожидаемую конфигурацию и отдельные буферы значений и достоверности.
self._decode.restype = ctypes.c_int # Отрицательный результат означает устаревшее либо повреждённое сообщение.
self.reset() # Новый экземпляр не принимает DATA до подтверждения своей первой подписки.
def reset(self) -> None: # Сбрасываем состояние соединения при переподключении без отправки wire-команд.
self._pending: GasWatchConfig | None = None # Позднее подтверждение предыдущего соединения больше не ожидается.
self._active: GasWatchConfig | None = None # Ранее подтверждённые DATA нельзя привязать к новому соединению автоматически.
def request(self, config: GasWatchConfig) -> bytes: # Подготавливаем запрос, оставляя старую активную подписку до нового ACK.
if not isinstance(config, GasWatchConfig): # Тонкий порт требует заранее нормализованную модель вместо произвольного объекта.
raise TypeError("Ожидается GasWatchConfig") # Не допускаем обхода проверок представимости аргументов ctypes.
for known in (self._active, self._pending): # Сравниваем идентификатор с ещё значимыми конфигурациями текущего соединения.
if known is not None and known.config_id == config.config_id and known != config: # Один id не может обозначать другой порядок адресов или период.
raise ValueError("Новая конфигурация требует нового config_id") # Иначе C не сможет отличить старые DATA с тем же id и количеством.
addresses = (ctypes.c_uint16 * len(config.addresses))(*config.addresses) # Передаём C непрерывный массив слов без собственного кодирования endian.
output = (ctypes.c_uint8 * MAX_PAYLOAD_BYTES)() # Резервируем транспортный предел; реальную длину определяет C-builder.
size = self._request(config.config_id, config.period_ms, addresses, len(addresses), output, len(output)) # Единственный C-валидатор проверяет дубликаты и сочетание периода с количеством.
if not size: # При ошибке запроса сохраняем предыдущие ожидаемую и активную подписки.
raise ValueError("Недопустимая конфигурация GAS_WATCH2") # Не публикуем частично сформированный payload.
self._pending = config # После успешной сборки ожидаем ACK именно последнего отправляемого запроса.
return bytes(output[:size]) # Копируем готовые байты C без разбора или пересборки полей в Python.
def validate_ack(self, payload: bytes, config: GasWatchConfig) -> None: # Активируем подписку только после полного подтверждения её содержимого.
if self._pending is None or config != self._pending: # Поздний ACK предыдущего запроса не должен отменять более новую ожидаемую конфигурацию.
raise ValueError("Подтверждение не соответствует ожидаемой конфигурации GAS_WATCH2") # Сохраняем активную подписку при гонке ответов.
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload) # Предоставляем C входные байты без знания их внутреннего расположения.
addresses = (ctypes.c_uint16 * len(config.addresses))(*config.addresses) # Сохраняем исходный порядок для строгой проверки эха в C.
if not self._ack(data, len(data), config.config_id, config.period_ms, addresses, len(addresses)): # Требуем совпадения всей конфигурации, а не одного количества каналов.
raise ValueError("Устройство не подтвердило полную конфигурацию GAS_WATCH2") # Повреждённый или частичный ACK не изменяет состояния подписок.
self._active = config # Только подтверждённая конфигурация определяет допустимые DATA и порядок возвращаемых значений.
self._pending = None # Повторный либо поздний ACK больше не считается ответом на ожидаемую команду.
def sample(self, payload: bytes) -> GasWatchSample: # Преобразуем проверенную C выборку в неизменяемую модель языка GUI.
config = self._active # Один вызов использует один согласованный снимок ссылки на подтверждённую конфигурацию.
if config is None: # До первого ACK и после reset принадлежность значений неизвестна.
raise RuntimeError("Сначала подтвердите конфигурацию GAS_WATCH2") # Не принимаем неподтверждённые или оставшиеся от прошлого соединения DATA.
data = (ctypes.c_uint8 * len(payload)).from_buffer_copy(payload) # Буфер ctypes передаёт готовый пакет C-декодеру без Python-парсинга.
sequence = ctypes.c_uint32() # C запишет номер выборки только после полной проверки пакета.
timestamp = ctypes.c_uint32() # C вернёт исходную миллисекундную метку устройства.
values = (ctypes.c_uint16 * MAX_SIGNALS)() # Буфер рассчитан на максимальные 64 регистра, согласованные с C-контрактом.
valid = (ctypes.c_uint8 * MAX_SIGNALS)() # C разворачивает wire-маску в отдельный признак достоверности каждого значения.
count = self._decode(data, len(data), config.config_id, len(config.addresses), ctypes.byref(sequence), ctypes.byref(timestamp), values, valid, MAX_SIGNALS) # Проверку id, длины и маски полностью выполняет общее ядро.
if count < 0: # Устаревшие и повреждённые DATA не меняют конфигурацию и не попадают на график.
raise ValueError("Устаревшие или повреждённые данные GAS_WATCH2") # Не возвращаем нулевые значения из неуспешного C-вызова.
words = tuple(values[index] if valid[index] else None for index in range(count)) # Преобразуем результат C в модель, сохраняя различие отсутствующего значения и нуля.
return GasWatchSample(config.config_id, sequence.value, timestamp.value, words) # Закрепляем идентификатор именно той конфигурации, которую подтвердил C-декодер.
+39 -5
View File
@@ -46,7 +46,7 @@ class MsgType(IntEnum):
DISCRETE = 0b0001
ANALOG = 0b0010
GENERAL_ADDRESS_SPACE = 0b0011
MODBUS_COIL = 0b0100
GAS_SLOT = 0b0100 # Тип 0x4 однозначно выбирает слотный GAS с маской вместо прежнего Modbus Coil.
MODBUS_DISCRETE = 0b0101
MODBUS_HOLDING = 0b0110
MODBUS_INPUT = 0b0111
@@ -115,7 +115,7 @@ MSGTYPE_RU = {
MsgType.DISCRETE: "Discrete",
MsgType.ANALOG: "Analog",
MsgType.GENERAL_ADDRESS_SPACE: "General Address Space",
MsgType.MODBUS_COIL: "Modbus Coil",
MsgType.GAS_SLOT: "GAS Slot", # Отдельное имя показывает, что адрес в Body относится к слоту, а не к Modbus-регистру.
MsgType.MODBUS_DISCRETE: "Modbus Discrete",
MsgType.MODBUS_HOLDING: "Modbus Holding",
MsgType.MODBUS_INPUT: "Modbus Input",
@@ -134,14 +134,26 @@ MSGTYPE_RU = {
#: но не присваивает приборам имён.
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",
@@ -408,9 +420,10 @@ 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:
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)
@@ -431,12 +444,27 @@ def decode(raw_id: int, data: bytes, ide: bool = True, rtr: bool = False) -> Dec
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_COIL: _decode_modbus_bit,
MsgType.MODBUS_DISCRETE: _decode_modbus_bit,
MsgType.MODBUS_HOLDING: _decode_modbus_reg,
MsgType.MODBUS_INPUT: _decode_modbus_reg,
@@ -611,6 +639,12 @@ def _decode_gas(res: Decoded) -> None:
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))
@@ -631,7 +665,7 @@ def _decode_gas(res: Decoded) -> None:
def _decode_modbus_bit(res: Decoded) -> None:
str_adr, reg_count = split_modbus(res.id.body)
kind = "COIL" if res.id.msg_type == MsgType.MODBUS_COIL else "DISCRETE"
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
+2
View File
@@ -51,6 +51,8 @@ class MessageType(IntEnum):
GAS_CATALOG = 0x11
GAS_WATCH_SET = 0x12
GAS_WATCH_DATA = 0x13
GAS_WATCH2_SET = 0x14 # Новый тип подписки передаёт идентификатор и полный упорядоченный список GAS-регистров, сохраняя старый 0x12.
GAS_WATCH2_DATA = 0x15 # Новый тип выборки связывает значения с конфигурацией и признаками достоверности, сохраняя старый 0x13.
SENSOR_SCAN = 0x20
SENSOR_LIST = 0x21
SENSOR_READ = 0x22