Сохрани адресацию отдельных регистров GAS 0x3 и выдели слотам тип 0x4 с маской Word0. Добавь единое C-ядро GAS_WATCH2, тонкие Python/JNI-порты и проверки общих эталонных байтов. Совместимость: тип 0x4 больше не обслуживает Modbus Coil. Потребителям PCAN_MSG_MODBUS_COIL и ProtoCanMsgToModbusCoil нужно перейти на GAS_SLOT и отдельный API слотов; регистровый GAS остаётся на 0x3.
116 lines
18 KiB
Python
116 lines
18 KiB
Python
# Тонкий порт 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-декодер.
|