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:
@@ -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, без настройки профиля.
|
||||
|
||||
@@ -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.
|
||||
@@ -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-декодер.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user