diff --git a/c/set-protocol/CMakeLists.txt b/c/set-protocol/CMakeLists.txt
index 6a6b56d..e017090 100644
--- a/c/set-protocol/CMakeLists.txt
+++ b/c/set-protocol/CMakeLists.txt
@@ -20,6 +20,7 @@ set(SETPROTOCOL_V2_SOURCES
src/set_telemetry.c
src/set_plot.c
src/set_trends.c
+ src/set_gas_watch.c # Собирает единый кодек GAS_WATCH2 и MCU-захват выбранных GAS-слов для static/shared целей.
src/set_spectrum.c
src/set_signal.c
src/set_wavegen.c
@@ -37,6 +38,7 @@ set(SETPROTOCOL_LEGACY_SOURCES
src/pcan_crc.c
src/pcan_frame.c
src/pcan_gas.c
+ src/pcan_gas_slot.c # Собирает GAS SLOT типа 0x4 с Word0-маской и тремя значениями независимо от регистрового GAS 0x3.
src/pcan_id.c
src/pcan_link.c
src/pcan_modbus_server.c
@@ -94,6 +96,12 @@ endif()
option(SETP_BUILD_TESTS "Build host tests" ON)
if(SETP_BUILD_TESTS)
enable_testing()
+ add_executable(test_gas_register tests/test_gas_register.c) # Проверяет запись одной уставки без соседей, границы 0…65535 и изоляцию типа 0x3.
+ target_link_libraries(test_gas_register PRIVATE setprotocol_static) # Запускает проверки на том же ядре, которое используют прошивки и host-клиенты.
+ add_test(NAME protocan_gas_register COMMAND test_gas_register) # Отдельный тест защищает восстановленный регистровый контракт.
+ add_executable(test_gas_slot tests/test_gas_slot.c) # Проверяет MsgType=0x4, маску Word0, сохранение соседей и границы PERIPH.
+ target_link_libraries(test_gas_slot PRIVATE setprotocol_static) # Использует ту же библиотеку, что MCU и клиенты.
+ add_test(NAME protocan_gas_slot COMMAND test_gas_slot) # Делает проверку нового формата частью штатного CTest.
add_executable(test_emulator_services tests/test_emulator_services.c ports/example-emulator/service.c)
target_link_libraries(test_emulator_services PRIVATE setprotocol_static)
add_test(NAME emulator_services COMMAND test_emulator_services)
@@ -170,6 +178,9 @@ if(SETP_BUILD_TESTS)
add_executable(test_trends tests/test_trends.c)
target_link_libraries(test_trends PRIVATE setprotocol_static)
add_test(NAME shared_trends COMMAND test_trends)
+ add_executable(test_gas_watch tests/test_gas_watch.c) # Собирает транзакционные проверки конфигурации и эталонных байтов GAS_WATCH2.
+ target_link_libraries(test_gas_watch PRIVATE setprotocol_static) # Проверяет настоящее общее ядро, а не копию алгоритма в тесте.
+ add_test(NAME gas_watch_v2 COMMAND test_gas_watch) # Регистрирует новый контракт рядом с неизменёнными legacy-проверками.
add_executable(test_spectrum tests/test_spectrum.c)
target_link_libraries(test_spectrum PRIVATE setprotocol_static)
add_test(NAME shared_spectrum COMMAND test_spectrum)
diff --git a/c/set-protocol/README.md b/c/set-protocol/README.md
index 170cc1b..0320214 100644
--- a/c/set-protocol/README.md
+++ b/c/set-protocol/README.md
@@ -1,5 +1,10 @@
# SETProtocol
+Пакетный сбор выбранных GAS-регистров: [GAS_WATCH2](docs/GAS_WATCH2.md).
+Конфигурация применяется целиком; выборка содержит поколение списка,
+номер захвата, время устройства и достоверность каждого слова. Старый GAS_WATCH
+сохраняет формат; новые C/ctypes/JNI-адаптеры включаются явно.
+
`periph28335_eeprom_*` — read-only reader физической EEPROM Бальзам-167
(AT25256B, 32 КиБ), со стабильным C ABI и портом
`python/protocan/periph28335_eeprom.py`. Разметка платы задана в
@@ -112,3 +117,7 @@ FFI-клиенты подключают `setprotocol_abi.h`. Имена функ
[PLOT_PROCESSING.md](docs/PLOT_PROCESSING.md).
Генератор сигнала, USB и порты ЦАП F407/G474:
[SIGNAL_GENERATOR.md](docs/SIGNAL_GENERATOR.md).
+
+## GAS: регистры 0x3 и слоты 0x4
+
+[Адресация GAS](docs/GAS_SLOT.md): `0x3` — отдельные 16-битные регистры `0…65535`, запись 1…4 слов. `0x4` — слот с Word0-маской и тремя значениями; PERIPH сохраняет 200 слов в 67 слотах `0xMM00…0xMM42`. Формат определяется MsgType, без настройки профиля.
diff --git a/c/set-protocol/docs/GAS_SLOT.md b/c/set-protocol/docs/GAS_SLOT.md
new file mode 100644
index 0000000..e2274c3
--- /dev/null
+++ b/c/set-protocol/docs/GAS_SLOT.md
@@ -0,0 +1,27 @@
+# GAS SLOT 0x4: маска в Word0
+
+Нормативный контракт: [ОАП](../../../doc/setcan/protocan/OAP.md). GAS `0x3` адресует отдельные 16-битные регистры; GAS SLOT `0x4` использует собственный адрес слота и заменяет прежний Modbus Coil.
+
+Один кадр: `Word0=маска`, `Word1`, `Word2`, `Word3`; все слова little-endian. В Word0 допустимы только непустые комбинации битов 1…3 (`0x0002…0x000E` с нулевым битом 0). Бит выбирает одноимённое слово. Запись всегда DLC=8; невыбранные значения игнорируются и сохраняются в устройстве. `DLC=0` запрашивает чтение; ответ DLC=8 содержит маску достоверных слов. RTR, standard/error frame, пустой ответ и усечённые данные отвергаются.
+
+Изменить только Word2 на `0x1234`: payload `04 00 00 00 34 12 00 00`. Полная запись трёх значений использует маску `0x000E`. Word0 — метаданные, не четвёртый регистр.
+
+## MCU
+
+`pcan_gas_slot_handle_address(type, id, port, request, response)` обслуживает любой узел и слот 0…65535. Возвращает 1 при ответе, 0 после записи, -1 при отказе. Provider чтения заполняет четыре wire-слова, включая Word0-маску; provider записи сначала проверяет все выбранные права и значения, затем изменяет только отмеченные позиции под общей блокировкой. Кодек не гарантирует транзакционность внешних Flash/callback операций.
+
+`pcan_gas_slot_handle(mode, ...)` дополнительно проверяет страницу PERIPH. `Slot=(mode<<8)|LocalSlot`, LocalSlot 0…66. Три значения на слот сохраняют 200 слов на mode; в последнем слоте доступны Word1/Word2. `pcan_gas_slot_native(local, word, write, &address)` принимает word 1…3 и переводит `3*local+word-1` в прежние native-сервисы. Нулевая позиция, резерв и запрещённая запись отвергаются.
+
+Вызывать обработчик из главного цикла/задачи; ISR только принимает кадры. DLC измеряется wire-октетами, в том числе на C28x с 16-битным char. Общий формат не выделяет все 384 КиБ логического пространства в RAM.
+
+## Python и Android
+
+`GasSlots.frame(5, 0)` возвращает `(0x11540500, b'')`. `frame(5, 0, [0x0004, 0, 0x1234, 0])` меняет только Word2. `frame_address(7, 14, 65535, [...])` адресует независимый узел и полный 16-битный слот. `decode()` проверяет PERIPH, `decode_address()` — общий формат. Wire-маска и endian формируются единственным C-кодеком.
+
+Android/JNI: `nativeRequestId`/`nativeDecode` для PERIPH; `nativeAddressId`/`nativeDecodeWords` для общего узла; `nativeEncode` принимает маску и три значения. Пустой запрос возвращает пустой набор значений; ошибка — null.
+
+В CAN-журнале тип 0x4 выбирается автоматически по MsgType. Параметра `gas_profile` нет; 0x3 всегда отображается как регистры. GAS_WATCH2 и регистровые провайдеры 0x3 сохраняют адреса и ABI.
+
+## Проверки
+
+CTest: `protocan_gas_register`, `protocan_gas_slot`, `protocan_modbus_server`; Python: `test_gas_slots.py`; JNI host smoke. Общие эталоны: `doc/setcan/protocan/examples/gas-slot-vectors.json`. Старые узлы Coil 0x4 несовместимы с новым назначением и обновляются согласованно.
diff --git a/c/set-protocol/docs/GAS_WATCH2.md b/c/set-protocol/docs/GAS_WATCH2.md
new file mode 100644
index 0000000..f3261f7
--- /dev/null
+++ b/c/set-protocol/docs/GAS_WATCH2.md
@@ -0,0 +1,202 @@
+# GAS_WATCH2: список адресов и пакетная выборка
+
+> GAS `0x3` использует адреса отдельных 16-битных регистров. GAS SLOT `0x4` — отдельный сервис с маской в Word0 и тремя значениями Word1…Word3. Спецификация: `doc/setcan/protocan/OAP.md`; адреса этого документа остаются регистровыми.
+
+`include/set_gas_watch.h` и `src/set_gas_watch.c` реализуют выбранный клиентом
+список GAS-регистров, транзакционную настройку и захват значений в отдельную
+структуру. График и архив могут использовать одну готовую выборку.
+Ядро — C99, без HAL, часов ОС, heap, глобального состояния и драйверов связи.
+
+Старые `gui_watch_t`, `GAS_WATCH_SET` / `GAS_WATCH_DATA` и ABI
+`set_trend_watch_*` сохраняют сигнатуры и побайтовый формат.
+Расширение включается явно и не меняет существующую типизированную
+телеметрию `set_telemetry` / SETProtocol v2 `SUBSCRIBE`.
+
+## Что подключается к МК
+
+Код `templates` компилируется в прошивку. Она создаёт `set_gas_watch_t`
+в собственной RAM, предоставляет `pcan_gas_map_t` и вызывает API из своего
+диспетчера команд и таймера. Нового процесса или среды исполнения на МК нет.
+
+| Компонент | Ответственность |
+|---|---|
+| `set_gas_watch_t` | Собственная копия списка адресов, период, поколение и расписание |
+| `set_gas_watch_sample_t` | Значения, достоверность каждого слова, время и номер захвата |
+| `pcan_gas_map_t` | Доступ к RAM/обработчикам источников; GAS-адрес не является указателем MCU |
+| Порт платы | Блокировка, локальное время, таймер, диспетчер и очередь передачи |
+| Python/JNI/Kotlin | Преобразование моделей и pending/active без второго кодека |
+| GUI | Имена, типы, масштаб, цвет, видимость и объединение запросов вкладок |
+
+Минимальные исходники: `set_gas_watch.c`, `pcan_gas.c`, `pcan_id.c`,
+`set_protocol.c`; заголовки берутся из `include`. Полная CMake/NDK/host-сборка
+уже включает новый модуль. Состояние и выборка используют ограниченные
+массивы; реальные размеры структур определяются `sizeof` на целевом MCU.
+
+Пример последовательности вызовов в прошивке:
+
+```c
+#include "set_gas_watch.h" /* Подключает список GAS-слов и независимый от транспорта захват. */
+static set_gas_watch_t watch; /* Хранит одну управляемую подписку в RAM платы; память выделяет проект. */
+static set_gas_watch_sample_t sample; /* Сохраняет выборку до завершения работы всех её потребителей. */
+/* При старте сессии: set_gas_watch_init(&watch); память и timer предоставляет прошивка. */
+/* В диспетчере, под общей блокировкой: set_gas_watch_apply(&watch, &map, payload, size). */
+/* При успехе, под той же блокировкой: set_gas_watch_encode_ack(&watch, tx, sizeof(tx)). */
+/* После снятия блокировки: отправить ACK транспортом проекта, при ошибке вернуть NACK/статус. */
+/* В таймере, под общей блокировкой: проверить set_gas_watch_due(&watch, now_ms). */
+/* Когда пора: set_gas_watch_capture(&watch, &map, now_ms, &sample), затем снять блокировку. */
+/* В задаче передачи: set_gas_watch_encode_sample(&sample, tx, sizeof(tx)), затем поставить tx в очередь. */
+```
+
+Пример показывает места вызовов; проект предоставляет `map`, `payload`,
+буферы, `now_ms`, lock и транспорт. Он обязан проверять результаты API.
+Для `[0x0000, 0x0001, 0xFF01]` ядро читает три разрозненных GAS-слова
+и формирует один логический пакет, не читая промежуточный диапазон.
+Адреса передаются при настройке; DATA содержит значения и метаданные.
+
+## Настройка и поколения
+
+Набор ограничен **64 словами по 16 бит**, а не 64 произвольными параметрами.
+Многословный параметр занимает несколько позиций. Дубли адресов отклоняются;
+владелец подписки объединяет запросы вкладок до отправки.
+
+`set_gas_watch_request` проверяет модель и строит payload.
+`set_gas_watch_apply` проверяет весь пакет и карту: все адреса должны
+существовать, разрешать чтение и иметь RAM либо callback чтения. Проверка
+не вызывает источники. Отказ сохраняет прежние адреса, период, sequence и
+расписание. Частичного принятия нет; коды заданы в `set_gas_watch_result_t`.
+
+`config_id` — ненулевой 32-битный номер, назначаемый управляющим клиентом.
+Любое изменение периода или порядка/состава адресов требует нового поколения.
+После первой команды принимается модульная разность `new_id - old_id`
+в диапазоне `1..0x7FFFFFFF`. Переход `0xFFFFFFFF → 1` допустим, ноль
+зарезервирован. Разрыв между последовательными поколениями меньше `2^31`.
+Поколения нельзя переиспользовать, пока в обмене могут оставаться старые пакеты.
+
+Точный повтор действующего id со всем прежним содержимым успешен и не
+сбрасывает захват: потерянный ACK можно запросить повторно. Тот же id
+с другим содержимым, старый id и разность `2^31` дают `SET_GAS_WATCH_CONFLICT`.
+Остановка — новая конфигурация `period_ms = 0, count = 0`, карта не требуется.
+Пустой список с ненулевым периодом и непустой список с нулевым запрещены.
+
+`set_gas_watch_encode_ack` возвращает полную применённую конфигурацию,
+включая адреса. Клиент проверяет её через `set_gas_watch_ack` и заменяет
+active только после успеха. Новая команда хранится как pending; DATA
+сопоставляются с active. ACK уже заменённого pending отвергается.
+Ошибка команды/ACK сохраняет прежний active.
+
+Один экземпляр обслуживает одного управляющего клиента. Если устройство
+сохранило состояние после переподключения, клиент продолжает номера поколений.
+`reset()` адаптера очищает только его pending/active, не посылает stop
+и не сбрасывает устройство. Проект может явно вызвать `set_gas_watch_init`
+при завершении управляющей сессии, разрешив новой сессии произвольный первый id.
+
+## Захват и синхронизация
+
+Приложение сериализует все обращения к состоянию, изменения карты,
+обновления исходного кэша и захват одним подходящим lock/IRQ-портом.
+Транзакционность API означает отсутствие частичной настройки при ошибке;
+присваивание C-структуры само по себе не даёт межпоточную атомарность.
+Ядро не управляет IRQ и не содержит скрытых ожиданий.
+
+Под lock выполняются применение конфигурации, подготовка ACK,
+проверка `set_gas_watch_due` и `set_gas_watch_capture`. Сам `capture`
+допускает явный захват вне расписания и не проверяет due.
+После снятия lock приложение кодирует готовую выборку и передаёт её
+транспорту. Кодировщик не обращается к источникам.
+
+При блокировке IRQ callbacks читают только быструю RAM: без запросов
+CAN/Modbus, ожидания периферии и выделения памяти. Удалённые источники
+обновляют кэш заранее. Общая блокировка согласует чтение кэша, но не
+синхронизирует физические измерения нескольких устройств и не определяет
+свежесть удалённых данных.
+
+Отказ отдельного источника даёт `valid = 0, value = 0` и не отменяет выборку.
+Корректное нулевое значение имеет `valid = 1`. Повреждённая карта целиком
+или остановленная подписка возвращают `false` без изменения состояния и выхода.
+
+Первый номер выборки нового поколения — 0; далее он увеличивается по
+модулю `2^32`. Каждый захват учитывается, даже если транспорт затем его
+потерял. Повторная отправка готовой выборки сохраняет тот же номер.
+Время передаётся портом **в миллисекундах устройства**, без подмены часами GUI.
+Потребитель разворачивает переполнение времени в длинную ось своего сеанса.
+
+Первый захват разрешён сразу; следующий срок — `now_ms + period_ms`.
+После опоздания нет пачки догоняющих выборок. Приложение проверяет таймер
+чаще одного раза за `2^31` мс для корректного модульного сравнения u32.
+
+Изменение GAS и конфигурации не меняет готовую выборку. Приложение хранит
+её до окончания чтения всеми потребителями, используя очередь или двойной
+буфер при необходимости. Входные/выходные структуры и массивы API не
+должны перекрываться в памяти.
+
+## Wire payload версии 2
+
+Числа little-endian, размеры ниже в байтах. Выравнивание C-структур
+в обмене не используется. Для GUI protocol v1 добавлены отдельные типы:
+
+| Тип GUI v1 | Назначение |
+|---|---|
+| `0x14 GAS_WATCH2_SET` | Настройка и полное эхо принятой конфигурации |
+| `0x15 GAS_WATCH2_DATA` | Выборка; внешний sequence равен 0 |
+
+Внешняя версия остаётся GUI v1. Старые типы `0x12/0x13` не меняются.
+Неподдерживаемая команда завершается явным отказом, автоматического fallback нет.
+`0x0014 UNSUBSCRIBE` SETProtocol v2 относится к другому кадрированию
+и сохраняет смысл. Обёртка нового GAS_WATCH2 для SETProtocol v2 согласуется
+отдельно с его существующей типизированной телеметрией.
+
+Конфигурация и ACK: `12 + 2N` байт, максимум 140.
+
+| Смещение | Тип | Значение |
+|---:|---|---|
+| 0 | u16 | версия payload: 2 |
+| 2 | u16 | период, мс |
+| 4 | u32 | config_id, не ноль |
+| 8 | u16 | N, число адресов: 0..64 |
+| 10 | u16 | резерв, строго 0 |
+| 12 | N × u16 | уникальные GAS-адреса в порядке значений |
+
+Выборка: `16 + ceil(N/8) + 2N` байт, максимум 152.
+
+| Смещение | Тип | Значение |
+|---:|---|---|
+| 0 | u16 | версия payload: 2 |
+| 2 | u16 | N, число значений: 1..64 |
+| 4 | u32 | config_id |
+| 8 | u32 | sequence захвата |
+| 12 | u32 | timestamp_ms устройства |
+| 16 | ceil(N/8) байт | битовая маска достоверности |
+| 16 + ceil(N/8) | N × u16 | сырые значения в порядке списка |
+
+Канал i соответствует биту `i % 8` байта `i / 8`, младший бит первым.
+Неиспользуемые старшие биты последнего байта нулевые. Недостоверные слова
+обязательно нулевые: кодировщик нормализует их, декодировщик отвергает
+неканонический вход. Ошибки версии, точной длины, id, ожидаемого количества
+или вместимости выхода возвращают `-1` без изменения выходных аргументов.
+Успешный результат — число слов, включая отмеченные недостоверными.
+
+Classic CAN требует сегментации логического пакета портом: восемь байт
+данных кадра не вмещают всю выборку. Окно Modbus для снимка подключается
+отдельным проектным адаптером.
+
+## Клиенты и проверки
+
+`python/protocan/gas_watch.py` предоставляет `GasWatchConfig`,
+`GasWatchSample`, `NativeGasWatch`; Android-порт — `GuiGasWatch2.kt` и JNI.
+Оба используют C request/ack/decode; недостоверный канал представлен
+Python `None` / Kotlin `null`. Старый DLL сообщает о необходимости
+пересборки нового режима вместо второго кодека на языке GUI.
+
+Стабильные ABI-символы `set_gas_watch_request`, `set_gas_watch_ack`,
+`set_gas_watch_decode` добавлены без изменения версии и прежних символов.
+Сборки CMake, прямой host builder и Android NDK включают новый исходник.
+Общий эталон — `tests/fixtures/gas-watch-v2.json`.
+C-тест `gas_watch_v2` проверяет wire, транзакционность, поколения,
+ошибки источников, независимость выборки, bitmap и переполнения.
+Python/JVM используют те же байты через настоящие DLL/JNI.
+
+Это изменение общего ядра и адаптеров в `templates`. Переключение внешних
+SETGUI/Android на новую ревизию сабмодуля, объединение подписок вкладок
+и подключение к диспетчеру конкретной платы выполняются в этих проектах.
+Архивный `c/gas-logger` сохраняет собственный формат истории; его
+динамическая перенастройка здесь не включается.
diff --git a/c/set-protocol/docs/GUI_TRENDS.md b/c/set-protocol/docs/GUI_TRENDS.md
index 2898b89..cf21839 100644
--- a/c/set-protocol/docs/GUI_TRENDS.md
+++ b/c/set-protocol/docs/GUI_TRENDS.md
@@ -1,5 +1,9 @@
# Общие тренды для Android GUI и SETGUI
+Расширенная подписка с подтверждением полного списка, поколением конфигурации
+и признаками достоверности: [GAS_WATCH2](GAS_WATCH2.md). Она включается отдельно;
+описанный ниже прежний GAS_WATCH и формат настроек сохраняют совместимость.
+
Модуль задаёт совместимые настройки и числовые источники графиков. Он не
рисует экран, не открывает файлы или порты и не управляет прошивкой устройства.
diff --git a/c/set-protocol/docs/legacy/GAS.md b/c/set-protocol/docs/legacy/GAS.md
index 51207fc..ceed7be 100644
--- a/c/set-protocol/docs/legacy/GAS.md
+++ b/c/set-protocol/docs/legacy/GAS.md
@@ -1,5 +1,7 @@
# Общее адресное пространство (GAS)
+> GAS `0x3` использует адреса отдельных 16-битных регистров. GAS SLOT `0x4` — отдельный сервис с маской в Word0 и тремя значениями Word1…Word3. Спецификация: `doc/setcan/protocan/OAP.md`; адреса этого документа остаются регистровыми.
+
Плоское пространство 16-битных регистров с адресом `0x0000..0xFFFF`.
Пространство собирается из **регионов**; регионы не перекрываются и
хранятся отсортированными по адресу, поиск — двоичный.
diff --git a/c/set-protocol/docs/legacy/OAP.md b/c/set-protocol/docs/legacy/OAP.md
index b33a350..34ce935 100644
--- a/c/set-protocol/docs/legacy/OAP.md
+++ b/c/set-protocol/docs/legacy/OAP.md
@@ -1,14 +1,16 @@
# Общее адресное пространство
+> GAS `0x3` использует адреса отдельных 16-битных регистров. GAS SLOT `0x4` — отдельный сервис с маской в Word0 и тремя значениями Word1…Word3. Спецификация: `doc/setcan/protocan/OAP.md`; адреса этого документа остаются регистровыми.
+
Статус: **Stable, данные ведутся в XLSX**
Порядок значений: **16-битные регистры, little-endian в CAN payload**
Редактируемый источник реестра:
-[`Протокол CAN и ОАП.xlsx`](../../Протокол%20CAN%20и%20ОАП.xlsx).
+[`Протокол CAN и ОАП.xlsx`](../../../../doc/setcan/Протокол%20CAN%20и%20ОАП.xlsx).
Просматриваемая большая таблица находится в
-[`Протокол CAN и ОАП.html`](../../Протокол%20CAN%20и%20ОАП.html) и
-[`Протокол CAN и ОАП.md`](../../Протокол%20CAN%20и%20ОАП.md).
+[`Протокол CAN и ОАП.html`](../../../../doc/setcan/Протокол%20CAN%20и%20ОАП.html) и
+[`Протокол CAN и ОАП.md`](../../../../doc/setcan/Протокол%20CAN%20и%20ОАП.md).
## Назначение
diff --git a/c/set-protocol/docs/legacy/PROTOCOL.md b/c/set-protocol/docs/legacy/PROTOCOL.md
index 5f76f3c..3daef73 100644
--- a/c/set-protocol/docs/legacy/PROTOCOL.md
+++ b/c/set-protocol/docs/legacy/PROTOCOL.md
@@ -1,7 +1,9 @@
# ProtoCAN — базовый протокол
+> GAS `0x3` использует адреса отдельных 16-битных регистров. GAS SLOT `0x4` — отдельный сервис с маской в Word0 и тремя значениями Word1…Word3. Полный контракт: [ОАП](../../../../doc/setcan/protocan/OAP.md). Существующие регистровые сервисы сохраняют свои адреса.
+
Статус: **Stable с зарезервированным загрузочным расширением**
-Версия: **1.0**
+Версия: **2.1: GAS 0x3 и масочный GAS SLOT 0x4**
Порядок байтов payload: **little-endian**, если явно не указано иное
## Назначение
@@ -59,8 +61,8 @@ can_id =
| `0x0` | `BROADCAST` | ПМ → все | зависит от команды | stable |
| `0x1` | `DISCRETE` | оба | 0…8 | stable |
| `0x2` | `ANALOG` | оба | 0…8 | stable |
-| `0x3` | `GAS` | оба | 0/2/4/6/8 | stable |
-| `0x4` | `MODBUS_COIL` | оба | 0…8 | stable |
+| `0x3` | `GAS` | оба | 0/2/4/6/8 | регистры 0…65535 |
+| `0x4` | `GAS_SLOT` | оба | 0/8 | Word0 — маска, Word1…Word3 — значения |
| `0x5` | `MODBUS_DISCRETE` | оба | 0…8 | stable |
| `0x6` | `MODBUS_HOLDING` | оба | 0…8 | stable |
| `0x7` | `MODBUS_INPUT` | оба | 0…8 | stable |
@@ -82,7 +84,8 @@ can_id =
| broadcast | команда `[15:4]`, параметр `[3:0]` |
| discrete/analog | подтип `[15:12]`, значение/адрес `[11:0]` |
| Modbus | начальный адрес `[15:4]`, количество `[3:0]` |
-| GAS | адрес первого 16-битного регистра `[15:0]` |
+| GAS 0x3 | адрес первого 16-битного регистра `[15:0]` |
+| GAS SLOT 0x4 | адрес слота `[15:0]`; маска — в Word0 payload, значения — Word1…Word3 |
| error | дополнительная информация `[15:8]`, код `[7:0]` |
| settings | номер сборки `[15:8]`, позиция `[7:0]` |
| boot control/status | `SessionID[15:8]`, команда `[7:0]` |
diff --git a/c/set-protocol/include/gui_frame.h b/c/set-protocol/include/gui_frame.h
index 7b8a784..a0bcbd9 100644
--- a/c/set-protocol/include/gui_frame.h
+++ b/c/set-protocol/include/gui_frame.h
@@ -61,6 +61,8 @@ extern "C" {
#define GUI_MSG_GAS_CATALOG 0x11U
#define GUI_MSG_GAS_WATCH_SET 0x12U
#define GUI_MSG_GAS_WATCH_DATA 0x13U
+#define GUI_MSG_GAS_WATCH2_SET 0x14U /* Новый тип GUI v1 для версионированного полного списка GAS; payload описан в GAS_WATCH2.md. */
+#define GUI_MSG_GAS_WATCH2_DATA 0x15U /* Новый тип GUI v1 для самостоятельной выборки с поколением, временем и маской достоверности. */
#define GUI_MSG_NACK 0x80U
#define GUI_MSG_ACK 0x81U
#define GUI_MSG_ERROR 0x82U
diff --git a/c/set-protocol/include/pcan_gas.h b/c/set-protocol/include/pcan_gas.h
index 312e910..b101ba5 100644
--- a/c/set-protocol/include/pcan_gas.h
+++ b/c/set-protocol/include/pcan_gas.h
@@ -8,6 +8,7 @@
* @file pcan_gas.h
* @brief Общее адресное пространство (General Address Space).
*
+ * Тип 0x3 адресует отдельные регистры; независимый тип 0x4 описан в pcan_gas_slot.h.
* Плоское пространство 16-битных регистров с адресом 0x0000..0xFFFF,
* собранное из регионов. Регион либо ссылается на массив в памяти,
* либо обслуживается колбэками - так в карту попадают и обычные
@@ -120,7 +121,7 @@ void pcan_gas_regs_to_frame(pcan_frame_t *frame, uint32_t base_id,
*
* DLC = 0 - запрос на чтение: в rsp кладётся до 4 регистров с адреса
* MsgBody, Route переключается на FROM_DEVICE.
- * DLC > 0 - запись: регистры пишутся в карту, ответ не формируется.
+ * DLC = 2/4/6/8 - запись одного/двух/трёх/четырёх регистров; остальные слова не меняются.
*
* @param[out] rsp кадр ответа; заполняется, только если функция вернула true.
* @return true, если ответ нужно передать.
diff --git a/c/set-protocol/include/pcan_gas_slot.h b/c/set-protocol/include/pcan_gas_slot.h
new file mode 100644
index 0000000..12ce89e
--- /dev/null
+++ b/c/set-protocol/include/pcan_gas_slot.h
@@ -0,0 +1,48 @@
+#ifndef PCAN_GAS_SLOT_H /* Отдельный интерфейс исключает смешение нового номера слота со старым адресом регистра. */
+#define PCAN_GAS_SLOT_H /* Защита допускает включение из общего заголовка и непосредственно из порта MCU. */
+#include /* Фиксированные слова и uint_least8_t доступны также на C28x без восьмибитного char. */
+#include /* NULL используется без зависимости от ОС и динамической памяти. */
+#include /* CHAR_BIT выбирает экспорт host ABI отдельно от 16-битного C28x. */
+#if CHAR_BIT == 8 /* Host/ARM используют существующий экспорт DLL/SO. */
+#include "pcan_abi.h" /* Переиспользуем публичный экспорт единого host-кодека. */
+#elif !defined(PCAN_ABI_API) /* Встроенный C28x статически собирает функции без Windows-атрибутов. */
+#define PCAN_ABI_API /* Не требуем отсутствующий на C28x uint8_t из host ABI. */
+#endif /* Все последующие объявления одинаковы по смыслу на ARM, host и C28x. */
+#ifdef __cplusplus /* C++ и JNI связываются с C99-ядром через стандартные C-имена. */
+extern "C" { /* Начинаем область совместимого C ABI. */
+#endif /* Дальнейшие объявления пригодны также для чистого C. */
+#define PCAN_GAS_SLOT_WORDS 4U /* Восемь октетов кадра содержат четыре wire-слова: маску Word0 и три значения Word1…Word3. */
+#define PCAN_GAS_SLOT_DATA_WORDS 3U /* Два октета маски оставляют шесть октетов для трёх независимых 16-битных значений. */
+#define PCAN_GAS_SLOT_MASK 0x000EU /* Биты 1…3 выбирают одноимённые значения; бит 0 и биты 4…15 зарезервированы. */
+#define PCAN_GAS_SLOT_MODE_COUNT 32U /* Два типа PERIPH по шестнадцать DeviceID дают mode 0…31. */
+#define PCAN_GAS_SLOT_MODE_SLOTS 67U /* Округление 200/3 вверх сохраняет все регистры PERIPH; в последнем слоте действуют два значения. */
+#define PCAN_GAS_SLOT_MODE_WORDS 200U /* Значение измеряется в 16-битных словах, не в байтах MCU. */
+#define PCAN_GAS_SLOT_FLAG_IDE 1U /* Флаг Extended ID совпадает с транспортом pcan_frame; отдельно объявлен для C28x-порта. */
+#define PCAN_GAS_SLOT_FLAG_RTR 2U /* RTR запрещён слотным профилем; запрос чтения использует data frame с DLC=0. */
+#define PCAN_GAS_SLOT_FLAG_ERR 8U /* Ошибочный кадр моста не должен читаться как запрос к данным. */
+typedef int (*pcan_gas_slot_read_fn)(void *, uint16_t, uint16_t[4]); /* Provider возвращает маску достоверных значений в Word0 и три значения; ноль означает успех. */
+typedef int (*pcan_gas_slot_write_fn)(void *, uint16_t, const uint16_t[4]); /* Provider сначала проверяет все выбранные слова, затем меняет только биты 1…3 маски Word0 под общей блокировкой. */
+typedef struct { /* Независимый MCU-кадр не меняет legacy ABI и не требует uint8_t на C28x. */
+ uint32_t id; /* Расширенный CAN ID содержит адрес узла и 16-битный номер слота. */
+ unsigned flags; /* IDE/RTR/ERR используют документированные значения транспортных флагов. */
+ unsigned dlc; /* Длина измеряется в октетах линии, а не в sizeof элементов C28x. */
+ uint_least8_t data[8]; /* На C28x октет хранится в 16-битной ячейке; значения строго 0…255. */
+} pcan_gas_slot_frame_t; /* Порт переводит структуру в регистры CAN, не копируя wire-структуру через ABI. */
+typedef struct { /* Один порт соединяет общий обработчик с данными конкретного узла без глобальной памяти. */
+ void *context; /* Контекст принадлежит проекту и должен жить дольше обработчика. */
+ pcan_gas_slot_read_fn read; /* NULL запрещает чтение; callback получает полный 16-битный адрес слота. */
+ pcan_gas_slot_write_fn write; /* NULL запрещает запись; callback не должен менять соседние слоты. */
+} pcan_gas_slot_port_t; /* Размер существующих структур pcan_gas не изменяется. */
+PCAN_ABI_API int32_t pcan_gas_slot_address(unsigned mode, unsigned local); /* Возвращает (mode<<8)|local либо -1 для mode>31 или local>66. */
+PCAN_ABI_API uint32_t pcan_gas_slot_id(unsigned device_type, unsigned device_id, unsigned address, unsigned route); /* Формирует MsgType=0x4 для любого узла и адреса 0…65535 без сужения неверных входов. */
+PCAN_ABI_API uint32_t pcan_gas_slot_request_id(unsigned mode, unsigned local, unsigned route); /* Формирует Extended ID PERIPH с Priority=1 и MsgType=0x4; ноль означает ошибку. */
+PCAN_ABI_API int pcan_gas_slot_encode(const uint16_t *words, unsigned count, uint_least8_t *data); /* Допускает count=0 либо четыре wire-слова с непустой маской в Word0; длина измеряется октетами. */
+PCAN_ABI_API int pcan_gas_slot_decode_words(uint32_t id, unsigned flags, const uint_least8_t *data, unsigned dlc, uint16_t *words); /* Проверяет общий слотный кадр любого узла; возвращает 0/4 либо -1 без изменения выхода при отказе. */
+PCAN_ABI_API int pcan_gas_slot_decode(uint32_t id, unsigned flags, const uint_least8_t *data, unsigned dlc, unsigned *mode, unsigned *local, uint16_t *words); /* Проверяет узел, слот и октеты; возвращает 0/4 либо -1 без изменения выходов. */
+PCAN_ABI_API int pcan_gas_slot_native(unsigned local, unsigned word, unsigned write, uint16_t *address); /* Переводит Word1…Word3 в родной адрес PERIPH; Word0 является маской и не адресует регистр. */
+PCAN_ABI_API int pcan_gas_slot_handle_address(unsigned device_type, unsigned device_id, const pcan_gas_slot_port_t *port, const pcan_gas_slot_frame_t *request, pcan_gas_slot_frame_t *response); /* Обслуживает масочную запись и чтение 0x4 для любого узла; не вызывает Modbus или GAS 0x3. */
+PCAN_ABI_API int pcan_gas_slot_handle(unsigned mode, const pcan_gas_slot_port_t *port, const pcan_gas_slot_frame_t *request, pcan_gas_slot_frame_t *response); /* Возвращает 1 для ответа, 0 после записи, -1 при отказе; не вызывает legacy fallback. */
+#ifdef __cplusplus /* Закрываем C ABI только при включении из C++. */
+} /* Конец объявлений с C-соглашением о связывании. */
+#endif /* Общие защитные символы не зависят от языка порта. */
+#endif /* Завершаем защиту единственного интерфейса слотного профиля GAS. */
diff --git a/c/set-protocol/include/pcan_id.h b/c/set-protocol/include/pcan_id.h
index dd88b9e..b4e9e4d 100644
--- a/c/set-protocol/include/pcan_id.h
+++ b/c/set-protocol/include/pcan_id.h
@@ -46,7 +46,7 @@ typedef enum {
PCAN_MSG_DISCRETE = 0x1U,
PCAN_MSG_ANALOG = 0x2U,
PCAN_MSG_GAS = 0x3U, /**< общее адресное пространство */
- PCAN_MSG_MODBUS_COIL = 0x4U,
+ PCAN_MSG_GAS_SLOT = 0x4U, /**< Слотный GAS с маской слов; код 0x4 освобождён от Modbus Coil. */
PCAN_MSG_MODBUS_DISCR = 0x5U,
PCAN_MSG_MODBUS_HOLDING = 0x6U,
PCAN_MSG_MODBUS_INPUT = 0x7U,
@@ -57,11 +57,11 @@ typedef enum {
/** Разобранный идентификатор. */
typedef struct {
uint16_t msg_body;
- uint8_t msg_type; /**< pcan_msgtype_t */
- uint8_t device_id; /**< 0..15 */
- uint8_t device_type; /**< 0..7 */
- uint8_t route;
- uint8_t priority;
+ uint_least8_t msg_type; /**< pcan_msgtype_t */ /* На C28x хранит октет в доступном 16-битном типе; допустимые значения поля и host ABI остаются прежними. */
+ uint_least8_t device_id; /**< 0..15 */ /* На C28x хранит октет в доступном 16-битном типе; допустимые значения поля и host ABI остаются прежними. */
+ uint_least8_t device_type; /**< 0..7 */ /* На C28x хранит октет в доступном 16-битном типе; допустимые значения поля и host ABI остаются прежними. */
+ uint_least8_t route; /* На C28x хранит октет в доступном 16-битном типе; допустимые значения поля и host ABI остаются прежними. */
+ uint_least8_t priority; /* На C28x хранит октет в доступном 16-битном типе; допустимые значения поля и host ABI остаются прежними. */
} pcan_id_t;
uint32_t pcan_id_pack(const pcan_id_t *id);
@@ -70,25 +70,25 @@ void pcan_id_unpack(uint32_t raw, pcan_id_t *id);
/* --- Раскладки MsgBody ----------------------------------------------------- */
/** BROADCAST: Type[15:4], Body[3:0]. */
-static inline uint16_t pcan_body_broadcast(uint16_t type, uint8_t body)
+static inline uint16_t pcan_body_broadcast(uint16_t type, uint_least8_t body) /* На C28x хранит октет в доступном 16-битном типе; допустимые значения поля и host ABI остаются прежними. */
{
return (uint16_t)(((type & 0x0FFFU) << 4) | (body & 0x0FU));
}
/** DISCRETE / ANALOG: Type[15:12], Body[11:0]. */
-static inline uint16_t pcan_body_typed(uint8_t type, uint16_t body)
+static inline uint16_t pcan_body_typed(uint_least8_t type, uint16_t body) /* На C28x хранит октет в доступном 16-битном типе; допустимые значения поля и host ABI остаются прежними. */
{
return (uint16_t)(((uint16_t)(type & 0x0FU) << 12) | (body & 0x0FFFU));
}
/** MODBUS: StrAdr[15:4], RegCount[3:0]. */
-static inline uint16_t pcan_body_modbus(uint16_t addr, uint8_t count)
+static inline uint16_t pcan_body_modbus(uint16_t addr, uint_least8_t count) /* На C28x хранит октет в доступном 16-битном типе; допустимые значения поля и host ABI остаются прежними. */
{
return (uint16_t)(((addr & 0x0FFFU) << 4) | (count & 0x0FU));
}
/** ERROR: Info[15:8], Code[7:0]. */
-static inline uint16_t pcan_body_error(uint8_t info, uint8_t code)
+static inline uint16_t pcan_body_error(uint_least8_t info, uint_least8_t code) /* На C28x хранит октет в доступном 16-битном типе; допустимые значения поля и host ABI остаются прежними. */
{
return (uint16_t)(((uint16_t)info << 8) | code);
}
diff --git a/c/set-protocol/include/pcan_modbus_server.h b/c/set-protocol/include/pcan_modbus_server.h
index 0552d22..f6874b9 100644
--- a/c/set-protocol/include/pcan_modbus_server.h
+++ b/c/set-protocol/include/pcan_modbus_server.h
@@ -21,7 +21,6 @@ extern "C" {
#endif
typedef enum {
- PCAN_MODBUS_COIL = 0x4U,
PCAN_MODBUS_DISCRETE = 0x5U,
PCAN_MODBUS_HOLDING = 0x6U,
PCAN_MODBUS_INPUT = 0x7U
@@ -54,7 +53,7 @@ bool pcan_modbus_server_init(pcan_modbus_server_t *server,
pcan_modbus_read_fn read,
pcan_modbus_write_fn write, void *user);
-/** Zero DLC reads; data writes COIL/HOLDING. Register words are little-endian. */
+/** DLC=0 читает DISCRETE/HOLDING/INPUT; данные записывают только HOLDING. Тип 0x4 принадлежит GAS SLOT. */
pcan_modbus_result_t pcan_modbus_server_handle(pcan_modbus_server_t *server,
const pcan_frame_t *request,
pcan_frame_t *response);
diff --git a/c/set-protocol/include/protocan_transport.h b/c/set-protocol/include/protocan_transport.h
index 34084ae..c07a0ec 100644
--- a/c/set-protocol/include/protocan_transport.h
+++ b/c/set-protocol/include/protocan_transport.h
@@ -27,6 +27,7 @@
#include "pcan_crc.h"
#include "pcan_frame.h"
#include "pcan_gas.h"
+#include "pcan_gas_slot.h" /* Слотный тип 0x4 имеет маску Word0 и собственный обработчик, регистровый GAS 0x3 сохраняет отдельные адреса слов. */
#include "pcan_id.h"
#include "pcan_link.h"
#include "pcan_modbus_server.h"
diff --git a/c/set-protocol/include/set_gas_watch.h b/c/set-protocol/include/set_gas_watch.h
new file mode 100644
index 0000000..ccef471
--- /dev/null
+++ b/c/set-protocol/include/set_gas_watch.h
@@ -0,0 +1,65 @@
+/* GAS_WATCH2: проверяемый список GAS-слов и независимые от транспорта выборки; формат описан в docs/GAS_WATCH2.md. */
+#ifndef SET_GAS_WATCH_H /* Исключает повторные объявления при подключении ядра несколькими компонентами прошивки. */
+#define SET_GAS_WATCH_H /* Отмечает загрузку публичного контракта расширенной подписки. */
+#include /* Даёт булевы результаты операций без платформенной зависимости. */
+#include "pcan_abi.h" /* Предоставляет фиксированные типы, size_t и экспорт стабильных функций для ctypes/JNI. */
+#include "pcan_gas.h" /* Предоставляет карту 16-битных GAS-регистров и проверяемые операции чтения. */
+#ifdef __cplusplus /* Открывает C ABI для проектов, компилирующих публичный заголовок как C++. */
+extern "C" { /* Запрещает изменение имён экспортируемых функций компилятором C++. */
+#endif /* Завершает условие входа в совместимую с C область объявлений. */
+
+#define SET_GAS_WATCH_VERSION 2U /* Версия payload; отличается от версии внешнего кадрирования GUI. */
+#define SET_GAS_WATCH_MAX 64U /* Максимум 16-битных слов в одном списке; многословный параметр занимает несколько позиций. */
+#define SET_GAS_WATCH_CONFIG_HEADER 12U /* Байты version:u16, period:u16, config_id:u32, count:u16, reserved:u16. */
+#define SET_GAS_WATCH_SAMPLE_HEADER 16U /* Байты version:u16, count:u16, config_id:u32, sequence:u32, timestamp_ms:u32. */
+#define SET_GAS_WATCH_CONFIG_MAX (SET_GAS_WATCH_CONFIG_HEADER + 2U * SET_GAS_WATCH_MAX) /* 140 байт с адресами 64 слов. */
+#define SET_GAS_WATCH_SAMPLE_MAX (SET_GAS_WATCH_SAMPLE_HEADER + 8U + 2U * SET_GAS_WATCH_MAX) /* 152 байта: заголовок, 64 бита достоверности и значения. */
+
+typedef enum { /* Разделяет неверный payload, ошибки карты и конфликт поколений без строк или исключений. */
+ SET_GAS_WATCH_OK = 0, /* Весь список применён либо подтверждён точный повтор действующей конфигурации. */
+ SET_GAS_WATCH_INVALID = 1, /* Нарушены версия, длина, указатели, период, уникальность адресов или структура карты. */
+ SET_GAS_WATCH_NO_REG = 2, /* Хотя бы один адрес отсутствует в корректной карте; действующий список сохранён. */
+ SET_GAS_WATCH_ACCESS = 3, /* Хотя бы один регистр запрещён для чтения либо не имеет источника чтения. */
+ SET_GAS_WATCH_CONFLICT = 4 /* Идентификатор устарел либо повторно использован с другим содержимым. */
+} set_gas_watch_result_t; /* Завершает публичный набор детерминированных результатов настройки. */
+
+typedef struct { /* Состояние одного прибора и одного управляющего клиента; память принадлежит приложению. */
+ uint32_t config_id; /* Ненулевое поколение; новые поколения сравниваются по модульной разности меньше 2^31. */
+ uint32_t sequence; /* Номер следующей выборки; первая имеет номер 0, переполнение происходит по модулю 2^32. */
+ uint32_t next_ms; /* Срок следующего захвата в миллисекундах локального 32-битного счётчика устройства. */
+ uint16_t period_ms; /* Период захвата в миллисекундах; ноль вместе с пустым списком останавливает поток. */
+ uint16_t count; /* Число выбранных 16-битных GAS-слов, от 0 до SET_GAS_WATCH_MAX. */
+ uint16_t address[SET_GAS_WATCH_MAX]; /* Упорядоченные уникальные GAS-индексы, а не указатели на память MCU. */
+ uint8_t scheduled; /* Ноль разрешает первую выборку сразу, единица включает проверку next_ms с переполнением. */
+} set_gas_watch_t; /* Внутренняя структура прошивки не передаётся через wire или языковую границу. */
+
+typedef struct { /* Самодостаточная копия одного захвата, пригодная для графика, архива и повторной передачи. */
+ uint32_t config_id; /* Связывает позиции значений с подтверждённым списком адресов. */
+ uint32_t sequence; /* Номер захвата, позволяющий обнаружить потерю пакета при занятой линии. */
+ uint32_t timestamp_ms; /* Время захвата на устройстве; задержка USB/UART не меняет эту метку. */
+ uint16_t count; /* Число значений и признаков достоверности в используемой части массивов. */
+ uint16_t values[SET_GAS_WATCH_MAX]; /* Сырые слова; при ошибке соответствующего источника сохраняется ноль. */
+ uint8_t valid[SET_GAS_WATCH_MAX]; /* Единица означает успешное чтение слова, ноль отличает ошибку от реального нулевого значения. */
+} set_gas_watch_sample_t; /* Приложение хранит выборку до завершения её использования всеми потребителями. */
+
+/* Все обращения к состоянию и захвату RAM приложение сериализует своим lock/IRQ-портом; ядро не управляет прерываниями. */
+void set_gas_watch_init(set_gas_watch_t *watch); /* Обнуляет состояние до первой команды; NULL допустим как пустая операция. */
+/* Проверяет весь payload и карту без вызовов источников; ошибка оставляет состояние неизменным. Повтор id+содержимое идемпотентен. */
+int set_gas_watch_apply(set_gas_watch_t *watch, const pcan_gas_map_t *map, const uint8_t *payload, size_t size); /* Пустой stop не требует карты. */
+size_t set_gas_watch_encode_ack(const set_gas_watch_t *watch, uint8_t *out, size_t capacity); /* Возвращает полное эхо применённой конфигурации или 0. */
+bool set_gas_watch_due(const set_gas_watch_t *watch, uint32_t now_ms); /* Проверяет срок с переполнением; период проверок должен быть меньше 2^31 мс. */
+/* Захватывает RAM по явному вызову, без проверки due; источники обязаны быть быстрыми и не выполнять I/O или ожидания. */
+bool set_gas_watch_capture(set_gas_watch_t *watch, const pcan_gas_map_t *map, uint32_t now_ms, set_gas_watch_sample_t *sample); /* Отказ источника отражается valid=0. */
+/* Кодирует только готовую выборку, без обращений к карте и без блокировки источников; приложение вызывает после снятия lock. */
+size_t set_gas_watch_encode_sample(const set_gas_watch_sample_t *sample, uint8_t *out, size_t capacity); /* Неверный аргумент не меняет выходной буфер. */
+
+/* Экспортируемый ABI использует скаляры и буферы: ни один клиент не повторяет кодек payload на своём языке. */
+PCAN_ABI_API size_t set_gas_watch_request(uint32_t config_id, uint16_t period_ms, const uint16_t *addresses, size_t count, uint8_t *out, size_t capacity); /* 0 означает отказ без записи. */
+PCAN_ABI_API int set_gas_watch_ack(const uint8_t *payload, size_t size, uint32_t config_id, uint16_t period_ms, const uint16_t *addresses, size_t count); /* 1 подтверждает точное эхо всех адресов. */
+/* Возвращает число слов или -1; до полной проверки payload и ожидаемого поколения не меняет ни один выходной аргумент. */
+PCAN_ABI_API int set_gas_watch_decode(const uint8_t *payload, size_t size, uint32_t expected_config_id, size_t expected_count, uint32_t *sequence, uint32_t *timestamp_ms, uint16_t *values, uint8_t *valid, size_t capacity); /* Метаданные sequence/time могут быть NULL. */
+
+#ifdef __cplusplus /* Закрывает только ту область linkage, которую открывали для C++ потребителя. */
+} /* Завершает объявления с C-именами функций. */
+#endif /* Завершает условное закрытие C++ linkage. */
+#endif /* SET_GAS_WATCH_H: завершает защиту публичного заголовка. */
diff --git a/c/set-protocol/include/set_invertor_profile.h b/c/set-protocol/include/set_invertor_profile.h
new file mode 100644
index 0000000..f95fb31
--- /dev/null
+++ b/c/set-protocol/include/set_invertor_profile.h
@@ -0,0 +1,8 @@
+#ifndef SET_INVERTOR_PROFILE_H /* Общий профиль адресации используется прошивкой и клиентами единственного C-ядра Templates. */
+#define SET_INVERTOR_PROFILE_H /* Предотвращаем повторное определение параметров нового устройства. */
+#define SET_INVERTOR_DEVICE_TYPE 7U /* DeviceType 0x7 выбран пользователем; поле ProtoCAN имеет ширину три бита. */
+#define SET_INVERTOR_DEVICE_ID 14U /* DeviceID 0xE свободен по аудиту 23.10.2026; G474 CANdle использует 0xF, F103 KONOR использует 0xD. */
+#define SET_INVERTOR_GAS_SLOT_COUNT 250U /* Пользователь выделил 250 слотов отдельному инвертору, сохранив существующую адресацию PERIPH. */
+#define SET_INVERTOR_GAS_WORDS_PER_SLOT 3U /* Тип 0x4 выделяет Word0 под маску и передаёт три независимых значения Word1…Word3. */
+#define SET_INVERTOR_GAS_WORD_COUNT (SET_INVERTOR_GAS_SLOT_COUNT * SET_INVERTOR_GAS_WORDS_PER_SLOT) /* Выделенные 250 слотов содержат 750 значений; служебная маска не является регистром. */
+#endif /* Этот заголовок задаёт назначение узла без нового кодека или изменения старых CAN ID. */
diff --git a/c/set-protocol/include/setprotocol.h b/c/set-protocol/include/setprotocol.h
index 6cf501f..18f86e3 100644
--- a/c/set-protocol/include/setprotocol.h
+++ b/c/set-protocol/include/setprotocol.h
@@ -31,5 +31,6 @@
#include "protocan_transport.h"
#include "gui_frame.h"
#include "gui_catalog.h"
+#include "set_gas_watch.h" /* Предоставляет новую подписку и независимый захват GAS-слов через общую точку подключения ядра. */
#endif /* SETPROTOCOL_H */
diff --git a/c/set-protocol/include/setprotocol_abi.h b/c/set-protocol/include/setprotocol_abi.h
index abb2bc1..c0ae0e2 100644
--- a/c/set-protocol/include/setprotocol_abi.h
+++ b/c/set-protocol/include/setprotocol_abi.h
@@ -15,6 +15,7 @@
#define SETPROTOCOL_ABI_H
#include "pcan_abi.h"
+#include "set_gas_watch.h" /* Подключает добавочные стабильные request/ack/decode символы без изменения существующего ABI v1. */
#define SETPROTOCOL_ABI_VERSION PCAN_ABI_VERSION
diff --git a/c/set-protocol/ports/android/Android.mk b/c/set-protocol/ports/android/Android.mk
index de1ba13..9458a79 100644
--- a/c/set-protocol/ports/android/Android.mk
+++ b/c/set-protocol/ports/android/Android.mk
@@ -29,6 +29,8 @@ LOCAL_SRC_FILES := \
../../src/pcan_ring.c \
../../src/pcan_gas.c \
setprotocol_jni.c
+LOCAL_SRC_FILES += ../../src/set_gas_watch.c # Включает общий GAS_WATCH2 в NDK-библиотеку для новых тонких JNI-вызовов.
+LOCAL_SRC_FILES += ../../src/pcan_gas_slot.c gas_slots_jni.c # Android собирает общий слотный кодек и тонкую JNI-границу без второго алгоритма.
LOCAL_CFLAGS := -std=c99 -Wall -Wextra -Wpedantic -fvisibility=hidden
LOCAL_LDLIBS := -llog -lm
include $(BUILD_SHARED_LIBRARY)
diff --git a/c/set-protocol/ports/android/gas_slots_jni.c b/c/set-protocol/ports/android/gas_slots_jni.c
new file mode 100644
index 0000000..fde83cd
--- /dev/null
+++ b/c/set-protocol/ports/android/gas_slots_jni.c
@@ -0,0 +1,42 @@
+#include /* Единственная ответственность JNI — преобразование массивов и скалярных типов JVM. */
+#include "pcan_gas_slot.h" /* Формат ID, слотов и payload реализуется общим C-ядром. */
+JNIEXPORT jlong JNICALL Java_ru_setcorp_setprotocol_GasSlots_nativeRequestId(JNIEnv *env,jobject self,jint mode,jint local,jint route) /* Kotlin и host JVM вызывают одинаковый native-метод. */
+{ /* Начинаем преобразование адресных координат. */
+ (void)env;(void)self;if(mode<0 || local<0 || route<0)return 0; /* Не сужаем отрицательные Java-числа в адреса устройств. */
+ return (jlong)pcan_gas_slot_request_id((unsigned)mode,(unsigned)local,(unsigned)route); /* Все формулы и пределы проверяются C. */
+} /* Конец построения Extended ID. */
+JNIEXPORT jbyteArray JNICALL Java_ru_setcorp_setprotocol_GasSlots_nativeEncode(JNIEnv *env,jobject self,jintArray input) /* Кодируем Word0-маску и три значения без второго endian-алгоритма на JVM. */
+{ /* NULL означает payload запроса чтения, массив — значения слота. */
+ jint source[4];uint16_t words[4];uint_least8_t bytes[8];int i,n; jbyteArray out;(void)self; /* Все буферы имеют фиксированную вместимость одного кадра. */
+ if(input==NULL)return (*env)->NewByteArray(env,0); /* Для запроса чтения payload пуст и не требует фиктивных регистров. */
+ if((*env)->GetArrayLength(env,input)!=4)return NULL; /* Фиксированные позиции маски и трёх значений требуют четырёх wire-слов даже при частичной записи. */
+ (*env)->GetIntArrayRegion(env,input,0,4,source);if((*env)->ExceptionCheck(env))return NULL; /* Не продолжаем JNI-вызовы после ошибки копирования массива. */
+ for(i=0;i<4;i++){if(source[i]<0 || source[i]>65535)return NULL;words[i]=(uint16_t)source[i];} /* Сохраняем границы беззнакового 16-битного регистра. */
+ n=pcan_gas_slot_encode(words,4U,bytes);if(n<0)return NULL; /* Общий кодек проверяет маску Word0 и формирует восемь wire-октетов. */
+ out=(*env)->NewByteArray(env,n);if(out!=NULL)(*env)->SetByteArrayRegion(env,out,0,n,(const jbyte *)bytes);return out; /* Возвращаем готовый payload транспорта JVM/Android. */
+} /* Конец тонкого адаптера записи. */
+JNIEXPORT jintArray JNICALL Java_ru_setcorp_setprotocol_GasSlots_nativeDecode(JNIEnv *env,jobject self,jlong id,jint flags,jbyteArray input) /* Декодирование возвращает mode, локальный слот и четыре слова. */
+{ /* Заголовок и содержимое проверяются до создания модели JVM. */
+ uint_least8_t bytes[8];uint16_t words[4];unsigned mode,slot;int n,i;jsize length;jint values[6];jintArray out;(void)self; /* Ограниченные массивы не требуют heap внутри ядра. */
+ if(id<0 || id>0xFFFFFFFFLL || flags<0 || input==NULL)return NULL; /* Не допускаем сужения 64-битного Long до другого действующего ID. */
+ length=(*env)->GetArrayLength(env,input);if(length>8)return NULL; /* Extended Classic CAN не принимает payload CAN FD. */
+ if(length>0)(*env)->GetByteArrayRegion(env,input,0,length,(jbyte *)bytes);if((*env)->ExceptionCheck(env))return NULL; /* Передаём исходные октеты C без ручной интерпретации. */
+ n=pcan_gas_slot_decode((uint32_t)id,(unsigned)flags,bytes,(unsigned)length,&mode,&slot,words);if(n<0)return NULL; /* Проверяем адрес узла, страницу слота, DLC и направление общей реализацией. */
+ values[0]=(jint)mode;values[1]=(jint)slot;for(i=0;iNewIntArray(env,n+2);if(out!=NULL)(*env)->SetIntArrayRegion(env,out,0,n+2,values);return out; /* JVM получает только проверенные скалярные значения. */
+} /* Конец JNI-декодирования без резервной реализации протокола. */
+JNIEXPORT jlong JNICALL Java_ru_setcorp_setprotocol_GasSlots_nativeAddressId(JNIEnv *env, jobject self, jint device_type, jint device_id, jint address, jint route) /* Создаём слотный ID любого узла, не только PERIPH. */
+{ /* JNI проверяет представимость, а ширину полей и номер типа проверяет C. */
+ (void)env; (void)self; if (device_type < 0 || device_id < 0 || address < 0 || route < 0) return 0; /* Отрицательные Java Int не превращаются в действительные unsigned-адреса. */
+ return (jlong)pcan_gas_slot_id((unsigned)device_type, (unsigned)device_id, (unsigned)address, (unsigned)route); /* Один общий построитель формирует MsgType=0x4 и полный адрес 0…65535. */
+} /* Конец адресного адаптера. */
+JNIEXPORT jintArray JNICALL Java_ru_setcorp_setprotocol_GasSlots_nativeDecodeWords(JNIEnv *env, jobject self, jlong id, jint flags, jbyteArray input) /* Возвращаем проверенные wire-слова любого слотного узла. */
+{ /* В этой границе отсутствуют собственные правила маски или порядка октетов. */
+ uint_least8_t bytes[8]; uint16_t words[4]; jint values[4]; jsize length; jintArray out; int count, i; (void)self; /* Все буферы ограничены одним classic CAN кадром. */
+ if (id < 0 || id > 0xFFFFFFFFLL || flags < 0 || input == NULL) return NULL; /* Проверяем представимость Long и наличие входа до JNI-копирования. */
+ length = (*env)->GetArrayLength(env, input); if (length > 8) return NULL; /* Не усекаем данные CAN FD до classic CAN. */
+ if (length > 0) (*env)->GetByteArrayRegion(env, input, 0, length, (jbyte *)bytes); if ((*env)->ExceptionCheck(env)) return NULL; /* Сохраняем точные wire-октеты и прекращаем работу при исключении JVM. */
+ count = pcan_gas_slot_decode_words((uint32_t)id, (unsigned)flags, bytes, (unsigned)length, words); if (count < 0) return NULL; /* Тип 0x4, маску, DLC и направление проверяет общее C-ядро. */
+ for (i = 0; i < count; ++i) values[i] = (jint)words[i]; /* Расширяем проверенные u16 слова без изменения знака или смысла маски. */
+ out = (*env)->NewIntArray(env, count); if (out != NULL) (*env)->SetIntArrayRegion(env, out, 0, count, values); return out; /* Публикуем готовый результат, пустой только для корректного запроса чтения. */
+} /* Конец общего JNI-декодера. */
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/ProtoCanCatalog.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/ProtoCanCatalog.kt
index 00dc1ab..7e367a8 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/ProtoCanCatalog.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setflash/core/ProtoCanCatalog.kt
@@ -11,7 +11,7 @@ enum class ProtoCanMessageType(val code: Int, val title: String) {
DISCRETE(0x1, "Discrete"),
ANALOG(0x2, "Analog"),
GENERAL_ADDRESS_SPACE(0x3, "General Address Space"),
- MODBUS_COIL(0x4, "Modbus Coil"),
+ GAS_SLOT(0x4, "GAS Slot"), // Код 0x4 выбирает слотный GAS с маской, регистровый GAS сохраняет собственный код 0x3.
MODBUS_DISCRETE(0x5, "Modbus Discrete"),
MODBUS_HOLDING(0x6, "Modbus Holding"),
MODBUS_INPUT(0x7, "Modbus Input"),
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/GasSlots.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/GasSlots.kt
new file mode 100644
index 0000000..c3167b5
--- /dev/null
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/GasSlots.kt
@@ -0,0 +1,10 @@
+package ru.setcorp.setprotocol // Размещаем слотный профиль рядом с существующим native-ядром приложения.
+
+object GasSlots { // Объект не вычисляет адреса и endian самостоятельно, сохраняя единый C-контракт.
+ init { System.loadLibrary("setprotocol") } // Отсутствие нового ядра проявляется явной ошибкой загрузки.
+ external fun nativeRequestId(mode: Int, localSlot: Int, route: Int): Long // Ноль означает неверные координаты; допустимый ID формирует C.
+ external fun nativeEncode(words: IntArray?): ByteArray? // Word0 — непустая маска битов 1…3, Word1…Word3 — значения; null запрашивает чтение.
+ external fun nativeDecode(canId: Long, flags: Int, payload: ByteArray): IntArray? // Результат PERIPH: mode, слот, маска и три значения; null означает отказ.
+ external fun nativeAddressId(deviceType: Int, deviceId: Int, address: Int, route: Int): Long // Общий C-построитель поддерживает любой узел и все адреса слота 0…65535.
+ external fun nativeDecodeWords(canId: Long, flags: Int, payload: ByteArray): IntArray? // Общий C-декодер возвращает маску и три значения любого узла либо пустой массив запроса.
+} // Завершаем тонкую привязку без Kotlin-копии wire-протокола.
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
index cab62ec..9e050d9 100644
--- a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/NativeSetProtocol.kt
@@ -33,6 +33,9 @@ object NativeSetProtocol {
external fun nativeTrendWatchValues(input: ByteArray): IntArray?
/** {unsigned timestampMs, word0, ...}. */
external fun nativeTrendWatchDecode(input: ByteArray): LongArray?
+ external fun nativeGasWatchRequest(configId: Long, period: Int, addresses: IntArray): ByteArray? // Передаёт идентификатор конфигурации u32, период в миллисекундах и адреса u16 общему C-кодеку GAS_WATCH2.
+ external fun nativeGasWatchAck(input: ByteArray, configId: Long, period: Int, addresses: IntArray): Boolean // Проверяет C-кодеком полное эхо конфигурации, включая порядок всех адресов, до переключения активной подписки.
+ external fun nativeGasWatchDecode(input: ByteArray, configId: Long, count: Int): LongArray? // Возвращает проверенные C-кодеком sequence и время как положительные u32 в Long, затем слова либо −1 для невалидных каналов.
external fun nativePackId(
priority: Int,
route: Int,
diff --git a/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch2.kt b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch2.kt
new file mode 100644
index 0000000..c086fa4
--- /dev/null
+++ b/c/set-protocol/ports/android/kotlin/ru/setcorp/setprotocol/trends/GuiGasWatch2.kt
@@ -0,0 +1,73 @@
+package ru.setcorp.setprotocol.trends // Размещает независимый от Android UI фасад подписок рядом с существующей поддержкой трендов.
+
+import java.util.Collections // Предоставляет неизменяемую оболочку коллекций, которую нельзя обойти приведением List к MutableList.
+import ru.setcorp.setprotocol.NativeSetProtocol // Подключает единственный C-кодек через тонкие JNI-преобразования без повторного разбора wire-формата в Kotlin.
+
+object GuiGasWatch2 { // Объединяет проверяемые операции GAS_WATCH2 и модель состояния отдельного подключения.
+ const val SET = 0x14 // Идентификатор сообщения установки GAS_WATCH2 по согласованному GUI-контракту; содержимое payload формирует только C.
+ const val DATA = 0x15 // Идентификатор сообщения снимка GAS_WATCH2 по согласованному GUI-контракту; содержимое payload проверяет только C.
+
+ class Config(val configId: Long, val periodMs: Int, addresses: List) { // Хранит неизменяемую конфигурацию: ненулевой u32 идентификатор, период в миллисекундах и упорядоченные адреса слов.
+ val addresses: List = Collections.unmodifiableList(ArrayList(addresses)) // Копирует внешний список и запрещает последующее изменение уже ожидаемой или активной конфигурации.
+ init { // Проверяет только границы преобразования JVM-чисел в C-типы; правила количества, дублей и остановки остаются в C-кодеке.
+ require(configId in 1L..0xFFFFFFFFL) { "Идентификатор конфигурации должен быть ненулевым u32" } // Не допускает потери старших битов или отрицательного Long при передаче идентификатора в uint32_t.
+ require(periodMs in 0..0xFFFF) { "Период должен помещаться в u16 миллисекунд" } // Сохраняет значение периода без усечения при преобразовании Int в uint16_t.
+ require(this.addresses.all { it in 0..0xFFFF }) { "Адрес GAS должен помещаться в u16" } // Проверяет каждый адрес до преобразования массива Int в массив 16-битных слов на JNI-границе.
+ } // Завершает проверки числового представления без добавления собственного кодека.
+ override fun equals(other: Any?): Boolean = other is Config && configId == other.configId && periodMs == other.periodMs && addresses == other.addresses // Сравнивает полную конфигурацию, чтобы запоздалое подтверждение не подошло другому порядку адресов.
+ override fun hashCode(): Int = 31 * (31 * configId.hashCode() + periodMs) + addresses.hashCode() // Согласует хеш с теми же тремя полями; множитель 31 служит обычным смешиванием хеша и не относится к протоколу.
+ } // Завершает модель конфигурации, общую для кодека и состояния подключения.
+
+ class Sample(val configId: Long, val sequence: Long, val timestampMs: Long, values: List) { // Представляет один проверенный снимок с приборным временем в миллисекундах и положительным u32 номером последовательности.
+ val values: List = Collections.unmodifiableList(ArrayList(values)) // Фиксирует порядок слов активной конфигурации; null явно сохраняет признак невалидного канала вместо подстановки нуля.
+ } // Завершает неизменяемый снимок, пригодный для графика и записи без дополнительного разбора payload.
+
+ fun request(config: Config): ByteArray { // Формирует payload установки или остановки подписки исключительно общим C-кодеком.
+ check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" } // Не допускает скрытого перехода на другой кодек при отсутствии JNI-библиотеки.
+ return requireNotNull(NativeSetProtocol.nativeGasWatchRequest(config.configId, config.periodMs, config.addresses.toIntArray())) { "Некорректная конфигурация GAS_WATCH2" } // Делегирует C проверку лимита, дублей и согласованности нулевого периода с пустым списком.
+ } // Возвращает готовые байты без изменения состояния какого-либо подключения.
+
+ fun validateAck(payload: ByteArray, config: Config) { // Проверяет полное эхо ожидаемой конфигурации, не делая выводов по одному количеству каналов.
+ check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" } // Требует тот же C-кодек, который сформировал запрос.
+ require(NativeSetProtocol.nativeGasWatchAck(payload, config.configId, config.periodMs, config.addresses.toIntArray())) { "Подтверждение GAS_WATCH2 не соответствует конфигурации" } // C сравнивает идентификатор, период, число и точную последовательность адресов с ожидаемыми.
+ } // Завершает проверку ACK без самовольного переключения конфигурации.
+
+ fun decode(payload: ByteArray, config: Config): Sample { // Преобразует только результат C-декодера; схема бинарного пакета в Kotlin отсутствует.
+ check(NativeSetProtocol.available) { "Общая библиотека SETProtocol недоступна" } // Сохраняет единый источник правил валидации снимков.
+ val decoded = requireNotNull(NativeSetProtocol.nativeGasWatchDecode(payload, config.configId, config.addresses.size)) { "Снимок GAS_WATCH2 не соответствует активной конфигурации" } // C отклоняет чужой идентификатор, неверное количество, длину, маску валидности и повреждённые поля.
+ return Sample(config.configId, decoded[0], decoded[1], decoded.drop(2).map { if (it < 0L) null else it.toInt() }) // Два первых элемента JNI-результата содержат sequence и время; −1 остальных элементов переводится в null без повторного wire-декодирования.
+ } // Возвращает снимок только после успешной проверки общей библиотекой.
+
+ class Session { // Изолирует подтверждённую и ожидающую подписки одного подключения, чтобы разные приборы не делили состояние.
+ @Volatile var active: Config? = null // Публикует только подтверждённую конфигурацию; volatile обеспечивает видимость чтения с другого потока JVM.
+ private set // Запрещает внешнему коду обходить проверку полного ACK при изменении активной конфигурации.
+ @Volatile var pending: Config? = null // Хранит последний успешно построенный запрос отдельно от действующей подписки.
+ private set // Не позволяет потребителю подменить ожидаемую конфигурацию без вызова request.
+
+ @Synchronized fun request(config: Config): ByteArray { // Сериализует формирование запросов и подтверждения, сохраняя атомарность переходов состояния.
+ require(active?.let { it.configId != config.configId || it == config } != false) { "Активный идентификатор GAS_WATCH2 уже связан с другой конфигурацией" } // Запрещает присвоить действующему идентификатору иной период или список, сохраняя допустимость идентичного повтора.
+ require(pending?.let { it.configId != config.configId || it == config } != false) { "Ожидающий идентификатор GAS_WATCH2 уже связан с другой конфигурацией" } // Не допускает неоднозначности одинаковых идентификаторов у двух различающихся ожидающих запросов.
+ val payload = GuiGasWatch2.request(config) // Сначала получает успешно проверенный C-кодеком payload; ошибка оставляет обе конфигурации неизменными.
+ pending = config // Запоминает именно последний запрос и оставляет active действующей до получения полного ACK.
+ return payload // Передаёт вызывающему коду готовый payload для отправки выбранным транспортом.
+ } // Завершает атомарную постановку ожидающей конфигурации.
+
+ @Synchronized fun validateAck(payload: ByteArray, config: Config) { // Применяет подтверждение только к конфигурации последнего запроса этого подключения.
+ require(pending == config) { "Получено запоздалое или незапрошенное подтверждение GAS_WATCH2" } // Даже корректный старый ACK не может вернуть уже заменённый ожидающий список.
+ GuiGasWatch2.validateAck(payload, config) // Проверяет в C полное эхо до каких-либо изменений active или pending.
+ active = config // Атомарно переключает декодирование входящих снимков на подтверждённый список адресов.
+ pending = null // Убирает завершённый запрос, чтобы повторный ACK не считался новым подтверждением.
+ } // Завершает переход к новой активной подписке только после двух проверок ACK.
+
+ @Synchronized fun sample(payload: ByteArray): Sample { // Декодирует снимок по стабильному активному списку даже во время ожидания нового ACK.
+ val config = checkNotNull(active) { "Нет подтверждённой конфигурации GAS_WATCH2" } // Отклоняет данные до первого подтверждения вместо угадывания адресов по ожидающему запросу.
+ check(config.periodMs != 0) { "Подписка GAS_WATCH2 остановлена" } // Не принимает запоздалые данные после подтверждённой остановки потока.
+ return GuiGasWatch2.decode(payload, config) // Делегирует C проверку активного идентификатора и количества каналов вместе с форматом снимка.
+ } // Возвращает только снимки подтверждённой работающей подписки.
+
+ @Synchronized fun reset() { // Сбрасывает состояние при разрыве связи до повторной настройки нового подключения.
+ active = null // Не позволяет принять пакет предыдущего соединения до нового подтверждения конфигурации.
+ pending = null // Не позволяет запоздалому ACK прежнего соединения активировать старый список.
+ } // Завершает согласованный сброс обоих состояний подключения.
+ } // Завершает диспетчер одного подключения, не привязанный к UI или конкретному транспорту.
+} // Завершает Android-фасад GAS_WATCH2, использующий единственный C-кодек.
diff --git a/c/set-protocol/ports/android/setprotocol_jni.c b/c/set-protocol/ports/android/setprotocol_jni.c
index 1d9c264..1cd7f44 100644
--- a/c/set-protocol/ports/android/setprotocol_jni.c
+++ b/c/set-protocol/ports/android/setprotocol_jni.c
@@ -11,6 +11,7 @@
#include "setprotocol_abi.h"
#include "set_trends.h"
+#include "set_gas_watch.h" /* Подключает единственный кодек GAS_WATCH2 и общие лимиты буферов, чтобы JNI не воспроизводил wire-формат. */
#include "set_spectrum.h"
#include "balsam_can.h"
#include "periph28335.h"
@@ -547,6 +548,77 @@ Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeTrendWatchDecode(
return result;
}
+static int gas_watch_copy_addresses(JNIEnv *env, jintArray input, uint16_t *addresses, jsize *count) /* Преобразует JVM IntArray в u16-массив без усечения; протокольные правила проверяет только C-кодек. */
+{ /* Начинает общий для запроса и ACK ограниченный переход между представлениями JNI и C. */
+ if (input == NULL) return 0; /* Отклоняет отсутствующий массив до обращения к GetArrayLength, исключая исключение JNI из-за null. */
+ *count = (*env)->GetArrayLength(env, input); /* Получает фактическое число адресов из JVM-массива, не доверяя внешнему счётчику. */
+ if (*count > (jsize)SET_GAS_WATCH_MAX) return 0; /* Ограничивает копирование общим лимитом 64 слов до заполнения стековых массивов. */
+ jint source[SET_GAS_WATCH_MAX]; /* Резервирует исходные знаковые JVM-числа по единственному лимиту из C-заголовка. */
+ (*env)->GetIntArrayRegion(env, input, 0, *count, source); /* Копирует JVM-адреса без удержания указателей на управляемую память. */
+ if ((*env)->ExceptionCheck(env)) return 0; /* Не использует частично скопированный массив, если JVM сообщила исключение доступа к массиву. */
+ for (jsize index = 0; index < *count; ++index) { /* Проверяет каждое число до приведения к беззнаковому 16-битному адресу. */
+ if (source[index] < 0 || source[index] > UINT16_MAX) return 0; /* Не позволяет отрицательному или слишком большому Int превратиться в другой действительный GAS-адрес. */
+ addresses[index] = (uint16_t)source[index]; /* Сохраняет проверенный адрес без потери битов; порядок остаётся равным порядку конфигурации. */
+ } /* Завершает безопасное преобразование адресов без сортировки, удаления дублей или иной протокольной логики. */
+ return 1; /* Сообщает вызывающей JNI-функции, что массив готов для проверки общим C-кодеком. */
+} /* Завершает вспомогательное преобразование числовых типов JNI. */
+
+JNIEXPORT jbyteArray JNICALL /* Экспортирует JNI-метод, возвращающий готовые байты конфигурации GAS_WATCH2 либо null при отказе. */
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeGasWatchRequest( /* Связывает метод с объявлением NativeSetProtocol.nativeGasWatchRequest без регистрации второго кодека. */
+ JNIEnv *env, jobject self, jlong config_id, jint period, jintArray input) /* Принимает u32 id в положительном Long, период в миллисекундах и JVM-массив адресов u16. */
+{ /* Начинает тонкий адаптер формирования запроса через общее C-ядро. */
+ (void)self; /* Объект Kotlin не содержит данных, необходимых переносимому C-кодеку. */
+ if (config_id <= 0 || (uint64_t)config_id > UINT32_MAX || period < 0 || period > UINT16_MAX) return NULL; /* Проверяет границы до приведения Long и Int, исключая потерю битов идентификатора или периода. */
+ jsize count = 0; /* Получит число адресов только после проверки фактического JVM-массива. */
+ uint16_t addresses[SET_GAS_WATCH_MAX]; /* Выделяет адреса на стеке по общему C-лимиту подписки без динамических выделений. */
+ uint8_t output[SET_GAS_WATCH_CONFIG_MAX]; /* Выделяет максимально допустимый payload по C-константе вместо повторного расчёта wire-размеров в адаптере. */
+ if (!gas_watch_copy_addresses(env, input, addresses, &count)) return NULL; /* Не передаёт C-кодеку адреса, которые нельзя точно представить как u16. */
+ size_t size = set_gas_watch_request((uint32_t)config_id, (uint16_t)period, addresses, (size_t)count, output, sizeof output); /* Единственный C builder проверяет геометрию и создаёт канонический payload конфигурации. */
+ return tms_result(env, output, size); /* Копирует результат в ByteArray существующим общим JNI-помощником; нулевая длина C-отказа превращается в null. */
+} /* Завершает запрос без хранения или изменения состояния подписки на JNI-стороне. */
+
+JNIEXPORT jboolean JNICALL /* Экспортирует JNI-проверку полного подтверждения конфигурации GAS_WATCH2. */
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeGasWatchAck( /* Связывает метод с NativeSetProtocol.nativeGasWatchAck для проверки точного эхо-ответа. */
+ JNIEnv *env, jobject self, jbyteArray input, jlong config_id, jint period, jintArray expected) /* Принимает payload ACK и исходные ожидаемые параметры без извлечения полей в JNI. */
+{ /* Начинает адаптер проверки ACK, делегирующий правила общему C-кодеку. */
+ (void)self; /* Kotlin-объект служит только точкой вызова и не участвует в сравнении wire-полей. */
+ if (input == NULL || config_id <= 0 || (uint64_t)config_id > UINT32_MAX || period < 0 || period > UINT16_MAX) return JNI_FALSE; /* Отбрасывает null и числа, которые были бы усечены при переходе к C-типам. */
+ jsize count = 0; /* Получит проверенное количество исходных адресов для полного сравнения ACK в C. */
+ uint16_t addresses[SET_GAS_WATCH_MAX]; /* Хранит ожидаемые адреса в том же исходном порядке, которым определяются позиции значений снимка. */
+ if (!gas_watch_copy_addresses(env, expected, addresses, &count)) return JNI_FALSE; /* Не сравнивает ACK с недопустимым JVM-массивом или усечёнными адресами. */
+ jsize size = (*env)->GetArrayLength(env, input); /* Получает длину ACK для безопасного копирования ограниченного буфера. */
+ if (size > (jsize)SET_GAS_WATCH_CONFIG_MAX) return JNI_FALSE; /* Отбрасывает превышение общего максимума до копирования на стек. */
+ jbyte payload[SET_GAS_WATCH_CONFIG_MAX]; /* Резервирует payload по общей C-константе; проверка точной длины остаётся в set_gas_watch_ack. */
+ (*env)->GetByteArrayRegion(env, input, 0, size, payload); /* Копирует байты ACK без ручного разбора их полей или порядка байтов. */
+ if ((*env)->ExceptionCheck(env)) return JNI_FALSE; /* Не вызывает C-кодек, если JVM не смогла полностью скопировать входной массив. */
+ return set_gas_watch_ack((const uint8_t *)payload, (size_t)size, (uint32_t)config_id, (uint16_t)period, addresses, (size_t)count) ? JNI_TRUE : JNI_FALSE; /* Проверяет C-кодеком весь echo, включая версию, id, период, количество, reserved и порядок адресов. */
+} /* Завершает проверку ACK без изменения активной или ожидающей конфигурации. */
+
+JNIEXPORT jlongArray JNICALL /* Экспортирует JNI-декодер снимка с положительными u32 счётчиками и отдельным маркером невалидного слова. */
+Java_ru_setcorp_setprotocol_NativeSetProtocol_nativeGasWatchDecode( /* Связывает метод с NativeSetProtocol.nativeGasWatchDecode и единственным C-декодером GAS_WATCH2. */
+ JNIEnv *env, jobject self, jbyteArray input, jlong config_id, jint expected_count) /* Принимает payload и параметры подтверждённой конфигурации, а не предполагаемые параметры из самого пакета. */
+{ /* Начинает ограниченное преобразование готового C-результата в JVM-массив. */
+ (void)self; /* Все параметры ожидаемой подписки переданы явно; объект Kotlin не участвует в C-декодировании. */
+ if (input == NULL || config_id <= 0 || (uint64_t)config_id > UINT32_MAX || expected_count < 0 || expected_count > (jint)SET_GAS_WATCH_MAX) return NULL; /* Проверяет null, u32 id и ограниченный count до приведения к C-типам и обращения к стековым буферам. */
+ jsize size = (*env)->GetArrayLength(env, input); /* Получает длину входного JVM-массива без чтения каких-либо wire-полей. */
+ if (size > (jsize)SET_GAS_WATCH_SAMPLE_MAX) return NULL; /* Гарантирует, что вход помещается в стековый буфер общего максимального размера снимка. */
+ jbyte payload[SET_GAS_WATCH_SAMPLE_MAX]; /* Хранит исходный payload до завершения C-проверки версии, длины, id, count и bitmap. */
+ uint16_t words[SET_GAS_WATCH_MAX]; /* Принимает от C сырые слова GAS без изменения знакового представления пользовательского параметра. */
+ uint8_t valid[SET_GAS_WATCH_MAX]; /* Принимает от C уже проверенные признаки валидности по одному элементу на канал вместо повторного разбора bitmap. */
+ uint32_t sequence = 0U, timestamp_ms = 0U; /* Принимает номер снимка и приборное время в миллисекундах как u32 без промежуточного знакового Int. */
+ (*env)->GetByteArrayRegion(env, input, 0, size, payload); /* Копирует вход без удержания JVM-памяти и без воспроизведения протокольных смещений в адаптере. */
+ if ((*env)->ExceptionCheck(env)) return NULL; /* Не использует частичный вход при исключении копирования JVM-массива. */
+ int count = set_gas_watch_decode((const uint8_t *)payload, (size_t)size, (uint32_t)config_id, (size_t)expected_count, &sequence, ×tamp_ms, words, valid, SET_GAS_WATCH_MAX); /* C проверяет принадлежность снимка активной конфигурации и каноничность всего payload, возвращая число слов или −1. */
+ if (count < 0) return NULL; /* Не публикует частично заполненные выходные данные после любой ошибки C-декодера. */
+ jlong values[2U + SET_GAS_WATCH_MAX]; /* Два первых JVM-элемента принадлежат sequence и времени; остальные ограничены общим максимумом адресов. */
+ values[0] = (jlong)sequence; /* Расширяет u32 номер снимка напрямую в положительный Long, включая значения с установленным bit31. */
+ values[1] = (jlong)timestamp_ms; /* Расширяет u32 приборное время напрямую в положительный Long без потери старшего бита. */
+ for (int index = 0; index < count; ++index) values[index + 2] = valid[index] ? (jlong)words[index] : -1; /* Передаёт валидное u16 слово как 0..65535, а невалидный канал как −1, которое Kotlin преобразует в null. */
+ jlongArray result = (*env)->NewLongArray(env, count + 2); /* Создаёт ровно два элемента метаданных и по одному элементу на каждый проверенный канал снимка. */
+ if (result != NULL) (*env)->SetLongArrayRegion(env, result, 0, count + 2, values); /* Копирует завершённый результат в управляемую память только после успешной C-проверки. */
+ return result; /* Возвращает null при отказе JVM выделить массив и корректный LongArray во всех успешных случаях. */
+} /* Завершает JNI-декодер без второго протокольного алгоритма или собственной таблицы адресов. */
+
typedef struct {
uint8_t *storage;
size_t storage_size;
diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/GasSlotsTest.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/GasSlotsTest.kt
new file mode 100644
index 0000000..94e2563
--- /dev/null
+++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/GasSlotsTest.kt
@@ -0,0 +1,34 @@
+package ru.setcorp.setprotocol // Проверяем тонкую Kotlin/JNI-границу общего C-кодека.
+import org.junit.Assert.* // Сопоставляем точные CAN ID, wire-октеты и результаты отказа.
+import org.junit.Test // Включаем проверки в существующий набор Android host tests.
+
+class GasSlotsTest { // Номер типа и маска проверяются настоящей shared-библиотекой.
+ @Test fun identifiersAndMask() { // Проверяем два адресных класса PERIPH и порядок Word0-маски.
+ val modes = intArrayOf(5, 6, 7, 8, 16, 31) // Номера совпадают с общими нормативными примерами.
+ val ids = longArrayOf(0x11540500L, 0x11640600L, 0x11740700L, 0x11840800L, 0x12041000L, 0x12F41F41L) // Тип 0x4 заменяет старый Coil без изменения адресов узлов.
+ val words = intArrayOf(14, 0x1234, 0xABCD, 0xFFFF) // Первое слово является маской, следующие три — независимыми значениями.
+ val payload = GasSlots.nativeEncode(words)!! // Только C-кодек формирует октеты и проверяет разрешённые биты маски.
+ assertArrayEquals(byteArrayOf(14, 0, 0x34, 0x12, 0xCD.toByte(), 0xAB.toByte(), 0xFF.toByte(), 0xFF.toByte()), payload) // Проверяем little-endian Word0 и всех трёх значений.
+ modes.forEachIndexed { index, mode -> // Дополнительно проверяем переход DeviceType после шестнадцати mode.
+ val slot = if (index == 5) 65 else 0 // Последний полный слот имеет индекс 65, неполный — 66.
+ assertEquals(ids[index], GasSlots.nativeRequestId(mode, slot, 0)) // Формула и номер типа находятся только в C.
+ val reply = GasSlots.nativeRequestId(mode, slot, 1) // Направление ответа задаётся явно.
+ assertArrayEquals(intArrayOf(mode, slot, 14, 0x1234, 0xABCD, 0xFFFF), GasSlots.nativeDecode(reply, 1, payload)) // Результат содержит маску отдельно от трёх значений.
+ } // Завершаем проверку независимых идентификаторов.
+ assertEquals(0x17E4FFFFL, GasSlots.nativeAddressId(7, 14, 65535, 0)) // Общий слотный API поддерживает независимый узел и максимальный адрес.
+ assertArrayEquals(words, GasSlots.nativeDecodeWords(0x1FE4FFFFL, 1, payload)) // Общий декодер не ограничен страницами PERIPH.
+ } // Конец проверки готовых кадров.
+ @Test fun invalidFrames() { // Ошибочный слот или маска не должны становиться другой командой.
+ assertEquals(0L, GasSlots.nativeRequestId(5, 67, 0)) // Значения за последним слотом 66 относятся к резерву.
+ assertEquals(0L, GasSlots.nativeAddressId(7, 14, 65536, 0)) // Полный адрес проверяется до сужения.
+ assertNull(GasSlots.nativeEncode(intArrayOf(1, 2, 3))) // Неполный payload не дополняется нулями.
+ assertNull(GasSlots.nativeEncode(intArrayOf(0, 1, 2, 3))) // Пустая маска запрещена.
+ assertNull(GasSlots.nativeEncode(intArrayOf(1, 1, 2, 3))) // Бит служебного Word0 не является битом значения.
+ assertNull(GasSlots.nativeEncode(intArrayOf(14, 1, 2, 65536))) // Число за пределами u16 не усекается.
+ val payload = GasSlots.nativeEncode(intArrayOf(14, 1, 2, 3))!! // Корректный кадр позволяет отдельно проверить адрес и флаги.
+ assertNull(GasSlots.nativeDecode(0x19540600L, 1, payload)) // Прибор 5 не обслуживает страницу mode 6.
+ assertNull(GasSlots.nativeDecode(0x19540500L, 3, payload)) // RTR не является ответом с данными.
+ assertNull(GasSlots.nativeDecode(0x19540542L, 1, payload)) // Последний слот не содержит Word3.
+ assertNull(GasSlots.nativeDecodeWords(0x19530500L, 1, payload)) // Тип 0x3 не принимается за слот при совпадении payload.
+ } // Конец отрицательных и граничных случаев.
+} // Конец регрессионной проверки JNI.
diff --git a/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/GuiGasWatch2Test.kt b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/GuiGasWatch2Test.kt
new file mode 100644
index 0000000..630bc14
--- /dev/null
+++ b/c/set-protocol/ports/android/tests/ru/setcorp/setprotocol/trends/GuiGasWatch2Test.kt
@@ -0,0 +1,122 @@
+package ru.setcorp.setprotocol.trends // Размещает проверки Android-фасада рядом с тестами существующих трендов и GAS_WATCH.
+
+import org.junit.Assert.* // Подключает стандартные утверждения JUnit для проверки состояния, значений и эталонных байтов.
+import org.junit.Test // Помечает независимые сценарии, запускаемые на JVM с настоящей JNI-библиотекой.
+import ru.setcorp.setprotocol.NativeSetProtocol // Позволяет отдельно проверить защиту числовых преобразований непосредственно на JNI-границе.
+
+class GuiGasWatch2Test { // Проверяет общий C-кодек через Kotlin/JNI и переходы ожидающей и активной подписок без второго кодека.
+ private val config = GuiGasWatch2.Config(0x12345678L, 100, listOf(0, 1, 0xFF01)) // Повторяет конфигурацию общего fixture gas-watch-v2.json: три GAS-слова и период 100 миллисекунд.
+ private val request = hex("0200640078563412030000000000010001ff") // Содержит эталонный payload конфигурации из общего C/Python/Kotlin fixture, включая полный список адресов.
+ private val payload = hex("0200030078563412fffffffff0ffffff037b00feff0000") // Содержит эталонный снимок: sequence u32 maximum, время 0xFFFFFFF0 мс, два валидных слова и один невалидный канал.
+
+ private fun hex(text: String): ByteArray = text.chunked(2).map { it.toInt(16).toByte() }.toByteArray() // Переводит текст эталонных байтов в массив без знания полей или порядка байтов самого протокола.
+ private fun fails(action: () -> Unit) = assertTrue(runCatching(action).isFailure) // Проверяет отказ операции независимо от текста локализованной ошибки и не подменяет JNI-результат.
+
+ @Test fun requestAndAckMatchSharedFixture() { // Фиксирует одинаковый wire-контракт Android-порта и общего C fixture.
+ assertArrayEquals(request, GuiGasWatch2.request(config)) // Сравнивает C-сформированный запрос побайтно с независимым эталоном.
+ GuiGasWatch2.validateAck(request, config) // Принимает точное полное эхо конфигурации в качестве ACK.
+ assertEquals(0x14, GuiGasWatch2.SET) // Фиксирует новый идентификатор установки, не затрагивая legacy GAS_WATCH_SET 0x12.
+ assertEquals(0x15, GuiGasWatch2.DATA) // Фиксирует новый идентификатор данных, не затрагивая legacy GAS_WATCH_DATA 0x13.
+ } // Завершает проверку эталонного запроса и допустимого подтверждения.
+
+ @Test fun copiedAddressesCannotChangeOutstandingConfiguration() { // Проверяет, что изменение исходного UI-списка не меняет смысл уже сформированного запроса.
+ val source = mutableListOf(0, 1, 0xFF01) // Создаёт изменяемый список как типичный результат выбора строк таблицы.
+ val copied = GuiGasWatch2.Config(config.configId, config.periodMs, source) // Сохраняет конфигурацию с защитным копированием списка адресов.
+ source[0] = 25 // Моделирует последующее редактирование UI после постановки запроса.
+ assertEquals(config, copied) // Подтверждает неизменность полной конфигурации несмотря на изменение внешнего списка.
+ assertArrayEquals(request, GuiGasWatch2.request(copied)) // Подтверждает, что C по-прежнему получает исходные адреса fixture.
+ fails { (copied.addresses as MutableList)[0] = 25 } // Проверяет защиту от обхода read-only List приведением типа во время исполнения JVM.
+ } // Завершает проверку неизменяемости конфигурации, используемой для сопоставления ACK и данных.
+
+ @Test fun requestRejectsInvalidGeometryWithoutTruncation() { // Проверяет диапазоны JNI и протокольные ограничения через единый C builder.
+ fails { GuiGasWatch2.Config(0L, 100, listOf(1)) } // Нулевой идентификатор запрещён, чтобы не смешивать неинициализированное состояние с конфигурацией.
+ fails { GuiGasWatch2.Config(0x100000000L, 100, listOf(1)) } // Значение за пределами u32 не должно усекаться до другого идентификатора.
+ fails { GuiGasWatch2.Config(1L, -1, listOf(1)) } // Отрицательный период не должен превращаться в большой unsigned u16.
+ fails { GuiGasWatch2.Config(1L, 100, listOf(0x10000)) } // Адрес за пределами u16 не должен превращаться в адрес ноль.
+ fails { GuiGasWatch2.request(GuiGasWatch2.Config(1L, 100, listOf(1, 1))) } // Проверяет отказ C от дублирующихся адресов, даже если список помещается в лимит.
+ fails { GuiGasWatch2.request(GuiGasWatch2.Config(1L, 100, (0..64).toList())) } // Проверяет отказ C от 65 слов при максимуме 64 слова на подписку.
+ fails { GuiGasWatch2.request(GuiGasWatch2.Config(1L, 0, listOf(1))) } // Остановка с непустым списком должна отклоняться вместо неоднозначного поведения прибора.
+ fails { GuiGasWatch2.request(GuiGasWatch2.Config(1L, 100, emptyList())) } // Пустая подписка с ненулевым периодом также нарушает общий контракт.
+ val maximum = GuiGasWatch2.Config(0xFFFFFFFFL, 0xFFFF, (0..63).toList()) // Покрывает допустимые максимальные u32 идентификатор, u16 период и 64 адреса без переполнения JNI.
+ GuiGasWatch2.validateAck(GuiGasWatch2.request(maximum), maximum) // Проверяет, что предельная допустимая конфигурация проходит C builder и exact ACK.
+ assertNull(NativeSetProtocol.nativeGasWatchRequest(-1L, 100, intArrayOf(1))) // Проверяет защиту JNI от отрицательного Long даже при обходе Kotlin Config.
+ assertNull(NativeSetProtocol.nativeGasWatchRequest(0x100000000L, 100, intArrayOf(1))) // Проверяет защиту JNI от усечения старших битов идентификатора.
+ assertNull(NativeSetProtocol.nativeGasWatchRequest(1L, 0x10000, intArrayOf(1))) // Проверяет защиту JNI от усечения периода при прямом вызове native-метода.
+ assertNull(NativeSetProtocol.nativeGasWatchRequest(1L, 100, intArrayOf(-1))) // Проверяет защиту JNI от преобразования отрицательного адреса в 0xFFFF.
+ } // Завершает проверки границ и протокольной геометрии без воспроизведения C-алгоритма.
+
+ @Test fun ackRequiresIdentifierPeriodAndOrderedAddresses() { // Проверяет защиту от ACK чужой подписки с тем же количеством слов.
+ fails { GuiGasWatch2.validateAck(request, GuiGasWatch2.Config(0x12345679L, 100, config.addresses)) } // Другой идентификатор конфигурации не должен подтверждать ожидаемый запрос.
+ fails { GuiGasWatch2.validateAck(request, GuiGasWatch2.Config(config.configId, 101, config.addresses)) } // Совпадающие адреса не компенсируют расхождение периода в миллисекундах.
+ fails { GuiGasWatch2.validateAck(request, GuiGasWatch2.Config(config.configId, 100, listOf(1, 0, 0xFF01))) } // Перестановка адресов при том же количестве должна отклоняться, иначе значения будут подписаны чужими именами.
+ fails { GuiGasWatch2.validateAck(request.copyOf(request.size - 1), config) } // Усечённое эхо не должно применяться частично.
+ fails { GuiGasWatch2.validateAck(request + byteArrayOf(0), config) } // Лишний байт должен отклоняться как нарушение точной длины ACK.
+ } // Завершает проверку полного, а не сокращённого подтверждения.
+
+ @Test fun decodePreservesUnsignedCountersAndInvalidValues() { // Проверяет преобразование C-результата в удобную для графика неизменяемую модель Kotlin.
+ val sample = GuiGasWatch2.decode(payload, config) // Декодирует реальный fixture через JNI и общее C-ядро.
+ assertEquals(config.configId, sample.configId) // Сохраняет идентификатор, по которому C проверил принадлежность снимка активной конфигурации.
+ assertEquals(0xFFFFFFFFL, sample.sequence) // Значение u32 maximum должно остаться положительным Long, а не −1 после знакового Int.
+ assertEquals(0xFFFFFFF0L, sample.timestampMs) // Приборное время с установленным старшим битом также должно остаться положительным.
+ assertEquals(listOf(123, 65534, null), sample.values) // Сырые u16 слова сохраняются без потери знака, а невалидный третий канал явно представлен null.
+ fails { (sample.values as MutableList)[0] = 5 } // Не позволяет потребителю переписать уже переданный другим обработчикам снимок.
+ } // Завершает проверку JNI-представления счётчиков, слов и валидности.
+
+ @Test fun decodeRejectsStaleAndMalformedPackets() { // Проверяет, что JNI действительно передаёт все проверки формата и конфигурации общему C-декодеру.
+ fails { GuiGasWatch2.decode(payload, GuiGasWatch2.Config(config.configId + 1L, 100, config.addresses)) } // Старый пакет с прежним id нельзя принять в новую подписку того же размера.
+ fails { GuiGasWatch2.decode(payload, GuiGasWatch2.Config(config.configId, 100, listOf(0, 1))) } // Совпадающий id не разрешает принять иное количество значений.
+ fails { GuiGasWatch2.decode(payload.copyOf(payload.size - 1), config) } // Усечённое последнее слово не должно давать частичный снимок.
+ fails { GuiGasWatch2.decode(payload + byteArrayOf(0), config) } // Лишний байт не должен молча игнорироваться после допустимого пакета.
+ val dirtyInvalid = payload.copyOf().also { it[it.lastIndex] = 1 } // Нарушает fixture: у невалидного третьего канала wire-значение обязано оставаться нулём.
+ fails { GuiGasWatch2.decode(dirtyInvalid, config) } // Проверяет C-отказ от неканонического невалидного слова, чтобы не скрывать повреждение payload.
+ val dirtyPadding = payload.copyOf().also { it[16] = 0x83.toByte() } // Устанавливает bit7 единственного байта bitmap fixture при трёх каналах; этот padding-бит не относится ни к одному слову.
+ fails { GuiGasWatch2.decode(dirtyPadding, config) } // Проверяет C-отказ от ненулевых неиспользуемых битов валидности.
+ val wrongVersion = payload.copyOf().also { it[0] = 1 } // Меняет эталонную версию 2 на legacy-значение 1, не меняя остальные байты.
+ fails { GuiGasWatch2.decode(wrongVersion, config) } // Не позволяет принять другой контракт только из-за совпадения длины пакета.
+ assertNull(NativeSetProtocol.nativeGasWatchDecode(payload, config.configId, -1)) // Прямой JNI-вызов не должен превращать отрицательный count в большое size_t.
+ assertNull(NativeSetProtocol.nativeGasWatchDecode(payload, config.configId, 65)) // JNI ограничивает размеры своих стековых массивов до обращения к C-декодеру.
+ } // Завершает проверку чужих конфигураций, повреждения длины и канонического bitmap.
+
+ @Test fun pendingChangePreservesActiveUntilExactAck() { // Моделирует смену адресов во время продолжающегося потока старой подписки.
+ val session = GuiGasWatch2.Session() // Создаёт состояние отдельного подключения без заранее активной конфигурации.
+ fails { session.sample(payload) } // До первого ACK клиент обязан отклонять данные, даже если их формат корректен.
+ assertArrayEquals(request, session.request(config)) // Ставит первый успешно построенный запрос в pending.
+ assertNull(session.active) // Один отправленный запрос ещё не означает, что прибор применил подписку.
+ assertEquals(config, session.pending) // Ожидающая конфигурация точно соответствует выданным байтам запроса.
+ session.validateAck(request, config) // Активирует первую подписку только после полного ACK.
+ val next = GuiGasWatch2.Config(config.configId + 1L, 100, listOf(2, 3, 0xFF01)) // Создаёт другую конфигурацию той же ширины для обнаружения ошибки позиционного сопоставления.
+ val nextRequest = session.request(next) // Ставит новую конфигурацию в pending, не заменяя подтверждённый список.
+ assertEquals(config, session.active) // До нового ACK продолжает действовать предыдущая подписка.
+ assertEquals(123, session.sample(payload).values[0]) // Старый корректный пакет продолжает декодироваться по старым адресам во время ожидания.
+ fails { session.validateAck(request, config) } // Запоздалый ACK первой конфигурации не может подтвердить последний запрос.
+ fails { session.validateAck(request, next) } // Подстановка ожидаемого объекта не позволяет выдать чужое полное эхо за новый ACK.
+ assertEquals(config, session.active) // Ошибочные подтверждения не должны сбросить действующую подписку.
+ assertEquals(next, session.pending) // Ошибочные подтверждения также не должны потерять последний ожидающий запрос.
+ session.validateAck(nextRequest, next) // Только точное эхо нового запроса переключает активную конфигурацию.
+ assertEquals(next, session.active) // Подтверждает переход к новому списку адресов.
+ assertNull(session.pending) // Успешное подтверждение завершает ожидающий запрос.
+ fails { session.sample(payload) } // Старый пакет того же размера после переключения должен отклоняться по id в C.
+ fails { session.validateAck(nextRequest, next) } // Повтор уже завершённого ACK не считается новым подтверждением.
+ } // Завершает проверку согласованного переключения и защиты от запоздалых пакетов.
+
+ @Test fun failedRequestReusedIdStopAndResetKeepStateConsistent() { // Проверяет отказ запросов, идемпотентный повтор, остановку и границу переподключения.
+ val session = GuiGasWatch2.Session() // Создаёт независимое состояние для сценария ошибок и завершения подписки.
+ session.request(config) // Готовит первый запрос, который далее будет подтверждён настоящим C-валидатором.
+ session.validateAck(request, config) // Устанавливает известную действующую конфигурацию.
+ session.request(config) // Разрешает идентичный повтор запроса с тем же id как идемпотентную операцию.
+ fails { session.request(GuiGasWatch2.Config(config.configId, 101, config.addresses)) } // Запрещает переиспользовать active или pending id с другим периодом.
+ fails { session.request(GuiGasWatch2.Config(config.configId + 1L, 100, listOf(1, 1))) } // Ошибка C builder не должна заменить ожидающую конфигурацию некорректным списком.
+ assertEquals(config, session.active) // После обеих ошибок сохраняется подтверждённая подписка.
+ assertEquals(config, session.pending) // После обеих ошибок сохраняется последний успешно построенный запрос.
+ session.validateAck(request, config) // Завершает идемпотентный повтор до отдельного запроса остановки.
+ val stop = GuiGasWatch2.Config(config.configId + 1L, 0, emptyList()) // Остановка представляется новой конфигурацией с нулевым периодом и пустым списком согласно C-контракту.
+ val stopRequest = session.request(stop) // Готовит остановку, пока старая подписка ещё активна до ACK.
+ assertEquals(123, session.sample(payload).values[0]) // Старый поток остаётся допустимым до фактического подтверждения остановки.
+ session.validateAck(stopRequest, stop) // Применяет остановку только после полного подтверждения прибора.
+ fails { session.sample(payload) } // После подтверждённой остановки данные не должны попадать в график.
+ session.reset() // Моделирует разрыв связи, очищая оба состояния перед следующим подключением.
+ assertNull(session.active) // Следующее соединение не наследует подтверждённый id предыдущего прибора.
+ assertNull(session.pending) // Следующее соединение не наследует ожидающий ACK предыдущего прибора.
+ fails { session.validateAck(stopRequest, stop) } // Запоздалое подтверждение до разрыва не может активировать конфигурацию после reset.
+ } // Завершает сценарий отказов и границы жизненного цикла подключения.
+} // Завершает JVM/JNI-проверки общего GAS_WATCH2 без UI-зависимостей.
diff --git a/c/set-protocol/ports/stm32-bxcan/README.md b/c/set-protocol/ports/stm32-bxcan/README.md
index 5f1dd42..04c89a3 100644
--- a/c/set-protocol/ports/stm32-bxcan/README.md
+++ b/c/set-protocol/ports/stm32-bxcan/README.md
@@ -34,7 +34,7 @@ c/set-protocol/
Модуль зависит от файлов `main.h` и `can.h`, сгенерированных STM32CubeMX, а также
от HAL-драйверов CAN, RTC и TIM. В сборку добавляются
-`c/set-protocol/ports/stm32-bxcan/protocan.c` и `c/set-protocol/src/pcan_id.c`.
+`c/set-protocol/ports/stm32-bxcan/protocan.c`, `c/set-protocol/src/pcan_id.c` и `c/set-protocol/src/pcan_gas_slot.c`.
В include paths добавляются `c/set-protocol/ports/stm32-bxcan`,
`c/set-protocol/include` и каталог CubeMX с `main.h`/`can.h`.
Если прошивка уже собирает общее ядро SETProtocol, второй раз добавлять
@@ -80,7 +80,7 @@ c/set-protocol/
| `0x1` | дискретное (`DISCRETE`) |
| `0x2` | аналоговое (`ANALOG`) |
| `0x3` | общее адресное пространство |
-| `0x4` | Modbus Coil |
+| `0x4` | GAS SLOT: Word0-маска и три значения |
| `0x5` | Modbus Discrete |
| `0x6` | Modbus Holding |
| `0x7` | Modbus Input |
@@ -600,3 +600,9 @@ CRC32 обнаруживает случайное повреждение, но
7. При необходимости подключить HAL-callback’и вручную.
8. Переопределить нужные `__weak`-обработчики.
9. Вызывать `PROTOCAN_ProcessAllRxMsgs()` в основном цикле.
+
+## GAS: регистры 0x3 и слоты 0x4
+
+[Адресация GAS](../../docs/GAS_SLOT.md): `0x3` — отдельные 16-битные регистры `0…65535`, запись 1…4 слов. `0x4` — слот с Word0-маской и тремя значениями; PERIPH сохраняет 200 слов в 67 слотах `0xMM00…0xMM42`. Формат определяется MsgType, без настройки профиля.
+
+`PROTOCAN_GAS_SLOT_BIND(&port)` подключает provider типа 0x4 до обработки очереди. `ProtoCanMsgToGasSlot()` вызывает общий адресный обработчик, `PROTOCAN_SEND_GAS_SLOT()` кодирует Word0-маску и три значения. Неподключённый provider возвращает ошибку. Типы Modbus 0x5…0x7 сохраняют прежние номера.
diff --git a/c/set-protocol/ports/stm32-bxcan/protocan.c b/c/set-protocol/ports/stm32-bxcan/protocan.c
index eef7c68..1bdeca0 100644
--- a/c/set-protocol/ports/stm32-bxcan/protocan.c
+++ b/c/set-protocol/ports/stm32-bxcan/protocan.c
@@ -170,12 +170,15 @@ void PROTOCAN_LoopProcessRxMsgs(void)
{
ProtoCanMsgToGeneralAddressSpace(rxMsg[CurrentStep]);
}
+ else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_GAS_SLOT) /* Независимый тип 0x4 не должен попадать в обработчик Modbus. */
+ { /* Передаём маску и значения общему слотному кодеку из основного цикла. */
+ ProtoCanMsgToGasSlot(rxMsg[CurrentStep]); /* ISR только ставит кадр в очередь; provider вызывается здесь. */
+ } /* Завершаем обработку слотного сообщения. */
else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_SETTINGS)
{
PROTOCAN_SettingsProcessing(rxMsg[CurrentStep]);
}
- else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_COIL ||
- rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE ||
+ else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE || /* Тип 0x4 передаётся отдельному слотному обработчику, Modbus начинается с 0x5. */
rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_HOLDING ||
rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_INPUT)
{
@@ -226,12 +229,15 @@ PROTOCAN_StatusTypeDef PROTOCAN_ProcessAllRxMsgs(void)
{
result = ProtoCanMsgToGeneralAddressSpace(rxMsg[CurrentStep]);
}
+ else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_GAS_SLOT) /* Независимый тип 0x4 не должен попадать в обработчик Modbus. */
+ { /* Передаём маску и значения общему слотному кодеку из основного цикла. */
+ result = ProtoCanMsgToGasSlot(rxMsg[CurrentStep]); /* ISR только ставит кадр в очередь; provider вызывается здесь. */
+ } /* Завершаем обработку слотного сообщения. */
else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_SETTINGS)
{
result = PROTOCAN_SettingsProcessing(rxMsg[CurrentStep]);
}
- else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_COIL ||
- rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE ||
+ else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE || /* Тип 0x4 передаётся отдельному слотному обработчику, Modbus начинается с 0x5. */
rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_HOLDING ||
rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_INPUT)
{
@@ -283,12 +289,15 @@ PROTOCAN_StatusTypeDef PROTOCAN_ProcessSingleRxMsg(void)
{
result = ProtoCanMsgToGeneralAddressSpace(rxMsg[CurrentStep]);
}
+ else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_GAS_SLOT) /* Независимый тип 0x4 не должен попадать в обработчик Modbus. */
+ { /* Передаём маску и значения общему слотному кодеку из основного цикла. */
+ result = ProtoCanMsgToGasSlot(rxMsg[CurrentStep]); /* ISR только ставит кадр в очередь; provider вызывается здесь. */
+ } /* Завершаем обработку слотного сообщения. */
else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_SETTINGS)
{
result = PROTOCAN_SettingsProcessing(rxMsg[CurrentStep]);
}
- else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_COIL ||
- rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE ||
+ else if(rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE || /* Тип 0x4 передаётся отдельному слотному обработчику, Modbus начинается с 0x5. */
rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_HOLDING ||
rxMsg[CurrentStep].eID.Fields.MsgType == PROTOCAN_MSGTYPE_MODBUS_INPUT)
{
@@ -1123,11 +1132,6 @@ PROTOCAN_StatusTypeDef PROTOCAN_ModbusProcessing(struct RXMsg _rxMsg)
{
switch(_rxMsg.eID.Fields.MsgType)
{
- case PROTOCAN_MSGTYPE_MODBUS_COIL:
- {
- return ProtoCanMsgToModbusCoil(_rxMsg);
- break;
- }
case PROTOCAN_MSGTYPE_MODBUS_DISCRETE:
{
return ProtoCanMsgToModbusDiscrete(_rxMsg);
@@ -1151,37 +1155,37 @@ PROTOCAN_StatusTypeDef PROTOCAN_ModbusProcessing(struct RXMsg _rxMsg)
return PROTOCAN_ERROR;
}
-/**
-* @brief __weak Функция отправки ответа на запрос Coil Modbus по CAN.
-* Формирует CAN ответ на запрос Coil Modbus и отправляет его.
-* @param _rxMsg Структура входящего сообщения RXMsg.
-* @return HAL_StatusTypeDef Статус выполнения команды CAN (успех или ошибка).
-*/
-__weak PROTOCAN_StatusTypeDef ProtoCanMsgToModbusCoil(struct RXMsg _rxMsg)
-{
- CAN_TxHeaderTypeDef TxHeader;
- uint32_t TxMailBox = 0;
- uint8_t data[8];
- TxHeader.IDE = CAN_ID_EXT;
- TxHeader.TransmitGlobalTime = DISABLE;
- TxHeader.RTR = CAN_RTR_DATA;
- ProtoCanId_t tmp_eID;
- tmp_eID.BitAll = _rxMsg.eID.BitAll;
- tmp_eID.Fields.Route = PROTOCAN_ROUTE_FROM_DEVICE;
- TxHeader.ExtId = PROTOCAN_IdPack(&tmp_eID);
- TxHeader.DLC = 8;
- msgBodyModbusType msg;
- msg.Body = _rxMsg.eID.Fields.MsgBody;
- data[0] = 'M';
- data[1] = 'C';
- data[2] = ' ';
- data[3] = 'S';
- data[4] = msg.Fields.StrAdr;
- data[5] = ' ';
- data[6] = 'C';
- data[7] = msg.Fields.RegCount;
- return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &TxHeader, data, &TxMailBox);
-}
+static const pcan_gas_slot_port_t *gas_slot_port; /* Provider принадлежит приложению; NULL означает, что слотный сервис ещё не подключён. */
+void PROTOCAN_GAS_SLOT_BIND(const pcan_gas_slot_port_t *port) /* Вызывать при инициализации до обработки очереди CAN. */
+{ /* Срок жизни структуры provider должен покрывать всё время работы порта. */
+ gas_slot_port = port; /* NULL явно отключает сервис без подстановки фиктивных данных. */
+} /* Конец подключения аппаратно-независимого provider. */
+PROTOCAN_StatusTypeDef PROTOCAN_SEND_GAS_SLOT(ProtoCanId_t id, uint16_t address, const uint16_t *words, unsigned count) /* Отправляем маску и три значения единственным C-кодеком. */
+{ /* Нулевой count обозначает чтение, четыре wire-слова — масочную запись или ответ. */
+ CAN_TxHeaderTypeDef header = {0}; uint32_t mailbox = 0U; uint_least8_t bytes[8]; uint16_t checked[4]; uint8_t data[8] = {0}; int length; unsigned i; /* HAL получает восемь октетов независимо от внутреннего представления общего ядра. */
+ id.Fields.MsgType = PROTOCAN_MSGTYPE_GAS_SLOT; id.Fields.MsgBody = address; /* Тип 0x4 и полный 16-битный адрес слота задаются отдельно от карты регистров 0x3. */
+ length = pcan_gas_slot_encode(words, count, bytes); /* Проверяем маску Word0 до формирования отправляемого HAL-заголовка. */
+ if (_HCAN == NULL || length < 0 || pcan_gas_slot_decode_words(PROTOCAN_IdPack(&id), PCAN_GAS_SLOT_FLAG_IDE, bytes, (unsigned)length, checked) < 0) return PROTOCAN_ERROR; /* Не отправляем неверную маску, пустой ответ или кадр без подготовленного CAN. */
+ for (i = 0U; i < (unsigned)length; ++i) data[i] = (uint8_t)bytes[i]; /* Переносим уже закодированные октеты без повторного endian-алгоритма. */
+ header.IDE = CAN_ID_EXT; header.ExtId = PROTOCAN_IdPack(&id); header.RTR = CAN_RTR_DATA; header.DLC = (uint32_t)length; header.TransmitGlobalTime = DISABLE; /* Используем extended data frame с DLC ровно 0 либо 8. */
+ return (PROTOCAN_StatusTypeDef)HAL_CAN_AddTxMessage(_HCAN, &header, data, &mailbox); /* Возвращаем фактический результат постановки кадра в аппаратный TX mailbox. */
+} /* Конец отправки слота через действующий HAL-порт. */
+__weak PROTOCAN_StatusTypeDef ProtoCanMsgToGasSlot(struct RXMsg message) /* При необходимости приложение может переопределить адаптер, сохранив общий wire-контракт. */
+{ /* По умолчанию подключённый provider обрабатывается переносимым C-ядром. */
+ pcan_gas_slot_frame_t request = {0}, response; unsigned i; int result; /* Ограничиваем вход одним CAN-кадром и не используем динамическую память. */
+ if (gas_slot_port == NULL || message.DLC > 8U) return PROTOCAN_ERROR; /* Не имитируем успешный сервис без реального provider устройства. */
+ request.id = PROTOCAN_IdPack(&message.eID); request.dlc = message.DLC; request.flags = (message.info.EXT ? PCAN_GAS_SLOT_FLAG_IDE : 0U) | (message.info.RTR ? PCAN_GAS_SLOT_FLAG_RTR : 0U); /* Сохраняем фактические флаги приёма для проверки общим декодером. */
+ for (i = 0U; i < message.DLC; ++i) request.data[i] = message.Data[i]; /* Передаём исходные wire-октеты без извлечения маски в HAL-адаптере. */
+ result = pcan_gas_slot_handle_address(CURRENT_TYPE_DEVICE, CURRENT_ID_DEVICE, gas_slot_port, &request, &response); /* Проверяем тип 0x4, адрес своего узла, направление и маску до вызова provider. */
+ if (result < 0) return PROTOCAN_ERROR; /* Неверный слот или запрещённая запись не переходят в Modbus или GAS 0x3. */
+ if (result == 0) return PROTOCAN_OK; /* Базовый слотный протокол не формирует отдельный ACK записи. */
+ message.eID.BitAll = response.id; /* Ответ общего ядра сохраняет адрес узла и меняет Route на FROM_DEVICE. */
+ { /* Проверенный ответ переводится в HAL тем же публичным методом отправки. */
+ uint16_t words[4]; /* Маска и значения возвращаются общим декодером без второго wire-алгоритма. */
+ if (pcan_gas_slot_decode_words(response.id, response.flags, response.data, response.dlc, words) < 0) return PROTOCAN_ERROR; /* Проверяем сформированный ответ перед публикацией в транспорт. */
+ return PROTOCAN_SEND_GAS_SLOT(message.eID, (uint16_t)(response.id & 0xFFFFU), words, 4U); /* Отправляем один полный восьмибайтовый слот с маской достоверных значений. */
+ } /* Завершаем отправку сформированного ответа. */
+} /* Конец стандартного адаптера слотного GAS. */
/**
* @brief __weak Функция отправки ответа на запрос Discrete Modbus по CAN.
@@ -1288,8 +1292,8 @@ __weak PROTOCAN_StatusTypeDef ProtoCanMsgToModbusInput(struct RXMsg _rxMsg)
* @param modbusData Структура с данными Modbus: адрес, количество регистров и сами данные.
* @return PROTOCAN_StatusTypeDef Статус операции.
* @details
- * В зависимости от типа Modbus (coil/discrete или holding/input), формируется
- * соответствующий CAN-пакет. Для coil/discrete отправляется один пакет, для holding/input —
+ * В зависимости от типа Modbus (discrete или holding/input), формируется
+ * соответствующий CAN-пакет. Для discrete отправляется один пакет, для holding/input —
* несколько, по 4 регистра в пакете. В случае ошибок при добавлении сообщения API HAL возвращается ошибка.
*/
PROTOCAN_StatusTypeDef PROTOCAN_SEND_MODBUS(const ProtoCanId_t id, const struct ProtoCanModbusData modbusData)
@@ -1300,7 +1304,7 @@ PROTOCAN_StatusTypeDef PROTOCAN_SEND_MODBUS(const ProtoCanId_t id, const struct
CAN_TxHeaderTypeDef TxHeader;
uint32_t TxMailBox = 0;
uint8_t canData[8];
- if(msgType == PROTOCAN_MSGTYPE_MODBUS_COIL || msgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE)
+ if(msgType == PROTOCAN_MSGTYPE_MODBUS_DISCRETE) /* Битовая ветвь Modbus поддерживает только входы 0x5; 0x4 зарезервирован слотному GAS. */
{
// Формируем body
msgBodyModbusType body;
@@ -1649,7 +1653,8 @@ PROTOCAN_StatusTypeDef PROTOCAN_SEND(ProtoCanId_t id, ProtoCanData_t data)
data.GeneralAddressSpaceData.Data,
data.GeneralAddressSpaceData.RegCount);
}
- case PROTOCAN_MSGTYPE_MODBUS_COIL:
+ case PROTOCAN_MSGTYPE_GAS_SLOT: /* Отправка 0x4 использует служебную маску, а не структуру Modbus. */
+ return PROTOCAN_SEND_GAS_SLOT(id, data.GasSlotData.Address, data.GasSlotData.Words, data.GasSlotData.Count); /* Count=0 запрашивает чтение; Count=4 передаёт маску и три значения. */
case PROTOCAN_MSGTYPE_MODBUS_DISCRETE:
case PROTOCAN_MSGTYPE_MODBUS_HOLDING:
case PROTOCAN_MSGTYPE_MODBUS_INPUT:
diff --git a/c/set-protocol/ports/stm32-bxcan/protocan.h b/c/set-protocol/ports/stm32-bxcan/protocan.h
index 2fac9b8..08636bc 100644
--- a/c/set-protocol/ports/stm32-bxcan/protocan.h
+++ b/c/set-protocol/ports/stm32-bxcan/protocan.h
@@ -10,6 +10,7 @@
#include "main.h"
#include "can.h"
#include "pcan_id.h"
+#include "pcan_gas_slot.h" /* Общий формат типа 0x4 используется при приёме и отправке масочных слотов без дублирования кодека. */
#define _DEMO
@@ -82,7 +83,7 @@ typedef enum {
PROTOCAN_MSGTYPE_DISCRETE = 0b0001, /**< Дискретное цифровое сообщение. */
PROTOCAN_MSGTYPE_ANALOG = 0b0010, /**< Аналоговое сообщение. */
PROTOCAN_MSGTYPE_GENERAL_ADDRESS_SPACE = 0b0011, /**< Сообщение общего адресного пространства. */
- PROTOCAN_MSGTYPE_MODBUS_COIL = 0b0100, /**< Сообщение Modbus - Coil. */
+ PROTOCAN_MSGTYPE_GAS_SLOT = 0b0100, /**< Слотный GAS: Word0 — маска, Word1…Word3 — значения; прежний код Coil заменён. */
PROTOCAN_MSGTYPE_MODBUS_DISCRETE = 0b0101, /**< Сообщение Modbus - Discrete. */
PROTOCAN_MSGTYPE_MODBUS_HOLDING = 0b0110, /**< Сообщение Modbus - Holding. */
PROTOCAN_MSGTYPE_MODBUS_INPUT = 0b0111, /**< Сообщение Modbus - Input. */
@@ -314,6 +315,11 @@ typedef struct{
uint16_t *Data; /**< Указатель на массив данных. */
unsigned RegCount; /**< Количество регистров. */
} GeneralAddressSpaceData;
+ struct ProtoCanGasSlotData { /* Отдельная структура исключает смешение номера слота и адреса первого регистра GAS. */
+ uint16_t Address; /* Полный 16-битный адрес слота 0…65535. */
+ const uint16_t *Words; /* Четыре wire-слова: маска Word0 и значения Word1…Word3. */
+ unsigned Count; /* Ноль означает чтение, четыре — маску и три значения; другие размеры отвергает C-кодек. */
+ } GasSlotData; /* Член структуры используется только для MsgType=0x4. */
struct ProtoCanModbusData{
uint16_t StrAdr;
uint16_t *Data;
@@ -422,7 +428,9 @@ PROTOCAN_StatusTypeDef ProtoCanMsgToGeneralAddressSpace(struct RXMsg _rxMsg);
PROTOCAN_StatusTypeDef PROTOCAN_RequestError(struct RXMsg _rxMsg);
PROTOCAN_StatusTypeDef PROTOCAN_ModbusProcessing(struct RXMsg _rxMsg);
-PROTOCAN_StatusTypeDef ProtoCanMsgToModbusCoil(struct RXMsg _rxMsg);
+void PROTOCAN_GAS_SLOT_BIND(const pcan_gas_slot_port_t *port); /* Подключает долгоживущий provider слотов до обработки очереди; NULL отключает сервис. */
+PROTOCAN_StatusTypeDef ProtoCanMsgToGasSlot(struct RXMsg message); /* Передаёт тип 0x4 общему обработчику с проверкой адреса узла и маски Word0. */
+PROTOCAN_StatusTypeDef PROTOCAN_SEND_GAS_SLOT(ProtoCanId_t id, uint16_t address, const uint16_t *words, unsigned count); /* Отправляет один слот через общий C-кодек, без Modbus-упаковки адреса. */
PROTOCAN_StatusTypeDef ProtoCanMsgToModbusDiscrete(struct RXMsg _rxMsg);
PROTOCAN_StatusTypeDef ProtoCanMsgToModbusHolding(struct RXMsg _rxMsg);
PROTOCAN_StatusTypeDef ProtoCanMsgToModbusInput(struct RXMsg _rxMsg);
diff --git a/c/set-protocol/src/pcan_gas.c b/c/set-protocol/src/pcan_gas.c
index 27132b4..1562edb 100644
--- a/c/set-protocol/src/pcan_gas.c
+++ b/c/set-protocol/src/pcan_gas.c
@@ -139,7 +139,8 @@ uint16_t pcan_gas_write_block(const pcan_gas_map_t *map, uint16_t addr,
uint16_t pcan_gas_frame_to_regs(const pcan_frame_t *frame, uint16_t *out,
uint16_t max)
{
- uint16_t n = 0U;
+ uint16_t n = 0U; /* Счётчик увеличивается только после проверки длины и наличия обоих октетов слова. */
+ if (frame == NULL || out == NULL || frame->dlc > PCAN_DATA_MAX || (frame->dlc & 1U) != 0U) return 0U; /* Отвергаем нечётные и превышающие classic CAN длины до доступа к данным. */
for (uint8_t i = 0U; (uint8_t)(i + 1U) < frame->dlc; i = (uint8_t)(i + 2U)) {
if (n >= max) {
break;
@@ -178,9 +179,10 @@ void pcan_gas_regs_to_frame(pcan_frame_t *frame, uint32_t base_id,
bool pcan_gas_handle(const pcan_gas_map_t *map, const pcan_frame_t *req,
pcan_frame_t *rsp)
{
- pcan_id_t id;
- pcan_id_unpack(req->id, &id);
- if (id.msg_type != (uint8_t)PCAN_MSG_GAS) {
+ pcan_id_t id; /* Адрес регистра и направление проверяются до вызова provider, чтобы ответ не стал записью. */
+ if (map == NULL || req == NULL || (req->id & ~PCAN_ID_MASK) != 0U || (req->flags & PCAN_FLAG_IDE) == 0U || (req->flags & (PCAN_FLAG_RTR | PCAN_FLAG_ERR)) != 0U || req->dlc > PCAN_DATA_MAX || (req->dlc & 1U) != 0U) return false; /* Принимаем только extended data frame с целыми 16-битными регистрами. */
+ pcan_id_unpack(req->id, &id); /* Разбираем проверенный 29-битный идентификатор единственным общим кодеком. */
+ if (id.msg_type != (uint8_t)PCAN_MSG_GAS || id.route != PCAN_ROUTE_FROM_PM || (uint32_t)id.msg_body + req->dlc / 2U > 0x10000UL || (req->dlc == 0U && rsp == NULL)) { /* Тип 0x4, ответы прибора и блоки за адресом 65535 не изменяют карту; чтению нужен выходной кадр. */
return false;
}
diff --git a/c/set-protocol/src/pcan_gas_slot.c b/c/set-protocol/src/pcan_gas_slot.c
new file mode 100644
index 0000000..3df5c9a
--- /dev/null
+++ b/c/set-protocol/src/pcan_gas_slot.c
@@ -0,0 +1,103 @@
+#include "pcan_gas_slot.h" /* Подключаем независимый слотный GAS типа 0x4 с маской в нулевом слове. */
+#include "pcan_id.h" /* Используем единственный общий упаковщик полей 29-битного CAN ID. */
+#include /* Локальный ответ обнуляется до заполнения и публикации вызывающему коду. */
+static unsigned available_mask(unsigned local) /* Вычисляем действующие позиции трёх значений PERIPH, включая неполный последний слот. */
+{ /* Маска не является хранимым регистром и не занимает адрес в карте технологических слов. */
+ unsigned word, mask = 0U; /* Биты 1…3 соответствуют одноимённым позициям wire-пакета после маски. */
+ for (word = 1U; word <= PCAN_GAS_SLOT_DATA_WORDS; ++word) if (local * PCAN_GAS_SLOT_DATA_WORDS + word - 1U < PCAN_GAS_SLOT_MODE_WORDS) mask |= 1U << word; /* Сохраняем ровно 200 слов без фиктивного 201-го регистра. */
+ return mask; /* Для обычного слота получаем 0x000E, для последнего — 0x0006. */
+} /* Завершаем расчёт единственной геометрии PERIPH. */
+int32_t pcan_gas_slot_address(unsigned mode, unsigned local) /* Строим читаемый адрес только для реально выделенной страницы PERIPH. */
+{ /* Остальные 16-битные адреса доступны через общий адресный API отдельного узла. */
+ if (mode >= PCAN_GAS_SLOT_MODE_COUNT || local >= PCAN_GAS_SLOT_MODE_SLOTS) return -1; /* Отвергаем чужой mode и резерв страницы до сужения типов. */
+ return (int32_t)(((uint32_t)mode << 8) | local); /* Старший октет повторяет mode, младший содержит локальный слот 0…66. */
+} /* Конец проверки координат PERIPH. */
+uint32_t pcan_gas_slot_id(unsigned device_type, unsigned device_id, unsigned address, unsigned route) /* Поддерживаем любой узел ProtoCAN и полный адрес слота 0…65535. */
+{ /* Нулевой результат означает недопустимые поля, а не молчаливое усечение адреса. */
+ pcan_id_t id; /* Битовую раскладку идентификатора формирует существующее общее ядро. */
+ if (device_type > 7U || device_id > 15U || address > 65535UL || route > 1U) return 0U; /* Проверяем все поля до приведения к их wire-типам. */
+ id.priority = PCAN_PRIORITY_STANDARD; id.route = (uint_least8_t)route; /* Обычный приоритет и явное направление сохраняют семантику ProtoCAN. */
+ id.device_type = (uint_least8_t)device_type; id.device_id = (uint_least8_t)device_id; /* Не связываем общий слотный сервис исключительно с семейством PERIPH. */
+ id.msg_type = PCAN_MSG_GAS_SLOT; id.msg_body = (uint16_t)address; /* Тип 0x4 однозначно отличает слот от адреса отдельного регистра типа 0x3. */
+ return pcan_id_pack(&id); /* Публикуем идентификатор после проверки полного диапазона входов. */
+} /* Конец общего адресного построителя. */
+uint32_t pcan_gas_slot_request_id(unsigned mode, unsigned local, unsigned route) /* Сохраняем удобный совместимый вызов для mode PERIPH. */
+{ /* Обёртка добавляет только раскладку mode и не реализует второй кодек. */
+ int32_t address = pcan_gas_slot_address(mode, local); /* Проверяем принадлежность слота выделенной странице. */
+ return address < 0 ? 0U : pcan_gas_slot_id(1U + mode / 16U, mode % 16U, (unsigned)address, route); /* Обе группы по 16 узлов используют общий конструктор типа 0x4. */
+} /* Конец PERIPH-обёртки построения ID. */
+int pcan_gas_slot_encode(const uint16_t *words, unsigned count, uint_least8_t *data) /* Word0 содержит маску, Word1…Word3 — значения в фиксированных позициях. */
+{ /* Пустой payload означает чтение, полный восьмибайтовый payload — маску и данные. */
+ unsigned i; /* Индекс относится к четырём wire-словам, включая служебную маску. */
+ if (count == 0U) return 0; /* Запрос чтения не требует массива значений или фиктивной маски. */
+ if (count != PCAN_GAS_SLOT_WORDS || words == NULL || data == NULL || words[0] == 0U || (words[0] & ~PCAN_GAS_SLOT_MASK) != 0U) return -1; /* Биты 0 и 4…15 зарезервированы, пустая запись запрещена. */
+ for (i = 0U; i < PCAN_GAS_SLOT_WORDS; ++i) { /* Проверенная маска и три значения одинаково упаковываются little-endian. */
+ data[2U * i] = (uint_least8_t)(words[i] & 255U); data[2U * i + 1U] = (uint_least8_t)(words[i] >> 8); /* Значения 255 и сдвиг 8 задают октеты линии независимо от ширины char C28x. */
+ } /* Все восемь октетов сформированы только после проверки входа. */
+ return 8; /* DLC измеряется октетами CAN, а не размером C-массива на wide-char процессоре. */
+} /* Конец единственного кодирования маски и значений. */
+int pcan_gas_slot_decode_words(uint32_t raw, unsigned flags, const uint_least8_t *data, unsigned dlc, uint16_t *words) /* Общая проверка формата не ограничивает узел PERIPH или страницу слота. */
+{ /* Выходные слова остаются неизменными при любой ошибке. */
+ pcan_id_t id; unsigned i; uint16_t decoded[PCAN_GAS_SLOT_WORDS]; /* Сначала собираем результат в локальном массиве, чтобы отказ был безопасным. */
+ if ((raw & ~PCAN_ID_MASK) != 0U || !(flags & PCAN_GAS_SLOT_FLAG_IDE) || (flags & (PCAN_GAS_SLOT_FLAG_RTR | PCAN_GAS_SLOT_FLAG_ERR)) != 0U) return -1; /* Принимаем только extended data frames classic CAN. */
+ if ((dlc != 0U && dlc != 8U) || (dlc == 8U && (data == NULL || words == NULL))) return -1; /* Усечённая маска или часть слота не являются допустимой командой. */
+ pcan_id_unpack(raw, &id); /* Все адресные поля извлекаются общим C-кодеком. */
+ if (id.msg_type != PCAN_MSG_GAS_SLOT || (dlc == 0U && id.route != PCAN_ROUTE_FROM_PM)) return -1; /* Регистровый GAS 0x3 и пустой ответ не попадают в слотный сервис. */
+ if (dlc == 0U) return 0; /* Пустой запрос чтения не изменяет массив значений. */
+ for (i = 0U; i < 8U; ++i) if (data[i] > 255U) return -1; /* На C28x запрещаем ненулевые старшие биты каждой ячейки, представляющей wire-октет. */
+ for (i = 0U; i < PCAN_GAS_SLOT_WORDS; ++i) decoded[i] = (uint16_t)((uint16_t)data[2U * i] | ((uint16_t)data[2U * i + 1U] << 8)); /* Восстанавливаем четыре слова с явно заданным порядком октетов. */
+ if (decoded[0] == 0U || (decoded[0] & ~PCAN_GAS_SLOT_MASK) != 0U) return -1; /* Разрешены только непустые комбинации битов 1, 2 и 3, соответствующие позициям значений. */
+ for (i = 0U; i < PCAN_GAS_SLOT_WORDS; ++i) words[i] = decoded[i]; /* Публикуем значения после проверки всей маски. */
+ return PCAN_GAS_SLOT_WORDS; /* Четыре возвращённых слова включают Word0 с маской, а не четвёртый регистр. */
+} /* Конец общего декодера любого адреса слота. */
+int pcan_gas_slot_decode(uint32_t raw, unsigned flags, const uint_least8_t *data, unsigned dlc, unsigned *mode, unsigned *local, uint16_t *words) /* Добавляем к общему формату ограничения страницы PERIPH. */
+{ /* При отказе ни координаты, ни значения не меняются. */
+ pcan_id_t id; unsigned found_mode, found_local, i; uint16_t decoded[PCAN_GAS_SLOT_WORDS]; int count; /* Подготавливаем промежуточные выходы общей проверки. */
+ if (mode == NULL || local == NULL || (dlc != 0U && words == NULL)) return -1; /* Все требуемые выходные указатели проверяются до чтения или публикации данных. */
+ count = pcan_gas_slot_decode_words(raw, flags, data, dlc, decoded); if (count < 0) return -1; /* Единственный wire-декодер проверяет тип 0x4, маску, DLC и флаги. */
+ pcan_id_unpack(raw, &id); if (id.device_type < 1U || id.device_type > 2U) return -1; /* PERIPH занимает только два существующих класса адресов. */
+ found_mode = (unsigned)(id.device_type - 1U) * 16U + id.device_id; found_local = id.msg_body & 255U; /* Номер mode восстанавливается независимо от старшего октета слота. */
+ if (pcan_gas_slot_address(found_mode, found_local) != (int32_t)id.msg_body || (count > 0 && (decoded[0] & ~available_mask(found_local)) != 0U)) return -1; /* Отвергаем чужую страницу, резерв и несуществующее третье значение последнего слота. */
+ if (count > 0) for (i = 0U; i < PCAN_GAS_SLOT_WORDS; ++i) words[i] = decoded[i]; /* Значения публикуются после проверки геометрии PERIPH. */
+ *mode = found_mode; *local = found_local; return count; /* Возвращаем координаты только успешно проверенного кадра. */
+} /* Конец совместимой PERIPH-обёртки декодирования. */
+int pcan_gas_slot_native(unsigned local, unsigned word, unsigned write, uint16_t *address) /* Сопоставляем только значения Word1…Word3 с прежними native-регистрами. */
+{ /* Нулевая позиция является маской и никогда не адресует технологический регистр. */
+ unsigned offset; uint16_t native; /* Смещение измеряется в 16-битных словах полезной карты, без служебных масок. */
+ if (local >= PCAN_GAS_SLOT_MODE_SLOTS || word == 0U || word > PCAN_GAS_SLOT_DATA_WORDS || write > 1U || address == NULL) return 0; /* Предотвращаем адресацию маски и выход за выделенные позиции. */
+ offset = local * PCAN_GAS_SLOT_DATA_WORDS + word - 1U; /* Каждый следующий слот содержит следующие три значения исходной карты из 200 слов. */
+ if (offset < 128U) native = (uint16_t)offset; /* Все 128 технологических регистров сохраняют свои native-адреса и смысл. */
+ else if (offset < 144U && !write) native = (uint16_t)(0x1000U + offset - 128U); /* Следующие 16 слов читают статус регистратора УМП. */
+ else if (offset >= 144U && offset < 156U && !write) native = (uint16_t)(0x1200U + offset - 144U); /* Двенадцать слов паспорта доступны только для чтения. */
+ else if (offset >= 156U && offset < 164U) { /* Восемь следующих слов относятся к защите УМП. */
+ native = (uint16_t)(0x1300U + offset - 156U); /* Сохраняем исходные адреса 0x1300…0x1307 независимо от границ слота. */
+ if (write && native != 0x1302U && native != 0x1303U) return 0; /* Масочная запись допускает только два разрешённых поля защиты. */
+ } else if (offset >= 164U && offset < 168U) native = (uint16_t)(0x1000U + offset - 164U); /* Команды регистратора сохраняют собственные native-адреса четырёх слов. */
+ else return 0; /* Резерв 168…199 и лишняя позиция 200 не имитируются нулевыми значениями. */
+ *address = native; return 1; /* Доступность конкретного сервиса и применение записи проверяет provider устройства. */
+} /* Конец сопоставления без побочных эффектов. */
+int pcan_gas_slot_handle_address(unsigned device_type, unsigned device_id, const pcan_gas_slot_port_t *port, const pcan_gas_slot_frame_t *request, pcan_gas_slot_frame_t *response) /* Обрабатываем любой адресуемый узел ProtoCAN, включая отдельный инвертор. */
+{ /* Provider отвечает за общую проверку и изменение только выбранных маской значений. */
+ pcan_id_t id; uint16_t words[PCAN_GAS_SLOT_WORDS]; int count, length; pcan_gas_slot_frame_t out; /* Все рабочие данные локальны и не требуют динамической памяти. */
+ if (port == NULL || request == NULL || device_type > 7U || device_id > 15U) return -1; /* Не принимаем отсутствующий порт или усечённый адрес узла. */
+ count = pcan_gas_slot_decode_words(request->id, request->flags, request->data, request->dlc, words); if (count < 0) return -1; /* Проверяем полный wire-контракт до обращения к provider. */
+ pcan_id_unpack(request->id, &id); /* Направление и адрес проверяем отдельно от декодирования payload. */
+ if (id.route != PCAN_ROUTE_FROM_PM || id.device_type != device_type || id.device_id != device_id) return -1; /* Ответы приборов и запросы соседям не исполняются. */
+ if (count > 0) return port->write != NULL && port->write(port->context, id.msg_body, words) == 0 ? 0 : -1; /* Единственный callback получает маску Word0 и обязан сохранять невыбранные значения без чтения на хосте. */
+ if (response == NULL || port->read == NULL || port->read(port->context, id.msg_body, words) != 0) return -1; /* Ошибка чтения не превращается в успешные нули. */
+ memset(&out, 0, sizeof(out)); length = pcan_gas_slot_encode(words, PCAN_GAS_SLOT_WORDS, out.data); if (length < 0) return -1; /* Проверяем маску достоверных значений, которую вернул provider. */
+ id.route = PCAN_ROUTE_FROM_DEVICE; out.id = pcan_id_pack(&id); out.flags = PCAN_GAS_SLOT_FLAG_IDE; out.dlc = (unsigned)length; /* Ответ сохраняет полный адрес слота и меняет только направление. */
+ *response = out; return 1; /* Публикуем только полностью сформированный ответ. */
+} /* Конец общего обработчика слотов типа 0x4. */
+int pcan_gas_slot_handle(unsigned mode, const pcan_gas_slot_port_t *port, const pcan_gas_slot_frame_t *request, pcan_gas_slot_frame_t *response) /* Сохраняем специализированный вход PERIPH поверх общего обработчика. */
+{ /* Временный ответ защищает выходной кадр от ошибочного provider последнего слота. */
+ unsigned found_mode, local; uint16_t words[PCAN_GAS_SLOT_WORDS]; pcan_gas_slot_frame_t out; int result; /* Буферы ограничены одним classic CAN кадром. */
+ if (request == NULL || mode >= PCAN_GAS_SLOT_MODE_COUNT || pcan_gas_slot_decode(request->id, request->flags, request->data, request->dlc, &found_mode, &local, words) < 0 || found_mode != mode) return -1; /* Проверяем страницу PERIPH и маску до возможной записи. */
+ if (request->dlc == 0U && response == NULL) return -1; /* Не вызываем read-provider, если вызывающая сторона не предоставила место для ответа. */
+ result = pcan_gas_slot_handle_address(1U + mode / 16U, mode % 16U, port, request, &out); /* Используем общий обработчик типа 0x4 без второго алгоритма записи. */
+ if (result == 1) { /* Дополнительно проверяем маску чтения с учётом неполного последнего слота PERIPH. */
+ if (pcan_gas_slot_decode(out.id, out.flags, out.data, out.dlc, &found_mode, &local, words) < 0) return -1; /* Нельзя опубликовать фиктивный 201-й регистр или чужую страницу из ошибочного provider. */
+ *response = out; /* Передаём вызывающей стороне только ответ, прошедший обе проверки. */
+ } /* Запись возвращает ноль и не изменяет выходной кадр. */
+ return result; /* Отрицательный результат означает отказ без автоматического перехода в регистровый GAS. */
+} /* Конец PERIPH-обёртки. */
diff --git a/c/set-protocol/src/pcan_id.c b/c/set-protocol/src/pcan_id.c
index 82dbcbb..9f2ae4e 100644
--- a/c/set-protocol/src/pcan_id.c
+++ b/c/set-protocol/src/pcan_id.c
@@ -19,9 +19,9 @@ void pcan_id_unpack(uint32_t raw, pcan_id_t *id)
{
raw &= PCAN_ID_MASK;
id->msg_body = (uint16_t)(raw & 0xFFFFU);
- id->msg_type = (uint8_t)((raw >> 16) & 0xFU);
- id->device_id = (uint8_t)((raw >> 20) & 0xFU);
- id->device_type = (uint8_t)((raw >> 24) & 0x7U);
- id->route = (uint8_t)((raw >> 27) & 0x1U);
- id->priority = (uint8_t)((raw >> 28) & 0x1U);
+ id->msg_type = (uint_least8_t)((raw >> 16) & 0xFU); /* Сужает извлечённое поле к переносимому типу октета, включая C28x с 16-битным char. */
+ id->device_id = (uint_least8_t)((raw >> 20) & 0xFU); /* Сужает извлечённое поле к переносимому типу октета, включая C28x с 16-битным char. */
+ id->device_type = (uint_least8_t)((raw >> 24) & 0x7U); /* Сужает извлечённое поле к переносимому типу октета, включая C28x с 16-битным char. */
+ id->route = (uint_least8_t)((raw >> 27) & 0x1U); /* Сужает извлечённое поле к переносимому типу октета, включая C28x с 16-битным char. */
+ id->priority = (uint_least8_t)((raw >> 28) & 0x1U); /* Сужает извлечённое поле к переносимому типу октета, включая C28x с 16-битным char. */
}
diff --git a/c/set-protocol/src/pcan_modbus_server.c b/c/set-protocol/src/pcan_modbus_server.c
index 46081f6..f0107df 100644
--- a/c/set-protocol/src/pcan_modbus_server.c
+++ b/c/set-protocol/src/pcan_modbus_server.c
@@ -12,8 +12,8 @@
static bool is_bank(uint8_t value)
{
- return (value >= (uint8_t)PCAN_MODBUS_COIL) &&
- (value <= (uint8_t)PCAN_MODBUS_INPUT);
+ return (value >= (uint8_t)PCAN_MODBUS_DISCRETE) && /* Диапазон Modbus начинается с 0x5: тип 0x4 занят слотным GAS и не должен вызывать Modbus callbacks. */
+ (value <= (uint8_t)PCAN_MODBUS_INPUT); /* Сохраняем существующие номера HOLDING=0x6 и INPUT=0x7 без сдвига адресации. */
}
bool pcan_modbus_server_init(pcan_modbus_server_t *server,
@@ -78,7 +78,7 @@ pcan_modbus_result_t pcan_modbus_server_handle(pcan_modbus_server_t *server,
if (request->dlc == 0U) {
uint16_t packed = 0U;
- if ((bank == PCAN_MODBUS_COIL) || (bank == PCAN_MODBUS_DISCRETE)) {
+ if (bank == PCAN_MODBUS_DISCRETE) { /* Только тип 0x5 передаёт битовые входы; 0x4 обрабатывается отдельным GAS-кодеком. */
if (count > 16U) {
return reject(server);
}
@@ -115,21 +115,7 @@ pcan_modbus_result_t pcan_modbus_server_handle(pcan_modbus_server_t *server,
(server->write == NULL)) {
return reject(server);
}
- if (bank == PCAN_MODBUS_COIL) {
- uint16_t bits;
- if ((count > 16U) || (request->dlc != 2U)) {
- return reject(server);
- }
- bits = (uint16_t)((uint16_t)request->data[0] |
- ((uint16_t)request->data[1] << 8U));
- for (uint8_t index = 0U; index < count; ++index) {
- if (!server->write(server->user, bank,
- (uint16_t)(address + index),
- (uint16_t)((bits >> index) & 1U))) {
- return reject(server);
- }
- }
- } else {
+ { /* После проверки readonly-банков запись возможна только в HOLDING; битовая запись бывшего 0x4 удалена. */
if ((count > 4U) || (request->dlc != (uint8_t)(count * 2U))) {
return reject(server);
}
diff --git a/c/set-protocol/src/set_gas_watch.c b/c/set-protocol/src/set_gas_watch.c
new file mode 100644
index 0000000..c2d9692
--- /dev/null
+++ b/c/set-protocol/src/set_gas_watch.c
@@ -0,0 +1,155 @@
+/* GAS_WATCH2 отделяет настройку списка, согласованный захват RAM и сериализацию готовой выборки. */
+#include "set_gas_watch.h" /* Подключает общий контракт списка, состояния, выборки и стабильного ABI. */
+#include "set_protocol.h" /* Повторно использует единые endian-преобразования SETProtocol вместо отдельного кодека чисел. */
+#include /* Даёт обнуление собственных структур и сравнение канонического подтверждения. */
+
+static bool config_valid(uint32_t id, uint16_t period, const uint16_t *addresses, size_t count) /* Проверяет модель до чтения адресов или записи в буфер. */
+{ /* Открывает проверку общих ограничений конфигурации. */
+ if (id == 0U || count > SET_GAS_WATCH_MAX || (count > 0U && addresses == NULL) || ((period == 0U) != (count == 0U))) return false; /* Требует ненулевое поколение и однозначную команду stop. */
+ for (size_t i = 0U; i < count; ++i) { /* Проверяет каждый адрес только внутри переданной длины списка. */
+ for (size_t j = 0U; j < i; ++j) { /* Сравнивает адрес с уже проверенными позициями, сохраняя исходный порядок. */
+ if (addresses[i] == addresses[j]) return false; /* Дубли требуют устранения у потребителя, иначе индексный контракт неоднозначен. */
+ } /* Завершает проверку совпадений текущего адреса. */
+ } /* Завершает проверку всех адресов без выделения памяти. */
+ return true; /* Разрешает обработку полностью проверенной модели. */
+} /* Завершает общую проверку ограничений конфигурации. */
+
+static bool config_decode(const uint8_t *payload, size_t size, set_gas_watch_t *out) /* Разбирает заявку во временное состояние, не затрагивая действующее. */
+{ /* Открывает проверку бинарного описания списка. */
+ if (payload == NULL || size < SET_GAS_WATCH_CONFIG_HEADER || size > SET_GAS_WATCH_CONFIG_MAX) return false; /* Проверяет границы до чтения полей заголовка. */
+ if (setp_get_u16(payload) != SET_GAS_WATCH_VERSION || setp_get_u16(payload + 10U) != 0U) return false; /* Версия 2 и нулевой резерв определены контрактом GAS_WATCH2. */
+ memset(out, 0, sizeof(*out)); /* Инициализирует также счётчик выборок, расписание и неиспользуемые адреса. */
+ out->period_ms = setp_get_u16(payload + 2U); /* Читает период в миллисекундах после двухбайтовой версии. */
+ out->config_id = setp_get_u32(payload + 4U); /* Читает 32-битное поколение из байтов 4..7. */
+ out->count = setp_get_u16(payload + 8U); /* Читает число 16-битных GAS-слов перед резервом заголовка. */
+ if (out->count > SET_GAS_WATCH_MAX || size != SET_GAS_WATCH_CONFIG_HEADER + 2U * (size_t)out->count) return false; /* Запрещает усечённые и дополненные пакеты до обхода адресов. */
+ for (size_t i = 0U; i < out->count; ++i) out->address[i] = setp_get_u16(payload + SET_GAS_WATCH_CONFIG_HEADER + 2U * i); /* Декодирует адреса в неизменном порядке, два байта на слово. */
+ return config_valid(out->config_id, out->period_ms, out->address, out->count); /* Применяет одинаковые правила к входящему и исходящему спискам. */
+} /* Завершает разбор временной конфигурации. */
+
+static bool active(const set_gas_watch_t *watch) /* Ограничивает обращения к массиву рамками настроенной активной подписки. */
+{ /* Открывает проверку минимальных инвариантов состояния. */
+ return watch != NULL && watch->config_id != 0U && watch->period_ms != 0U && watch->count > 0U && watch->count <= SET_GAS_WATCH_MAX; /* Остановленная или неинициализированная подписка не выдаёт выборки. */
+} /* Завершает проверку активности. */
+
+void set_gas_watch_init(set_gas_watch_t *watch) /* Подготавливает независимый экземпляр для одного управляющего клиента. */
+{ /* Открывает безопасную инициализацию состояния. */
+ if (watch != NULL) memset(watch, 0, sizeof(*watch)); /* Нулевое поколение обозначает отсутствие применённой конфигурации. */
+} /* Завершает инициализацию экземпляра. */
+
+size_t set_gas_watch_request(uint32_t config_id, uint16_t period_ms, const uint16_t *addresses, size_t count, uint8_t *out, size_t capacity) /* Собирает единственный канонический формат команды и её полного эхо-ответа. */
+{ /* Открывает создание payload конфигурации. */
+ if (!config_valid(config_id, period_ms, addresses, count) || out == NULL || capacity < SET_GAS_WATCH_CONFIG_HEADER + 2U * count) return 0U; /* Проверяет всё до первой записи, включая дубли и размер буфера в байтах. */
+ setp_put_u16(out, SET_GAS_WATCH_VERSION); /* Записывает версию payload 2 независимо от внешнего транспорта. */
+ setp_put_u16(out + 2U, period_ms); /* Кодирует период в миллисекундах в байты 2..3. */
+ setp_put_u32(out + 4U, config_id); /* Кодирует ненулевое поколение в байты 4..7. */
+ setp_put_u16(out + 8U, (uint16_t)count); /* Сохраняет проверенное число слов в двухбайтовом поле. */
+ setp_put_u16(out + 10U, 0U); /* Резерв всегда нулевой, чтобы будущие расширения требовали явного согласования. */
+ for (size_t i = 0U; i < count; ++i) setp_put_u16(out + SET_GAS_WATCH_CONFIG_HEADER + 2U * i, addresses[i]); /* Сериализует по два байта на адрес без перестановки списка. */
+ return SET_GAS_WATCH_CONFIG_HEADER + 2U * count; /* Возвращает фактический размер payload, а не вместимость буфера. */
+} /* Завершает кодирование команды настройки. */
+
+int set_gas_watch_apply(set_gas_watch_t *watch, const pcan_gas_map_t *map, const uint8_t *payload, size_t size) /* Транзакционно заменяет список после всех проверок; вызывающий удерживает общий lock. */
+{ /* Открывает применение новой конфигурации. */
+ set_gas_watch_t proposed; /* Держит весь новый список отдельно от состояния, доступного сборщику. */
+ if (watch == NULL || !config_decode(payload, size, &proposed)) return SET_GAS_WATCH_INVALID; /* Неверный пакет не меняет действующий список и расписание. */
+ if (watch->config_id == proposed.config_id) { /* Обрабатывает повтор команды после потери подтверждения. */
+ if (watch->count != proposed.count || watch->period_ms != proposed.period_ms) return SET_GAS_WATCH_CONFLICT; /* Запрещает изменение содержимого под тем же поколением. */
+ for (size_t i = 0U; i < proposed.count; ++i) if (watch->address[i] != proposed.address[i]) return SET_GAS_WATCH_CONFLICT; /* Сравнивает также порядок адресов, определяющий смысл позиций. */
+ return SET_GAS_WATCH_OK; /* Точный повтор не сбрасывает sequence и срок следующей выборки. */
+ } /* Завершает идемпотентный путь повторного запроса. */
+ if (watch->config_id != 0U && (uint32_t)(proposed.config_id - watch->config_id) >= 0x80000000UL) return SET_GAS_WATCH_CONFLICT; /* Отклоняет старые поколения по модульному порядку с полуинтервалом 2^31. */
+ if (proposed.count > 0U) { /* Команда остановки не зависит от наличия карты или исправности источников. */
+ if (!pcan_gas_map_validate(map)) return SET_GAS_WATCH_INVALID; /* Не допускает двоичный поиск по несортированной, перекрытой или повреждённой карте. */
+ for (size_t i = 0U; i < proposed.count; ++i) { /* Проверяет доступность каждого выбранного слова до замены состояния. */
+ const pcan_gas_region_t *region = pcan_gas_find(map, proposed.address[i]); /* Находит описание региона, не вызывая callback источника. */
+ if (region == NULL) return SET_GAS_WATCH_NO_REG; /* Один неизвестный адрес отменяет всю перенастройку без частичной подписки. */
+ if ((region->flags & PCAN_GAS_WRONLY) != 0U || (region->read == NULL && region->storage == NULL)) return SET_GAS_WATCH_ACCESS; /* Требует разрешённое чтение и реальный обработчик либо RAM. */
+ } /* Завершает проверку полного набора выбранных адресов. */
+ } /* Завершает проверку карты для непустой подписки. */
+ *watch = proposed; /* Фиксирует целую проверенную конфигурацию; от конкурентного доступа защищает lock приложения. */
+ return SET_GAS_WATCH_OK; /* Разрешает отправить полное подтверждение применённого списка. */
+} /* Завершает транзакцию настройки. */
+
+size_t set_gas_watch_encode_ack(const set_gas_watch_t *watch, uint8_t *out, size_t capacity) /* Строит эхо из фактически применённого списка, а не из неподтверждённого запроса. */
+{ /* Открывает кодирование подтверждения. */
+ if (watch == NULL) return 0U; /* Не читает поля отсутствующего экземпляра. */
+ return set_gas_watch_request(watch->config_id, watch->period_ms, watch->address, watch->count, out, capacity); /* Делегирует формат единственному кодировщику конфигурации. */
+} /* Завершает кодирование подтверждения без отдельного wire-формата. */
+
+int set_gas_watch_ack(const uint8_t *payload, size_t size, uint32_t config_id, uint16_t period_ms, const uint16_t *addresses, size_t count) /* Проверяет весь список, а не только количество принятых слов. */
+{ /* Открывает host-проверку подтверждения настройки. */
+ uint8_t expected[SET_GAS_WATCH_CONFIG_MAX]; /* Ограниченный стековый буфер содержит каноническое представление ожидаемой команды. */
+ size_t expected_size = set_gas_watch_request(config_id, period_ms, addresses, count, expected, sizeof(expected)); /* Строит ожидаемый пакет тем же C-кодировщиком. */
+ return payload != NULL && expected_size != 0U && size == expected_size && memcmp(payload, expected, size) == 0; /* Принимает только побайтовое совпадение версии, поколения, периода и всех адресов. */
+} /* Завершает точную проверку эхо-ответа. */
+
+bool set_gas_watch_due(const set_gas_watch_t *watch, uint32_t now_ms) /* Проверяет расписание захвата относительно локального времени MCU. */
+{ /* Открывает неблокирующую проверку таймера. */
+ return active(watch) && (watch->scheduled == 0U || (uint32_t)(now_ms - watch->next_ms) < 0x80000000UL); /* Первая выборка немедленная; полуинтервал 2^31 корректно обрабатывает переполнение u32. */
+} /* Завершает проверку срока без изменения состояния. */
+
+bool set_gas_watch_capture(set_gas_watch_t *watch, const pcan_gas_map_t *map, uint32_t now_ms, set_gas_watch_sample_t *sample) /* Снимает согласованный кэш в памяти под lock, предоставленным приложением. */
+{ /* Открывает захват одной самостоятельной выборки. */
+ set_gas_watch_sample_t captured; /* Временная выборка не позволяет частично изменить выход при неверных аргументах. */
+ if (!active(watch) || sample == NULL || !pcan_gas_map_validate(map)) return false; /* Отклоняет остановленный поток, отсутствующий выход и повреждённую карту. */
+ memset(&captured, 0, sizeof(captured)); /* Ошибочные и неиспользуемые позиции заранее получают нулевые значения и признаки. */
+ captured.config_id = watch->config_id; /* Закрепляет поколение списка вместе с набором значений. */
+ captured.sequence = watch->sequence; /* Первая выборка имеет номер 0; номер увеличивается после успешного захвата структуры. */
+ captured.timestamp_ms = now_ms; /* Фиксирует переданное время начала выборки, не время отправки пакета. */
+ captured.count = watch->count; /* Закрепляет число позиций независимо от последующей перенастройки. */
+ for (size_t i = 0U; i < watch->count; ++i) { /* Читает разрозненные GAS-адреса в согласованном порядке списка. */
+ uint16_t value = 0U; /* Не позволяет неуспешному callback протащить мусор или частичное значение. */
+ if (pcan_gas_read(map, watch->address[i], &value) == PCAN_GAS_OK) { /* Достоверность означает успешное чтение локального источника, не свежесть удалённого измерения. */
+ captured.values[i] = value; /* Копирует прочитанное слово только после подтверждения источником. */
+ captured.valid[i] = 1U; /* Отмечает в модели, что слово пригодно для графика и архива. */
+ } /* При ошибке источник оставляет и значение, и признак нулевыми. */
+ } /* Завершает чтение всех каналов даже при отказе одного из них. */
+ *sample = captured; /* Передаёт неизменяемый по отношению к карте снимок приложениям-потребителям. */
+ watch->sequence++; /* Учитывает каждый захват, в том числе потерянный позднее из-за занятой линии. */
+ watch->next_ms = now_ms + watch->period_ms; /* Планирует следующий захват от текущего времени без пачки догоняющих выборок. */
+ watch->scheduled = 1U; /* Включает проверку next_ms после первой выборки. */
+ return true; /* Готовая выборка доступна независимо от ошибок отдельных регистров. */
+} /* Завершает захват; транспорт вызывается приложением после снятия lock. */
+
+size_t set_gas_watch_encode_sample(const set_gas_watch_sample_t *sample, uint8_t *out, size_t capacity) /* Упаковывает сохранённую выборку без повторных обращений к источникам. */
+{ /* Открывает сериализацию пакета значений. */
+ if (sample == NULL || out == NULL || sample->config_id == 0U || sample->count == 0U || sample->count > SET_GAS_WATCH_MAX) return 0U; /* Проверяет модель до вычисления размера и обращения к массивам. */
+ size_t mask_size = ((size_t)sample->count + 7U) / 8U; /* Округляет число флагов до байтов, восемь каналов на один байт маски. */
+ size_t values_offset = SET_GAS_WATCH_SAMPLE_HEADER + mask_size; /* Значения идут сразу после переменной по длине маски достоверности. */
+ size_t total = values_offset + 2U * sample->count; /* Каждое значение занимает два байта без зависимости от выравнивания структур. */
+ if (capacity < total) return 0U; /* Недостаточный выходной буфер остаётся полностью неизменным. */
+ for (size_t i = 0U; i < sample->count; ++i) if (sample->valid[i] > 1U) return 0U; /* Запрещает неканонические признаки до записи заголовка. */
+ setp_put_u16(out, SET_GAS_WATCH_VERSION); /* Сохраняет версию payload 2 в первых двух байтах. */
+ setp_put_u16(out + 2U, sample->count); /* Помещает число слов перед поколением конфигурации. */
+ setp_put_u32(out + 4U, sample->config_id); /* Позволяет клиенту отклонить пакет предыдущего списка той же длины. */
+ setp_put_u32(out + 8U, sample->sequence); /* Номер захвата занимает байты 8..11 и передаётся без знакового преобразования. */
+ setp_put_u32(out + 12U, sample->timestamp_ms); /* Время устройства в миллисекундах занимает байты 12..15. */
+ memset(out + SET_GAS_WATCH_SAMPLE_HEADER, 0, mask_size); /* Обнуляет также свободные старшие биты последнего байта маски. */
+ for (size_t i = 0U; i < sample->count; ++i) { /* Заполняет значения и соответствующие им биты одной позиции списка. */
+ if (sample->valid[i] != 0U) out[SET_GAS_WATCH_SAMPLE_HEADER + i / 8U] |= (uint8_t)(1U << (i % 8U)); /* Младший бит байта соответствует первому из восьми каналов. */
+ setp_put_u16(out + values_offset + 2U * i, sample->valid[i] != 0U ? sample->values[i] : 0U); /* Недостоверный канал всегда кодируется нулём независимо от содержимого модели. */
+ } /* Завершает построение канонического payload выборки. */
+ return total; /* Возвращает число готовых к передаче байтов без внешнего кадрирования. */
+} /* Завершает сериализацию самостоятельной выборки. */
+
+int set_gas_watch_decode(const uint8_t *payload, size_t size, uint32_t expected_config_id, size_t expected_count, uint32_t *sequence, uint32_t *timestamp_ms, uint16_t *values, uint8_t *valid, size_t capacity) /* Проверяет полный пакет прежде, чем открыть его данным графика или архива. */
+{ /* Открывает стабильный host-декодер payload выборки. */
+ if (payload == NULL || size < SET_GAS_WATCH_SAMPLE_HEADER || size > SET_GAS_WATCH_SAMPLE_MAX || expected_config_id == 0U || expected_count == 0U || expected_count > SET_GAS_WATCH_MAX) return -1; /* Исключает неверные границы и отсутствие активной конфигурации до чтения payload. */
+ size_t count = setp_get_u16(payload + 2U); /* Читает число слов из проверенного фиксированного заголовка. */
+ if (setp_get_u16(payload) != SET_GAS_WATCH_VERSION || setp_get_u32(payload + 4U) != expected_config_id || count != expected_count || count > capacity || values == NULL || valid == NULL) return -1; /* Отбрасывает устаревшую конфигурацию и неподходящие выходные массивы. */
+ size_t mask_size = (count + 7U) / 8U; /* Вычисляет единственный допустимый размер маски для ожидаемого числа слов. */
+ size_t values_offset = SET_GAS_WATCH_SAMPLE_HEADER + mask_size; /* Находит начало значений после bitmap в байтах. */
+ if (size != values_offset + 2U * count) return -1; /* Не принимает ни усечённый пакет, ни добавленные после значений байты. */
+ if (count % 8U != 0U && (payload[values_offset - 1U] >> (count % 8U)) != 0U) return -1; /* Требует нулевые неиспользуемые старшие биты маски последнего байта. */
+ for (size_t i = 0U; i < count; ++i) { /* Сначала проверяет каноничность всех каналов, ничего не публикуя вызывающему. */
+ if ((payload[SET_GAS_WATCH_SAMPLE_HEADER + i / 8U] & (1U << (i % 8U))) == 0U && setp_get_u16(payload + values_offset + 2U * i) != 0U) return -1; /* Ошибочный канал обязан содержать нулевое слово, иначе пакет отвергается целиком. */
+ } /* Завершает полную проверку до первой записи в выходные аргументы. */
+ for (size_t i = 0U; i < count; ++i) { /* Публикует проверенные значения и их достоверность в одинаковом порядке. */
+ values[i] = setp_get_u16(payload + values_offset + 2U * i); /* Копирует сырое 16-битное слово без изменения знаковости. */
+ valid[i] = (uint8_t)((payload[SET_GAS_WATCH_SAMPLE_HEADER + i / 8U] >> (i % 8U)) & 1U); /* Раскрывает один бит в 0 или 1 для простого ctypes/JNI ABI. */
+ } /* Завершает публикацию массивов после успешной проверки пакета. */
+ if (sequence != NULL) *sequence = setp_get_u32(payload + 8U); /* Возвращает номер захвата, если клиент запросил метаданные. */
+ if (timestamp_ms != NULL) *timestamp_ms = setp_get_u32(payload + 12U); /* Возвращает приборное время без привязки к часам компьютера. */
+ return (int)count; /* Положительный результат сообщает точное число достоверно разобранных позиций, включая помеченные ошибкой. */
+} /* Завершает единый C-декодер для всех языковых портов. */
diff --git a/c/set-protocol/tests/fixtures/gas-watch-v2.json b/c/set-protocol/tests/fixtures/gas-watch-v2.json
new file mode 100644
index 0000000..014e37f
--- /dev/null
+++ b/c/set-protocol/tests/fixtures/gas-watch-v2.json
@@ -0,0 +1,13 @@
+{
+ "description": "Общие эталонные байты GAS_WATCH2 для C, Python и JVM; порядок адресов сохраняет позиции значений.",
+ "version": 2,
+ "config_id": 305419896,
+ "period_ms": 100,
+ "addresses": [0, 1, 65281],
+ "request_hex": "0200640078563412030000000000010001ff",
+ "sequence": 4294967295,
+ "timestamp_ms": 4294967280,
+ "values": [123, 65534, 0],
+ "valid": [1, 1, 0],
+ "sample_hex": "0200030078563412fffffffff0ffffff037b00feff0000"
+}
diff --git a/c/set-protocol/tests/test_gas_register.c b/c/set-protocol/tests/test_gas_register.c
new file mode 100644
index 0000000..c81fa6f
--- /dev/null
+++ b/c/set-protocol/tests/test_gas_register.c
@@ -0,0 +1,29 @@
+#include "pcan_gas.h" /* Проверяем фактическую карту отдельных регистров GAS типа 0x3. */
+#include "pcan_id.h" /* Собираем адресуемые запросы общим кодеком идентификатора. */
+#include /* Release-сборка сохраняет проверки и сообщения ошибок. */
+#define CHECK(x) do { if (!(x)) { printf("gas_register:%d: %s\n", __LINE__, #x); return 1; } } while (0) /* Любое нарушение возвращает ненулевой код теста. */
+int main(void) /* Проверяем точечную запись, все допустимые длины и границу полного пространства. */
+{ /* Массивы карты принадлежат тестовому приложению и не требуют выделения всей памяти 0…FFFF. */
+ uint16_t low[8] = {100U, 200U, 300U, 400U, 500U, 600U, 700U, 800U}, high[2] = {11U, 22U}; /* Отличающиеся соседние слова обнаруживают лишнюю запись или переход через FFFF. */
+ pcan_gas_region_t regions[] = {{0U, 8U, low, NULL, NULL, 0U, NULL, NULL}, {0xFFFEU, 2U, high, NULL, NULL, 0U, NULL, NULL}}; /* Два небольших региона покрывают начало и конец 16-битного пространства. */
+ pcan_gas_map_t map = {regions, 2U}; pcan_frame_t request = {0}, response; pcan_id_t id = {1U, PCAN_MSG_GAS, 5U, 1U, PCAN_ROUTE_FROM_PM, PCAN_PRIORITY_STANDARD}; unsigned count, i; /* Запрос адресует второе слово без слота или дополнительного индекса. */
+ CHECK(pcan_gas_map_validate(&map)); request.id = pcan_id_pack(&id); request.flags = PCAN_FLAG_IDE; /* Проверяем карту и формируем обычный extended data frame. */
+ request.dlc = 2U; request.data[0] = 0x34U; request.data[1] = 0x12U; CHECK(!pcan_gas_handle(&map, &request, &response)); /* Два октета записывают ровно один регистр и не порождают несуществующий ACK. */
+ CHECK(low[0] == 100U && low[1] == 0x1234U && low[2] == 300U && low[3] == 400U); /* Соседние уставки остаются прежними без предварительного чтения на клиенте. */
+ for (count = 1U; count <= 4U; ++count) { /* Все четыре допустимые длины должны изменять только заданный последовательный диапазон. */
+ for (i = 0U; i < 8U; ++i) low[i] = (uint16_t)(100U + i); /* Возвращаем различимые исходные значения перед каждой длиной записи. */
+ for (i = 0U; i < count; ++i) { request.data[2U * i] = (uint8_t)(10U + i); request.data[2U * i + 1U] = 0U; } /* Значения 10…13 не совпадают с фоновыми данными карты. */
+ request.dlc = (uint8_t)(count * 2U); CHECK(!pcan_gas_handle(&map, &request, &response)); /* DLC определяет один, два, три или четыре слова начиная с MsgBody. */
+ for (i = 0U; i < 8U; ++i) CHECK(low[i] == ((i >= 1U && i <= count) ? 9U + i : 100U + i)); /* Любая запись вне выбранного диапазона является ошибкой. */
+ } /* Завершаем проверку допустимых коротких и полных payload. */
+ for (i = 1U; i <= 9U; i += 2U) { request.dlc = (uint8_t)i; CHECK(!pcan_gas_handle(&map, &request, &response)); CHECK(low[1] == 10U); } /* Нечётный или превышающий восемь октетов payload не записывает даже первый полный регистр. */
+ request.dlc = 2U; id.msg_type = PCAN_MSG_GAS_SLOT; request.id = pcan_id_pack(&id); CHECK(!pcan_gas_handle(&map, &request, &response)); CHECK(low[1] == 10U); /* Тип 0x4 изолирован от адресации отдельных регистров. */
+ id.msg_type = PCAN_MSG_GAS; id.route = PCAN_ROUTE_FROM_DEVICE; request.id = pcan_id_pack(&id); CHECK(!pcan_gas_handle(&map, &request, &response)); CHECK(low[1] == 10U); /* Ответ прибора не исполняется повторно как запись. */
+ id.route = PCAN_ROUTE_FROM_PM; id.msg_body = 0xFFFFU; request.id = pcan_id_pack(&id); request.data[0] = 0x78U; request.data[1] = 0x56U; /* Выбираем максимальный адрес без потери старших битов. */
+ CHECK(!pcan_gas_handle(&map, &request, &response)); CHECK(high[0] == 11U && high[1] == 0x5678U); /* Одно слово по адресу FFFF допустимо и не меняет предыдущий регистр. */
+ request.dlc = 4U; request.data[0] = 1U; CHECK(!pcan_gas_handle(&map, &request, &response)); CHECK(high[1] == 0x5678U && low[0] == 100U); /* Блок через верхнюю границу отвергается целиком, не оборачиваясь к адресу ноль. */
+ request.dlc = 0U; CHECK(pcan_gas_handle(&map, &request, &response)); CHECK(response.dlc == 2U && response.data[0] == 0x78U && response.data[1] == 0x56U); /* Чтение максимального адреса возвращает одно существующее слово. */
+ request.dlc = 2U; for (i = 0U; i <= 8U; i += 2U) { request.flags = (uint8_t)(PCAN_FLAG_IDE | i); if (i == 0U || i == 4U || i == 6U) continue; CHECK(!pcan_gas_handle(&map, &request, &response)); CHECK(high[1] == 0x5678U); } /* RTR и ERR не вызывают запись карты. */
+ request.flags = 0U; CHECK(!pcan_gas_handle(&map, &request, &response)); CHECK(!pcan_gas_handle(&map, NULL, &response)); /* Стандартный ID и отсутствующий запрос безопасно отвергаются. */
+ puts("GAS register addressing: OK"); return 0; /* Подтверждаем успешную проверку всех контрактов отдельного регистра. */
+} /* Конец теста безслотового GAS. */
diff --git a/c/set-protocol/tests/test_gas_slot.c b/c/set-protocol/tests/test_gas_slot.c
new file mode 100644
index 0000000..819d9b9
--- /dev/null
+++ b/c/set-protocol/tests/test_gas_slot.c
@@ -0,0 +1,61 @@
+#include "pcan_gas_slot.h" /* Проверяем реальный публичный кодек и MCU-обработчик слотного GAS. */
+#include "pcan_id.h" /* Независимо проверяем разделение типов 0x3 и 0x4. */
+#include /* Диагностика CHECK работает также в release-сборке без assert. */
+#include /* Сравниваем точные октеты и сохранность невыбранных значений. */
+#define CHECK(x) do { if (!(x)) { printf("gas_slot:%d: %s\n", __LINE__, #x); return 1; } } while (0) /* Ненулевой код делает любое нарушение видимым сборщику тестов. */
+static unsigned reads, writes; /* Счётчики обнаруживают вызов provider для отвергнутого кадра. */
+static uint16_t state[3] = {0x1234U, 0xABCDU, 0xFFFFU}; /* Три независимых значения позволяют проверить, что частичная запись сохраняет соседей. */
+static int read_slot(void *context, uint16_t address, uint16_t words[4]) /* Provider реализует данные, не повторяя формат CAN ID или endian. */
+{ /* Чтение возвращает Word0 с маской достоверности и три фиксированные позиции. */
+ unsigned i; (void)context; ++reads; if (address != 0x0500U && address != 0xFFFFU && address != 0x0542U) return 1; /* Только три адреса существуют в тестовом источнике. */
+ words[0] = address == 0x0542U ? 0x0006U : PCAN_GAS_SLOT_MASK; /* Последний слот PERIPH содержит два действующих значения, общий адрес FFFF содержит три. */
+ for (i = 0U; i < 3U; ++i) words[i + 1U] = state[i]; /* Служебная маска не подменяет первое хранимое значение. */
+ return 0; /* Успех сообщается после заполнения всех wire-позиций. */
+} /* Конец источника чтения. */
+static int write_slot(void *context, uint16_t address, const uint16_t words[4]) /* Проверяем рекомендуемый контракт provider с общей предварительной валидацией. */
+{ /* В реальном устройстве обе фазы выполняются под одной блокировкой данных приложения. */
+ unsigned i; (void)context; if (address != 0x0500U && address != 0xFFFFU) return 1; /* Несуществующий адрес не изменяет состояние. */
+ for (i = 1U; i < 4U; ++i) if ((words[0] & (1U << i)) != 0U && words[i] == 0xDEADU) return 1; /* Отвергаем весь набор до первого изменения выбранных слов. */
+ for (i = 1U; i < 4U; ++i) if ((words[0] & (1U << i)) != 0U) state[i - 1U] = words[i]; /* Меняем только отмеченные позиции; остальные остаются текущими значениями устройства. */
+ ++writes; return 0; /* Считаем только полностью проверенную и применённую команду. */
+} /* Конец тестового provider записи. */
+int main(void) /* Охватываем wire-контракт, изоляцию типов, масочную запись и все границы адресации. */
+{ /* Все буферы ограничены размером одного classic CAN кадра. */
+ unsigned mode, local, i, mask; uint16_t native, words[4] = {0x000EU, 0x1234U, 0xABCDU, 0xFFFFU}, decoded[4]; /* Маска занимает нулевое слово, а не младшие биты адреса. */
+ uint_least8_t data[8], expected[8] = {0x0E, 0, 0x34, 0x12, 0xCD, 0xAB, 0xFF, 0xFF}; /* Независимый эталон показывает порядок маски и трёх значений. */
+ pcan_gas_slot_port_t port = {NULL, read_slot, write_slot}; pcan_gas_slot_frame_t request = {0}, response; /* Используем тот же входной API, который вызывает прошивка устройства. */
+ CHECK(pcan_gas_slot_request_id(5, 0, 0) == 0x11540500UL); CHECK(pcan_gas_slot_request_id(6, 0, 0) == 0x11640600UL); /* В ID заменён только MsgType, адреса узлов PERIPH сохранены. */
+ CHECK(pcan_gas_slot_request_id(7, 0, 0) == 0x11740700UL); CHECK(pcan_gas_slot_request_id(8, 0, 0) == 0x11840800UL); /* Проверяем ранее опубликованные адреса соседних mode. */
+ CHECK(pcan_gas_slot_request_id(16, 0, 0) == 0x12041000UL); CHECK(pcan_gas_slot_request_id(31, 66, 0) == 0x12F41F42UL); /* Проверяем смену DeviceType и неполный последний слот страницы. */
+ CHECK(pcan_gas_slot_address(32, 0) == -1); CHECK(pcan_gas_slot_address(5, 67) == -1); CHECK(pcan_gas_slot_request_id(5, 0, 2) == 0U); /* Резерв, чужой mode и неверный Route не маскируются. */
+ CHECK(pcan_gas_slot_encode(words, 4, data) == 8); CHECK(memcmp(data, expected, sizeof(data)) == 0); /* Сопоставляем все восемь wire-октетов, включая маску. */
+ CHECK(pcan_gas_slot_decode(0x19540500UL, 1, data, 8, &mode, &local, decoded) == 4); CHECK(mode == 5 && local == 0 && memcmp(words, decoded, sizeof(words)) == 0); /* Обратное преобразование сохраняет маску и значения. */
+ CHECK(pcan_gas_slot_encode(words, 3, data) == -1); CHECK(pcan_gas_slot_encode(NULL, 0, NULL) == 0); /* Частичная запись имеет полный payload с маской; чтение остаётся пустым. */
+ for (i = 1U; i < 8U; ++i) CHECK(pcan_gas_slot_decode_words(0x11540500UL, 1, data, i, decoded) == -1); /* Ни одна усечённая длина не обращается к provider. */
+ for (i = 0U; i < 4U; ++i) { /* Проверяем пустую маску, бит нулевого слова и зарезервированные старшие биты. */
+ uint16_t invalid[] = {0U, 1U, 0x10U, 0xFFFFU}; words[0] = invalid[i]; CHECK(pcan_gas_slot_encode(words, 4, data) == -1); /* Недопустимый набор отвергается до изменения выходного буфера. */
+ } /* Завершаем проверку каноничности Word0. */
+ words[0] = PCAN_GAS_SLOT_MASK; CHECK(pcan_gas_slot_encode(words, 4, data) == 8); /* Восстанавливаем корректный полный набор для последующих проверок. */
+ CHECK(pcan_gas_slot_decode_words(0x19530500UL, 1, data, 8, decoded) == -1); /* Тип 0x3 никогда не трактуется как слотный даже при подходящих первых байтах. */
+ CHECK(pcan_gas_slot_decode(0x19540600UL, 1, data, 8, &mode, &local, decoded) == -1); /* Устройство mode 5 не обслуживает страницу mode 6. */
+ CHECK(pcan_gas_slot_decode(0x19540542UL, 1, data, 8, &mode, &local, decoded) == -1); /* Третье значение последнего слота не существует и не принимается через маску. */
+ words[0] = 0x0006U; CHECK(pcan_gas_slot_encode(words, 4, data) == 8); CHECK(pcan_gas_slot_decode(0x19540542UL, 1, data, 8, &mode, &local, decoded) == 4); /* Два действующих значения последнего слота сохраняют допустимость. */
+ request.id = 0x11540500UL; request.flags = PCAN_GAS_SLOT_FLAG_IDE; CHECK(pcan_gas_slot_handle(5, &port, &request, &response) == 1); /* Чтение не содержит фиктивной маски в запросе. */
+ CHECK(response.id == 0x19540500UL && response.dlc == 8U && reads == 1U); CHECK(memcmp(response.data, expected, sizeof(expected)) == 0); /* Ответ содержит маску достоверности и исходные три значения. */
+ for (mask = 2U; mask <= PCAN_GAS_SLOT_MASK; mask += 2U) { /* Все семь непустых комбинаций трёх битов должны сохранять невыбранные позиции. */
+ unsigned before = writes; state[0] = 10U; state[1] = 20U; state[2] = 30U; words[0] = (uint16_t)mask; words[1] = 101U; words[2] = 202U; words[3] = 303U; /* Отличающиеся старые и новые значения обнаруживают лишнюю запись соседей. */
+ request.dlc = 8U; CHECK(pcan_gas_slot_encode(words, 4, request.data) == 8); CHECK(pcan_gas_slot_handle(5, &port, &request, &response) == 0); /* Одна команда вызывает provider один раз без предварительного чтения слота на хосте. */
+ CHECK(writes == before + 1U); CHECK(state[0] == ((mask & 2U) ? 101U : 10U)); CHECK(state[1] == ((mask & 4U) ? 202U : 20U)); CHECK(state[2] == ((mask & 8U) ? 303U : 30U)); /* Проверяем каждую позицию отдельно для всех масок. */
+ } /* Конец исчерпывающей проверки выбора трёх значений. */
+ words[0] = 6U; words[1] = 7U; words[2] = 0xDEADU; CHECK(pcan_gas_slot_encode(words, 4, request.data) == 8); CHECK(pcan_gas_slot_handle(5, &port, &request, &response) == -1); CHECK(state[0] == 101U && state[1] == 202U); /* Отказ второго выбранного значения не должен менять первое. */
+ request.flags = 3U; CHECK(pcan_gas_slot_handle(5, &port, &request, &response) == -1); request.flags = 0U; CHECK(pcan_gas_slot_handle(5, &port, &request, &response) == -1); /* RTR и стандартный ID отвергаются до записи. */
+ request.flags = 1U; request.id = 0x19540500UL; CHECK(pcan_gas_slot_handle(5, &port, &request, &response) == -1); /* Ответ прибора не исполняется как команда мастера. */
+ CHECK(pcan_gas_slot_id(7, 14, 65535U, 0) == 0x17E4FFFFUL); CHECK(pcan_gas_slot_id(7, 14, 65536UL, 0) == 0U); /* Общий слотный API сохраняет адрес FFFF и отвергает выход за 16 бит. */
+ request.id = 0x17E4FFFFUL; request.dlc = 0U; CHECK(pcan_gas_slot_handle_address(7, 14, &port, &request, &response) == 1); CHECK(response.id == 0x1FE4FFFFUL); /* Отдельный инвертор не привязан к геометрии PERIPH. */
+ CHECK(pcan_gas_slot_handle_address(7, 13, &port, &request, &response) == -1); /* Соседний узел не обслуживает чужой запрос. */
+ CHECK(pcan_gas_slot_native(42, 2, 0, &native) == 1 && native == 127U); CHECK(pcan_gas_slot_native(42, 3, 0, &native) == 1 && native == 0x1000U); /* Граница технологических слов проходит внутри слота и не меняет native-адреса. */
+ CHECK(pcan_gas_slot_native(48, 1, 0, &native) == 1 && native == 0x1200U); CHECK(pcan_gas_slot_native(52, 3, 1, &native) == 1 && native == 0x1302U); /* Паспорт и разрешённое поле защиты сохраняют исходное назначение. */
+ CHECK(pcan_gas_slot_native(42, 3, 1, &native) == 0); CHECK(pcan_gas_slot_native(52, 1, 1, &native) == 0); CHECK(pcan_gas_slot_native(0, 0, 0, &native) == 0); /* Readonly-слова и служебная маска не записываются как технологические данные. */
+ CHECK(pcan_gas_slot_native(56, 1, 0, &native) == 0); CHECK(pcan_gas_slot_native(66, 3, 0, &native) == 0); /* Резерв и лишняя 201-я позиция не становятся фиктивными регистрами. */
+ puts("GAS SLOT masks: OK"); return 0; /* Успех означает прохождение всех позитивных, отрицательных и граничных случаев. */
+} /* Конец регрессионного теста масочного слота. */
diff --git a/c/set-protocol/tests/test_gas_watch.c b/c/set-protocol/tests/test_gas_watch.c
new file mode 100644
index 0000000..ed2d8d2
--- /dev/null
+++ b/c/set-protocol/tests/test_gas_watch.c
@@ -0,0 +1,370 @@
+/* Контракт GAS_WATCH2: атомарная подписка, самостоятельный снимок и проверка входящих пакетов. */
+#include /* Целочисленные типы фиксируют размеры полей и переполнение счётчиков протокола. */
+#include /* Диагностика сообщает конкретное нарушенное условие и строку теста. */
+#include /* Побайтовые сравнения проверяют эталонный формат и отсутствие частичных изменений. */
+#include "set_gas_watch.h" /* Проверяем только публичный интерфейс общего ядра и его хостовые функции. */
+
+static unsigned failures; /* Число нарушений накапливается, чтобы один запуск показал все независимые ошибки. */
+static void check(int condition, const char *expression, int line) /* Обёртка сохраняет проверки даже при определённом NDEBUG. */
+{ /* Начало общей диагностики проверяемого условия. */
+ if (!condition) { /* Записываем только нарушения, чтобы успешный прогон оставался коротким. */
+ printf("FAIL gas_watch:%d: %s\n", line, expression); /* Исходная строка позволяет быстро найти нарушенный контракт. */
+ ++failures; /* Каждое отдельное нарушение делает итоговый код возврата ненулевым. */
+ } /* Завершение обработки одного нарушения. */
+} /* Завершение функции диагностики. */
+#define CHECK(condition) check((condition), #condition, __LINE__) /* Макрос сохраняет выражение и его исходную строку без зависимости от assert. */
+
+typedef struct { /* Состояние обработчика позволяет отличить проверку карты от реального чтения. */
+ unsigned calls; /* Счётчик фиксирует любое обращение к провайдеру регистра. */
+ uint16_t rejected_offset; /* Смещение 0xFFFF означает, что ни один из двух тестовых регистров не отвергается. */
+ uint16_t base_value; /* Начальное значение добавляется к смещению, сохраняя различимость соседних каналов. */
+} read_context_t; /* Один экземпляр контекста обслуживает вычисляемый регион карты. */
+
+static pcan_gas_status_t read_value(const pcan_gas_region_t *region, uint16_t offset, uint16_t *value) /* Подменяем приборный провайдер воспроизводимым обработчиком. */
+{ /* Начало имитации чтения одного 16-битного регистра. */
+ read_context_t *context = (read_context_t *)region->user; /* Контекст берём из публичного пользовательского поля GAS-региона. */
+ ++context->calls; /* Считаем в том числе неудачные чтения, чтобы проверить продолжение после ошибки. */
+ *value = (uint16_t)(context->base_value + offset); /* Записываем ненулевой результат и при отказе: ядро обязано его обнулить. */
+ return offset == context->rejected_offset ? PCAN_GAS_REJECTED : PCAN_GAS_OK; /* Выбранное смещение имитирует недостоверное значение устройства. */
+} /* Завершение подмены чтения. */
+
+static pcan_gas_status_t write_value(const pcan_gas_region_t *region, uint16_t offset, uint16_t value) /* Провайдер записи создаёт корректный регион без возможности чтения. */
+{ /* Начало обработчика, используемого только для описания карты. */
+ (void)region; /* Тесту достаточно наличия обработчика: состояние региона не изменяем. */
+ (void)offset; /* Адрес записи не нужен, потому что операции записи здесь не выполняются. */
+ (void)value; /* Значение записи не используется в проверках подписки только на чтение. */
+ return PCAN_GAS_OK; /* Наличие рабочего обработчика записи делает регион допустимым для общей карты GAS. */
+} /* Завершение провайдера записи. */
+
+static uint16_t storage[] = {123U, 65534U, 456U, 789U}; /* Значения включают отрицательное INT16 в исходном представлении 0xFFFE. */
+static read_context_t context; /* Общий контекст даёт доступ к счётчику обращений из разных сценариев. */
+static const pcan_gas_region_t regions[] = { /* Разрозненные адреса повторяют реальное сочетание памяти и вычисляемых регистров. */
+ {0x0000U, 4U, storage, NULL, NULL, PCAN_GAS_RDONLY, NULL, "memory"}, /* Первые четыре адреса доступны напрямую, без обработчиков. */
+ {0x0100U, 2U, NULL, read_value, NULL, PCAN_GAS_RDONLY, &context, "provider"}, /* Два вычисляемых канала позволяют отказать первому и успешно прочитать следующий. */
+ {0x0200U, 1U, storage, NULL, NULL, PCAN_GAS_WRONLY, NULL, "write-only"}, /* Явный запрет чтения должен отвергаться до сбора выборки. */
+ {0x0300U, 1U, NULL, NULL, write_value, 0U, NULL, "writer"}, /* Отсутствие провайдера чтения должно отвергаться и без флага WRONLY. */
+ {0xFF01U, 1U, storage + 2U, NULL, NULL, PCAN_GAS_RDONLY, NULL, "uptime"} /* Высокий адрес проверяет отсутствие требований к непрерывности списка. */
+}; /* Конец отсортированной карты без пересечений. */
+static const pcan_gas_map_t map = {regions, 5U}; /* Пять регионов образуют валидную карту для основных сценариев. */
+static const uint16_t reference_addresses[] = {0x0000U, 0x0001U, 0xFF01U}; /* Порядок адресов совпадает с общим JSON-эталоном портов. */
+static const uint8_t reference_config[] = { /* Эталон соответствует tests/fixtures/gas-watch-v2.json и little-endian полям. */
+ 0x02U, 0x00U, 0x64U, 0x00U, 0x78U, 0x56U, 0x34U, 0x12U, 0x03U, 0x00U, 0x00U, 0x00U, /* Версия 2, период 100 мс, идентификатор 0x12345678, три адреса и нулевой резерв. */
+ 0x00U, 0x00U, 0x01U, 0x00U, 0x01U, 0xFFU /* Адреса 0x0000, 0x0001 и 0xFF01 сохраняют исходный порядок запроса. */
+}; /* Конец эталона запроса, который одновременно является точным ACK. */
+static const uint8_t reference_sample[] = { /* Эталон фиксирует полный диапазон беззнаковых счётчиков и недостоверный третий канал. */
+ 0x02U, 0x00U, 0x03U, 0x00U, 0x78U, 0x56U, 0x34U, 0x12U, /* Версия 2, три значения и идентификатор 0x12345678. */
+ 0xFFU, 0xFFU, 0xFFU, 0xFFU, 0xF0U, 0xFFU, 0xFFU, 0xFFU, /* Номер 0xFFFFFFFF и приборное время 0xFFFFFFF0 мс сохраняются без знакового искажения. */
+ 0x03U, 0x7BU, 0x00U, 0xFEU, 0xFFU, 0x00U, 0x00U /* Биты 0 и 1 отмечают достоверность значений 123 и 65534; недостоверное значение передаётся нулём. */
+}; /* Конец эталонной выборки длиной 23 байта. */
+
+static void reject_unchanged(set_gas_watch_t *watch, const pcan_gas_map_t *active_map, const uint8_t *payload, size_t size, int expected) /* Общая проверка атомарного отказа на стороне устройства. */
+{ /* Начало вспомогательного сценария ошибочного применения конфигурации. */
+ set_gas_watch_t before = *watch; /* Сохраняем весь объект, включая адреса, счётчики и срок очередной выборки. */
+ unsigned calls = context.calls; /* Проверка доступности регистров не должна запускать приборные чтения. */
+ CHECK(set_gas_watch_apply(watch, active_map, payload, size) == expected); /* Код ошибки отличает формат, отсутствующий адрес, доступ и конфликт идентификатора. */
+ CHECK(memcmp(watch, &before, sizeof before) == 0); /* Ошибка любого адреса оставляет подписку целиком прежней. */
+ CHECK(context.calls == calls); /* Отклонение запроса не вызывает побочных действий провайдера. */
+} /* Завершение атомарной проверки отказа. */
+
+static void test_reference(void) /* Проверяем точные байты обеих сторон контракта на общей эталонной конфигурации. */
+{ /* Начало сценария совместимости wire-формата. */
+ set_gas_watch_t watch; /* Устройство хранит подписку в памяти вызывающего приложения. */
+ set_gas_watch_sample_t sample; /* Выборка хранится отдельно и не зависит от последующей перенастройки. */
+ uint8_t output[SET_GAS_WATCH_SAMPLE_MAX]; /* Размер обеспечивает место для максимально разрешённого пакета. */
+ uint16_t values[3] = {0U, 0U, 0U}; /* Декодер получает ровно три ячейки для значений эталона. */
+ uint8_t valid[3] = {0U, 0U, 0U}; /* Признаки достоверности возвращаются отдельно от значений. */
+ uint32_t sequence = 0U; /* Выходной счётчик должен принять максимальное беззнаковое значение. */
+ uint32_t timestamp = 0U; /* Выходная отметка должна сохранить приборные миллисекунды. */
+ set_gas_watch_init(&watch); /* Пустое состояние явно инициализируется до первого применения. */
+ CHECK(!set_gas_watch_due(&watch, 0U)); /* До настройки устройство не считает сбор активным. */
+ CHECK(set_gas_watch_request(0x12345678UL, 100U, reference_addresses, 3U, output, sizeof output) == sizeof reference_config); /* Запрос содержит ровно заголовок 12 байт и три 16-битных адреса. */
+ CHECK(memcmp(output, reference_config, sizeof reference_config) == 0); /* Сравнение фиксирует порядок байтов, резерв и порядок адресов. */
+ CHECK(set_gas_watch_apply(&watch, &map, output, sizeof reference_config) == SET_GAS_WATCH_OK); /* Полный валидный список принимается одной транзакцией. */
+ CHECK(set_gas_watch_encode_ack(&watch, output, sizeof output) == sizeof reference_config); /* Подтверждение возвращает весь принятый список. */
+ CHECK(memcmp(output, reference_config, sizeof reference_config) == 0); /* ACK подтверждает адреса точно, а не только совпадение числа каналов. */
+ CHECK(set_gas_watch_ack(output, sizeof reference_config, 0x12345678UL, 100U, reference_addresses, 3U) == 1); /* Хост принимает точное подтверждение текущего запроса. */
+ CHECK(set_gas_watch_ack(output, sizeof reference_config, 0x12345679UL, 100U, reference_addresses, 3U) == 0); /* Подтверждение предыдущей конфигурации не подтверждает новую. */
+ CHECK(set_gas_watch_ack(output, sizeof reference_config, 0x12345678UL, 101U, reference_addresses, 3U) == 0); /* Подтверждённый период тоже является частью конфигурации. */
+ const uint16_t reordered[] = {0x0001U, 0x0000U, 0xFF01U}; /* Перестановка сохраняет размер и состав множества, но меняет смысл позиций. */
+ CHECK(set_gas_watch_ack(output, sizeof reference_config, 0x12345678UL, 100U, reordered, 3U) == 0); /* ACK обязан подтверждать порядок списка, а не только его множество. */
+ CHECK(set_gas_watch_ack(NULL, sizeof reference_config, 0x12345678UL, 100U, reference_addresses, 3U) == 0); /* Нулевой вход ACK безопасно отвергается до сравнения содержимого. */
+ CHECK(set_gas_watch_ack(output, sizeof reference_config - 1U, 0x12345678UL, 100U, reference_addresses, 3U) == 0); /* Усечённое подтверждение не подтверждает даже совпадающий префикс. */
+ memset(&sample, 0, sizeof sample); /* Обнуляем запасные каналы, чтобы эталон не зависел от содержимого стека. */
+ sample.config_id = 0x12345678UL; /* Идентификатор связывает выборку с конкретным списком адресов. */
+ sample.sequence = UINT32_MAX; /* Максимальный номер проверяет сохранение всех 32 бит. */
+ sample.timestamp_ms = 0xFFFFFFF0UL; /* Время в 16 мс до переполнения проверяет беззнаковую передачу. */
+ sample.count = 3U; /* В эталон включены два достоверных и один недостоверный канал. */
+ sample.values[0] = 123U; /* Первый регистр передаётся как обычный UINT16. */
+ sample.values[1] = 65534U; /* Второй регистр передаётся как сырое слово 0xFFFE без интерпретации типа GUI. */
+ sample.values[2] = 0xCAFEU; /* Ненулевое недостоверное значение обязано превратиться в ноль при сериализации. */
+ sample.valid[0] = 1U; /* Первый бит маски подтверждает первый канал. */
+ sample.valid[1] = 1U; /* Второй бит маски подтверждает второй канал. */
+ CHECK(set_gas_watch_encode_sample(&sample, output, sizeof output) == sizeof reference_sample); /* Размер включает заголовок 16 байт, один байт маски и шесть байт значений. */
+ CHECK(memcmp(output, reference_sample, sizeof reference_sample) == 0); /* Эталон проверяет каноническое обнуление и отсутствие лишних битов маски. */
+ CHECK(sample.values[2] == 0xCAFEU); /* Кодирование не изменяет исходный самостоятельный снимок. */
+ CHECK(set_gas_watch_decode(output, sizeof reference_sample, sample.config_id, 3U, &sequence, ×tamp, values, valid, 3U) == 3); /* Хост возвращает число успешно распознанных каналов. */
+ CHECK(sequence == UINT32_MAX && timestamp == 0xFFFFFFF0UL); /* Номер и время сохраняются при прохождении через ABI. */
+ CHECK(values[0] == 123U && values[1] == 65534U && values[2] == 0U); /* Значения возвращаются в порядке адресов, включая нулевое недостоверное. */
+ CHECK(valid[0] == 1U && valid[1] == 1U && valid[2] == 0U); /* Маска распаковывается в отдельные признаки каналов. */
+} /* Завершение проверки эталонных байтов. */
+
+static void test_atomic_apply(void) /* Проверяем отказ без частичной замены списка и работу идентификаторов конфигурации. */
+{ /* Начало сценария транзакционной настройки. */
+ set_gas_watch_t watch; /* Состояние будет многократно получать ошибочные и корректные запросы. */
+ uint8_t payload[SET_GAS_WATCH_CONFIG_MAX + 1U]; /* Дополнительный байт позволяет проверить отказ от лишнего хвоста. */
+ uint16_t addresses[] = {0x0000U, 0x0100U}; /* Второй канал принадлежит провайдеру, который нельзя вызывать при настройке. */
+ size_t size = set_gas_watch_request(1U, 32U, addresses, 2U, payload, sizeof payload); /* Первая конфигурация запускает два разрозненных регистра с периодом 32 мс. */
+ set_gas_watch_init(&watch); /* В исходном состоянии нет принятого идентификатора. */
+ context.calls = 0U; /* Счётчик отделяет настройку от всех предыдущих сценариев. */
+ CHECK(set_gas_watch_apply(&watch, &map, payload, size) == SET_GAS_WATCH_OK); /* Валидный запрос устанавливает сразу весь список. */
+ CHECK(context.calls == 0U); /* Проверка наличия обработчика не должна читать устройство. */
+ watch.sequence = 77U; /* Ненулевой номер помогает заметить ошибочный сброс при повторе запроса. */
+ watch.next_ms = 12345U; /* Установленный срок должен сохраниться при идемпотентном повторе. */
+ watch.scheduled = 1U; /* Состояние уже выполнявшегося сбора не должно превращаться в новый старт. */
+ set_gas_watch_t before = watch; /* Копия фиксирует все поля до повторной доставки одного запроса. */
+ CHECK(set_gas_watch_apply(&watch, &map, payload, size) == SET_GAS_WATCH_OK); /* Повтор принятой конфигурации является успешным повтором подтверждения. */
+ CHECK(memcmp(&watch, &before, sizeof watch) == 0); /* Идемпотентный повтор не меняет номера, расписание и адреса. */
+ payload[2] = 33U; /* При прежнем идентификаторе изменяем период с 32 на 33 мс. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_CONFLICT); /* Один идентификатор не может обозначать две разные конфигурации. */
+ payload[2] = 32U; /* Восстанавливаем канонический период исходной конфигурации. */
+ payload[0] = 3U; /* Неподдерживаемая версия не может интерпретироваться как текущий формат. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_INVALID); /* Неверная версия оставляет рабочий сбор действующим. */
+ payload[0] = 2U; /* Возвращаем версию 2 перед проверкой следующего поля. */
+ payload[10] = 1U; /* Зарезервированное поле должно оставаться нулевым в версии 2. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_INVALID); /* Неизвестные расширения не принимаются молча. */
+ payload[10] = 0U; /* Убираем нарушение резерва для независимой проверки длины. */
+ reject_unchanged(&watch, &map, payload, size - 1U, SET_GAS_WATCH_INVALID); /* Оборванный адрес не приводит к усечённой подписке. */
+ reject_unchanged(&watch, &map, payload, size + 1U, SET_GAS_WATCH_INVALID); /* Лишний байт исключает неоднозначное принятие пакета. */
+ reject_unchanged(&watch, &map, payload, SET_GAS_WATCH_CONFIG_HEADER - 1U, SET_GAS_WATCH_INVALID); /* До чтения полей должен присутствовать весь фиксированный заголовок. */
+ reject_unchanged(&watch, &map, payload, sizeof payload, SET_GAS_WATCH_INVALID); /* Пакет больше 140 байт не может содержать допустимую конфигурацию версии 2. */
+ reject_unchanged(&watch, &map, NULL, size, SET_GAS_WATCH_INVALID); /* Нулевой указатель с ненулевой длиной обрабатывается безопасно. */
+ addresses[1] = 0x0042U; /* Адрес 0x0042 расположен в промежутке между регионами тестовой карты. */
+ size = set_gas_watch_request(2U, 32U, addresses, 2U, payload, sizeof payload); /* Новый идентификатор исключает смешение ошибки адреса с конфликтом номера. */
+ reject_unchanged(&watch, NULL, payload, size, SET_GAS_WATCH_INVALID); /* Без карты невозможно подтвердить доступность каналов нового списка. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_NO_REG); /* Отсутствующий регистр отвергает весь список. */
+ addresses[1] = 0x0200U; /* Этот адрес существует, но помечен как доступный только для записи. */
+ size = set_gas_watch_request(2U, 32U, addresses, 2U, payload, sizeof payload); /* Сохраняем корректный wire-формат для проверки прав доступа. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_ACCESS); /* Явный запрет чтения не превращается в постоянно недостоверный канал. */
+ addresses[1] = 0x0300U; /* У этого региона есть только провайдер записи без флага WRONLY. */
+ size = set_gas_watch_request(2U, 32U, addresses, 2U, payload, sizeof payload); /* Подписка проверяет реальную возможность чтения, а не один флаг. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_ACCESS); /* Отсутствие хранилища и провайдера чтения тоже означает запрет. */
+ addresses[1] = 0x0101U; /* Корректная замена сохраняет число каналов, но меняет второй адрес. */
+ size = set_gas_watch_request(2U, 32U, addresses, 2U, payload, sizeof payload); /* Новая конфигурация имеет отдельный идентификатор. */
+ CHECK(set_gas_watch_apply(&watch, &map, payload, size) == SET_GAS_WATCH_OK); /* Устройство принимает новый список целиком. */
+ CHECK(watch.config_id == 2U && watch.address[1] == 0x0101U); /* Равное число каналов не скрывает замену их содержимого. */
+ CHECK(watch.sequence == 0U && watch.scheduled == 0U); /* Новый список начинает отдельную последовательность и готов к немедленному сбору. */
+ size = set_gas_watch_request(1U, 32U, addresses, 2U, payload, sizeof payload); /* Возвращаем устаревший идентификатор с тем же числом каналов. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_CONFLICT); /* Задержавшийся запрос не откатывает устройство назад. */
+ size = set_gas_watch_request(0x80000002UL, 32U, addresses, 2U, payload, sizeof payload); /* Разность ровно половины диапазона uint32 неоднозначна для порядка номеров. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_CONFLICT); /* Граница 2^31 не признаётся более новой конфигурацией. */
+ payload[4] = 0U; /* Первые четыре байта идентификатора заменяем нулями для запрещённого номера. */
+ payload[5] = 0U; /* Убираем второй байт идентификатора, сохраняя длину и адреса. */
+ payload[6] = 0U; /* Убираем третий байт идентификатора без изменения других полей. */
+ payload[7] = 0U; /* Завершаем формирование зарезервированного идентификатора 0. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_INVALID); /* Нулевой номер запрещён и не используется как команда обхода порядка. */
+ size = set_gas_watch_request(3U, 32U, addresses, 2U, payload, sizeof payload); /* Создаём валидный пакет перед намеренным дублированием адреса на линии. */
+ payload[14] = payload[12]; /* Второй адрес получает младший байт первого адреса. */
+ payload[15] = payload[13]; /* Второй адрес получает старший байт первого адреса. */
+ reject_unchanged(&watch, &map, payload, size, SET_GAS_WATCH_INVALID); /* Дубликаты отвергаются и на устройстве, даже если хостовый builder их запрещает. */
+ pcan_gas_region_t overlap[] = {regions[0], regions[0]}; /* Перекрывающиеся регионы создают неоднозначное отображение GAS-адресов. */
+ pcan_gas_map_t invalid_map = {overlap, 2U}; /* Некорректная карта не должна обходить проверку через удачный первый поиск. */
+ size = set_gas_watch_request(3U, 32U, addresses, 2U, payload, sizeof payload); /* Убираем дубликат из запроса, оставляя единственным нарушением карту. */
+ reject_unchanged(&watch, &invalid_map, payload, size, SET_GAS_WATCH_INVALID); /* Применение проверяет целостность карты до принятия подписки. */
+ set_gas_watch_init(&watch); /* Новое состояние позволяет отдельно проверить перенос идентификатора через uint32. */
+ size = set_gas_watch_request(UINT32_MAX, 32U, addresses, 2U, payload, sizeof payload); /* Любой ненулевой номер разрешён для самой первой конфигурации. */
+ CHECK(set_gas_watch_apply(&watch, &map, payload, size) == SET_GAS_WATCH_OK); /* Максимальный номер не отвергается из-за его старшего знакового бита. */
+ size = set_gas_watch_request(1U, 32U, addresses, 2U, payload, sizeof payload); /* После максимального номера хост пропускает запрещённый ноль. */
+ CHECK(set_gas_watch_apply(&watch, &map, payload, size) == SET_GAS_WATCH_OK); /* Модульное сравнение распознаёт номер 1 как новый после 0xFFFFFFFF. */
+} /* Завершение атомарных и порядковых проверок. */
+
+static void test_capture_and_timing(void) /* Проверяем приборное время, единичное чтение и независимость уже полученной выборки. */
+{ /* Начало сценария сбора и сериализации самостоятельного снимка. */
+ set_gas_watch_t watch; /* Подписка управляет периодом и последовательностью выборок. */
+ set_gas_watch_sample_t sample; /* Снимок принадлежит вызывающему приложению и переживает изменение подписки. */
+ uint16_t addresses[] = {0x0100U, 0x0101U, 0x0000U}; /* После отказавшего провайдера остаются ещё два канала для чтения. */
+ uint8_t payload[SET_GAS_WATCH_CONFIG_MAX]; /* Буфер переносит только конфигурацию адресов. */
+ uint8_t encoded[SET_GAS_WATCH_SAMPLE_MAX]; /* Первый результат кодирования сохраняется для проверки повторной передачи. */
+ uint8_t repeated[SET_GAS_WATCH_SAMPLE_MAX]; /* Повторное кодирование обязано совпадать даже после изменений карты. */
+ context.calls = 0U; /* Изолируем число приборных обращений текущего сценария. */
+ context.base_value = 1000U; /* Вычисляемые значения отличаются от памяти GAS. */
+ context.rejected_offset = 0U; /* Первый вычисляемый канал вернёт отказ уже после записи ненулевого результата. */
+ set_gas_watch_init(&watch); /* Начинаем без активной подписки и расписания. */
+ size_t size = set_gas_watch_request(1U, 32U, addresses, 3U, payload, sizeof payload); /* Три канала должны собираться одним снимком каждые 32 мс. */
+ CHECK(set_gas_watch_apply(&watch, &map, payload, size) == SET_GAS_WATCH_OK); /* Проверяем, что наличие провайдера достаточно независимо от его будущего результата. */
+ CHECK(context.calls == 0U); /* Настройка не вызывает провайдер даже для заведомо отказавшего чтения. */
+ CHECK(set_gas_watch_due(&watch, 0xFFFFFFF0UL)); /* Новый список готов к сбору сразу и около переполнения времени. */
+ CHECK(set_gas_watch_capture(&watch, &map, 0xFFFFFFF0UL, &sample)); /* Одна операция фиксирует все каналы и приборную отметку времени. */
+ CHECK(context.calls == 2U); /* Оба провайдера прочитаны ровно по одному разу, несмотря на отказ первого. */
+ CHECK(sample.config_id == 1U && sample.sequence == 0U && watch.sequence == 1U); /* Первая выборка имеет номер 0, после неё счётчик увеличивается один раз. */
+ CHECK(sample.timestamp_ms == 0xFFFFFFF0UL && sample.count == 3U); /* Метка относится ко всей выборке и не подменяется временем отправки. */
+ CHECK(sample.valid[0] == 0U && sample.values[0] == 0U); /* Неудачное чтение не выпускает записанное провайдером мусорное значение. */
+ CHECK(sample.valid[1] == 1U && sample.values[1] == 1001U); /* Отказ одного адреса не прерывает чтение следующего провайдера. */
+ CHECK(sample.valid[2] == 1U && sample.values[2] == storage[0]); /* Последний канал памяти остаётся достоверным после ошибки другого канала. */
+ CHECK(watch.scheduled == 1U && watch.next_ms == 16U); /* Прибавление 32 мс к 0xFFFFFFF0 корректно переносит срок в следующий оборот таймера. */
+ CHECK(!set_gas_watch_due(&watch, 0xFFFFFFF1UL)); /* Через 1 мс после выборки период ещё не истёк. */
+ CHECK(!set_gas_watch_due(&watch, 0U)); /* После переполнения до срока остаётся 16 мс. */
+ CHECK(!set_gas_watch_due(&watch, 15U)); /* В последнюю миллисекунду перед сроком чтение ещё не требуется. */
+ CHECK(set_gas_watch_due(&watch, 16U)); /* Равенство приборного времени сроку означает готовность новой выборки. */
+ watch.sequence = UINT32_MAX; /* Устанавливаем границу, которую нельзя дождаться перебором миллиардов выборок. */
+ CHECK(set_gas_watch_capture(&watch, &map, 0U, &sample)); /* Явный сбор разрешён и до очередного срока: due остаётся решением вызывающего кода. */
+ CHECK(sample.sequence == UINT32_MAX && watch.sequence == 0U); /* Последовательность переносится через uint32 без потери последнего номера. */
+ size_t encoded_size = set_gas_watch_encode_sample(&sample, encoded, sizeof encoded); /* Кодируем зафиксированный снимок до изменения окружения. */
+ unsigned calls = context.calls; /* Запоминаем количество чтений, чтобы выявить скрытое перечитывание при сериализации. */
+ storage[0] = 999U; /* Модель прибора меняется после фиксации снимка. */
+ context.base_value = 2000U; /* Вычисляемый провайдер тоже теперь вернул бы другие значения. */
+ size = set_gas_watch_request(2U, 100U, reference_addresses, 3U, payload, sizeof payload); /* Меняем подписку на другой список при сохранении числа каналов. */
+ CHECK(set_gas_watch_apply(&watch, &map, payload, size) == SET_GAS_WATCH_OK); /* Перенастройка должна быть независима от уже созданного пакета. */
+ CHECK(set_gas_watch_encode_sample(&sample, repeated, sizeof repeated) == encoded_size); /* Повторная передача использует прежний размер самостоятельного снимка. */
+ CHECK(memcmp(encoded, repeated, encoded_size) == 0); /* Ни значения карты, ни новые адреса не изменяют старую выборку. */
+ CHECK(context.calls == calls); /* Подтверждение настройки и повторное кодирование не читают приборные регистры. */
+ pcan_gas_region_t changed_regions[5]; /* Подмена карты моделирует исчезновение или изменение прав после успешной настройки. */
+ memcpy(changed_regions, regions, sizeof changed_regions); /* Остальные свойства регионов сохраняются, чтобы карта оставалась структурно корректной. */
+ changed_regions[0].flags = PCAN_GAS_WRONLY; /* Выбранные адреса 0 и 1 теперь запрещены для чтения. */
+ changed_regions[4].base = 0xFF02U; /* Ранее выбранный адрес 0xFF01 больше не покрывается картой. */
+ pcan_gas_map_t changed_map = {changed_regions, 5U}; /* Изменённая карта остаётся отсортированной и без перекрытий. */
+ CHECK(set_gas_watch_capture(&watch, &changed_map, 1U, &sample)); /* Потеря отдельных каналов не отменяет номер и время всей выборки. */
+ CHECK(sample.valid[0] == 0U && sample.valid[1] == 0U && sample.valid[2] == 0U); /* Изменение прав и исчезновение адреса явно отражаются недостоверностью. */
+ CHECK(sample.values[0] == 0U && sample.values[1] == 0U && sample.values[2] == 0U); /* Ни старые значения, ни указатели исчезнувших каналов не попадают в новый пакет. */
+ pcan_gas_map_t invalid_map = {NULL, 1U}; /* Ненулевое число регионов без массива является структурной ошибкой карты. */
+ set_gas_watch_t before_watch = watch; /* Ошибка всей карты не должна расходовать номер выборки или сдвигать расписание. */
+ set_gas_watch_sample_t before_sample = sample; /* Ошибка аргументов не должна повреждать уже полученный снимок. */
+ CHECK(!set_gas_watch_capture(&watch, &invalid_map, 2U, &sample)); /* Структурно неверная карта отличается от ошибки отдельного читаемого канала. */
+ CHECK(memcmp(&watch, &before_watch, sizeof watch) == 0); /* Состояние подписки полностью сохраняется при невозможности захвата. */
+ CHECK(memcmp(&sample, &before_sample, sizeof sample) == 0); /* Выходной снимок полностью сохраняется при невозможности захвата. */
+ size = set_gas_watch_request(3U, 0U, NULL, 0U, payload, sizeof payload); /* Остановка выражается пустым списком с периодом 0 мс и новым идентификатором. */
+ CHECK(size == SET_GAS_WATCH_CONFIG_HEADER); /* Пакет остановки состоит только из заголовка конфигурации. */
+ CHECK(set_gas_watch_apply(&watch, &map, payload, size) == SET_GAS_WATCH_OK); /* Остановка применяется такой же транзакцией, как активная конфигурация. */
+ set_gas_watch_sample_t before = sample; /* Сохраняем старую выборку перед попыткой собрать неактивный поток. */
+ CHECK(!set_gas_watch_due(&watch, UINT32_MAX)); /* Остановленный поток не становится активным из-за переполнения времени. */
+ CHECK(!set_gas_watch_capture(&watch, &map, 1U, &sample)); /* Пустая подписка не создаёт новый пакет. */
+ CHECK(memcmp(&sample, &before, sizeof sample) == 0); /* Неудачный сбор сохраняет уже готовую выборку в буфере вызывающего кода. */
+ storage[0] = 123U; /* Возвращаем общее значение эталона для независимости следующих сценариев. */
+} /* Завершение проверки снимка и расписания. */
+
+static void decode_rejected(const uint8_t *payload, size_t size, uint32_t config_id, size_t count, size_t capacity) /* Проверяем единый контракт отсутствия частичного результата на стороне хоста. */
+{ /* Начало вспомогательной проверки отказа декодера. */
+ uint16_t values[SET_GAS_WATCH_MAX]; /* Полный буфер отделяет недостаточную объявленную ёмкость от реального выхода за память. */
+ uint8_t valid[SET_GAS_WATCH_MAX]; /* Маска хранится в собственном буфере с отличимым заполнителем. */
+ uint16_t before_values[SET_GAS_WATCH_MAX]; /* Копия выявляет изменение любого канала, включая запасные ячейки. */
+ uint8_t before_valid[SET_GAS_WATCH_MAX]; /* Копия маски выявляет раннюю запись до полной валидации payload. */
+ uint32_t sequence = 0x11223344UL; /* Отличимая исходная последовательность не должна меняться при отказе. */
+ uint32_t timestamp = 0x55667788UL; /* Отличимая исходная метка не должна меняться при отказе. */
+ memset(values, 0xA5, sizeof values); /* Все значения получают заполнитель, отличный от эталонного payload. */
+ memset(valid, 0x5A, sizeof valid); /* Признаки достоверности получают отдельный заполнитель. */
+ memcpy(before_values, values, sizeof values); /* Сохраняем массив значений для полного побайтового сравнения. */
+ memcpy(before_valid, valid, sizeof valid); /* Сохраняем все ячейки маски до вызова декодера. */
+ CHECK(set_gas_watch_decode(payload, size, config_id, count, &sequence, ×tamp, values, valid, capacity) == -1); /* Любая ошибка возвращает единый отрицательный результат. */
+ CHECK(sequence == 0x11223344UL && timestamp == 0x55667788UL); /* Скалярные выходы обновляются только после полного принятия пакета. */
+ CHECK(memcmp(values, before_values, sizeof values) == 0); /* Декодер не оставляет частично декодированные значения. */
+ CHECK(memcmp(valid, before_valid, sizeof valid) == 0); /* Декодер не оставляет частично распакованные признаки. */
+} /* Завершение проверки транзакционного декодирования. */
+
+static void test_malformed_and_limits(void) /* Проверяем границы буферов, максимальный список и канонический bitmap. */
+{ /* Начало сценария валидации публичной границы данных. */
+ uint8_t payload[SET_GAS_WATCH_SAMPLE_MAX + 1U]; /* Дополнительный байт используется только для проверки лишнего хвоста. */
+ uint8_t before[sizeof payload]; /* Заполнитель позволяет доказать отсутствие записи при отказе builder. */
+ uint16_t duplicates[] = {1U, 1U}; /* Повтор одного адреса запрещён независимо от его реального существования в карте. */
+ memset(payload, 0xA5, sizeof payload); /* Начинаем с различимого содержимого выходного буфера. */
+ memcpy(before, payload, sizeof payload); /* Снимок буфера позволит выявить даже частично записанный заголовок. */
+ CHECK(set_gas_watch_request(0U, 100U, reference_addresses, 3U, payload, sizeof payload) == 0U); /* Нулевой идентификатор конфигурации запрещён хостовому builder. */
+ CHECK(set_gas_watch_request(1U, 0U, reference_addresses, 3U, payload, sizeof payload) == 0U); /* Ненулевой список требует ненулевого периода. */
+ CHECK(set_gas_watch_request(1U, 100U, NULL, 0U, payload, sizeof payload) == 0U); /* Пустой список требует нулевого периода и означает остановку. */
+ CHECK(set_gas_watch_request(1U, 100U, duplicates, 2U, payload, sizeof payload) == 0U); /* Дубликаты не должны выпускаться builder на линию. */
+ CHECK(set_gas_watch_request(1U, 100U, NULL, 3U, payload, sizeof payload) == 0U); /* Ненулевое количество требует доступного массива адресов. */
+ CHECK(set_gas_watch_request(1U, 100U, reference_addresses, 65U, payload, sizeof payload) == 0U); /* Проверка предела 64 выполняется до чтения указанного массива. */
+ CHECK(set_gas_watch_request(1U, 100U, reference_addresses, 3U, payload, sizeof reference_config - 1U) == 0U); /* Недостаточная ёмкость не допускает усечённого запроса. */
+ CHECK(set_gas_watch_request(1U, 100U, reference_addresses, 3U, NULL, sizeof payload) == 0U); /* Нулевой выходной указатель возвращает отказ без записи. */
+ CHECK(memcmp(payload, before, sizeof payload) == 0); /* Ни один ошибочный запрос не должен частично менять переданный буфер. */
+ memcpy(payload, reference_sample, sizeof reference_sample); /* Берём правильный пакет и независимо повреждаем отдельные части. */
+ decode_rejected(payload, sizeof reference_sample, 0x12345679UL, 3U, 3U); /* Задержавшаяся выборка прежнего списка отвергается даже при том же количестве каналов. */
+ decode_rejected(payload, sizeof reference_sample, 0x12345678UL, 2U, 3U); /* Число каналов должно совпадать с подтверждённой подпиской. */
+ decode_rejected(payload, sizeof reference_sample, 0x12345678UL, 3U, 2U); /* Недостаточное число выходных ячеек не оставляет частичного результата. */
+ decode_rejected(NULL, sizeof reference_sample, 0x12345678UL, 3U, 3U); /* Нулевой входной указатель обрабатывается как некорректный пакет. */
+ decode_rejected(payload, SET_GAS_WATCH_SAMPLE_HEADER - 1U, 0x12345678UL, 3U, 3U); /* Не хватает даже фиксированного заголовка выборки. */
+ decode_rejected(payload, sizeof reference_sample - 1U, 0x12345678UL, 3U, 3U); /* Оборванное последнее значение не может обновить первые каналы. */
+ decode_rejected(payload, sizeof reference_sample + 1U, 0x12345678UL, 3U, 3U); /* Избыточный хвост исключает неверную интерпретацию следующего сообщения. */
+ payload[0] = 3U; /* Изменяем версию при сохранении всех остальных эталонных полей. */
+ decode_rejected(payload, sizeof reference_sample, 0x12345678UL, 3U, 3U); /* Неподдерживаемый формат не обновляет выходы. */
+ payload[0] = 2U; /* Возвращаем действующую версию перед повреждением bitmap. */
+ payload[16] = 0x83U; /* Бит 7 находится за пределами трёх каналов и обязан быть нулевым. */
+ decode_rejected(payload, sizeof reference_sample, 0x12345678UL, 3U, 3U); /* Неиспользуемые биты маски проверяются для канонического представления. */
+ payload[16] = 3U; /* Восстанавливаем корректную маску двух достоверных каналов. */
+ payload[21] = 1U; /* Третий канал остаётся недостоверным, но его слово становится ненулевым. */
+ decode_rejected(payload, sizeof reference_sample, 0x12345678UL, 3U, 3U); /* Неканоническое ненулевое слово недостоверного канала отвергается. */
+ payload[21] = 0U; /* Восстанавливаем эталон перед проверкой чрезмерного количества. */
+ payload[2] = 65U; /* Заголовок требует больше максимальных 64 каналов. */
+ decode_rejected(payload, sizeof reference_sample, 0x12345678UL, 65U, 64U); /* Ограничение количества проверяется до обращения к выходным массивам. */
+ uint16_t addresses[SET_GAS_WATCH_MAX]; /* Максимальный список хранит уникальные адреса единого региона. */
+ uint16_t words[SET_GAS_WATCH_MAX]; /* Модель карты содержит достаточно реальных слов для всех 64 каналов. */
+ for (size_t i = 0U; i < SET_GAS_WATCH_MAX; ++i) { /* Заполняем все разрешённые позиции без фиксации размера в теле цикла. */
+ addresses[i] = (uint16_t)i; /* Диапазон 0..63 создаёт уникальный отсортированный список регистров. */
+ words[i] = (uint16_t)(1000U + i); /* Ненулевой возрастающий рисунок делает перестановки значений заметными. */
+ } /* Завершение подготовки максимального списка. */
+ pcan_gas_region_t full_region = {0U, SET_GAS_WATCH_MAX, words, NULL, NULL, PCAN_GAS_RDONLY, NULL, "full"}; /* Один читаемый регион покрывает все 64 адреса. */
+ pcan_gas_map_t full_map = {&full_region, 1U}; /* Карта максимального списка остаётся простой и однозначной. */
+ set_gas_watch_t watch; /* Состояние максимального потока проверяет размеры публичных структур. */
+ set_gas_watch_sample_t sample; /* Выборка должна вместить все значения и признаки без динамической памяти. */
+ set_gas_watch_init(&watch); /* Создаём пустое состояние для независимого граничного сценария. */
+ size_t config_size = set_gas_watch_request(1U, 1U, addresses, SET_GAS_WATCH_MAX, payload, sizeof payload); /* Минимальный ненулевой период 1 мс остаётся разрешённым. */
+ CHECK(config_size == SET_GAS_WATCH_CONFIG_MAX && config_size == 140U); /* Максимальная конфигурация занимает 12 + 64*2 байта. */
+ CHECK(set_gas_watch_apply(&watch, &full_map, payload, config_size) == SET_GAS_WATCH_OK); /* Устройство принимает ровно предельные 64 уникальных адреса. */
+ CHECK(set_gas_watch_capture(&watch, &full_map, 42U, &sample)); /* Максимальная выборка собирается за один вызов. */
+ size_t sample_size = set_gas_watch_encode_sample(&sample, payload, sizeof payload); /* Кодируем весь снимок с восемью байтами bitmap. */
+ CHECK(sample_size == SET_GAS_WATCH_SAMPLE_MAX && sample_size == 152U); /* Полный пакет занимает 16 + 8 + 64*2 байта. */
+ uint16_t decoded[SET_GAS_WATCH_MAX]; /* Выходные значения хоста имеют ровно предельную ёмкость. */
+ uint8_t valid[SET_GAS_WATCH_MAX]; /* Один признак на каждый из 64 каналов проверяет все восемь байтов bitmap. */
+ uint32_t sequence = UINT32_MAX; /* Отличимое исходное значение должно замениться нулём первой выборки. */
+ uint32_t timestamp = 0U; /* Метка должна получить приборное значение 42 мс. */
+ CHECK(set_gas_watch_decode(payload, sample_size, 1U, SET_GAS_WATCH_MAX, &sequence, ×tamp, decoded, valid, SET_GAS_WATCH_MAX) == (int)SET_GAS_WATCH_MAX); /* Декодер принимает предельный размер без усечения. */
+ CHECK(sequence == 0U && timestamp == 42U && memcmp(decoded, words, sizeof words) == 0); /* Все значения и скалярные поля максимального пакета сохраняются. */
+ for (size_t i = 0U; i < SET_GAS_WATCH_MAX; ++i) { /* Проверяем каждую позицию вместо одной средней маски. */
+ CHECK(valid[i] == 1U); /* Читаемый массив делает достоверными все 64 канала, включая последние биты bitmap. */
+ } /* Завершение проверки полного bitmap. */
+ CHECK(set_gas_watch_decode(payload, sample_size, 1U, SET_GAS_WATCH_MAX, NULL, ×tamp, decoded, valid, SET_GAS_WATCH_MAX) == (int)SET_GAS_WATCH_MAX); /* Вызывающий код может отказаться от получения необязательного счётчика последовательности. */
+ CHECK(set_gas_watch_decode(payload, sample_size, 1U, SET_GAS_WATCH_MAX, &sequence, NULL, decoded, valid, SET_GAS_WATCH_MAX) == (int)SET_GAS_WATCH_MAX); /* Вызывающий код может отказаться от получения необязательной отметки времени. */
+ CHECK(set_gas_watch_decode(payload, sample_size, 1U, SET_GAS_WATCH_MAX, &sequence, ×tamp, NULL, valid, SET_GAS_WATCH_MAX) == -1); /* Обязательный массив значений проверяется до записи результата. */
+ CHECK(set_gas_watch_decode(payload, sample_size, 1U, SET_GAS_WATCH_MAX, &sequence, ×tamp, decoded, NULL, SET_GAS_WATCH_MAX) == -1); /* Обязательный массив достоверности проверяется до записи результата. */
+ CHECK(set_gas_watch_encode_sample(&sample, NULL, sizeof payload) == 0U); /* Нулевой буфер сериализации приводит к безопасному отказу. */
+ CHECK(set_gas_watch_encode_sample(&sample, payload, sample_size - 1U) == 0U); /* Сериализация никогда не выпускает частичный пакет. */
+ CHECK(set_gas_watch_encode_sample(NULL, payload, sizeof payload) == 0U); /* Отсутствующий снимок нельзя заменить чтением какой-либо глобальной карты. */
+ CHECK(set_gas_watch_encode_ack(NULL, payload, sizeof payload) == 0U); /* Отсутствующая подписка не образует корректного подтверждения. */
+ CHECK(set_gas_watch_encode_ack(&watch, NULL, sizeof payload) == 0U); /* Нулевой буфер ACK безопасно отвергается. */
+ CHECK(set_gas_watch_encode_ack(&watch, payload, config_size - 1U) == 0U); /* Подтверждение не сокращается до одного заголовка из-за малого буфера. */
+ CHECK(set_gas_watch_apply(NULL, &full_map, payload, config_size) == SET_GAS_WATCH_INVALID); /* Пустой указатель состояния не должен разыменовываться. */
+ CHECK(!set_gas_watch_due(NULL, 1U)); /* Запрос расписания без состояния возвращает отсутствие готовности. */
+ CHECK(!set_gas_watch_capture(NULL, &full_map, 1U, &sample)); /* Сбор без состояния возвращает отказ. */
+ CHECK(!set_gas_watch_capture(&watch, NULL, 1U, &sample)); /* Сбор без карты не обращается к несуществующим провайдерам. */
+ CHECK(!set_gas_watch_capture(&watch, &full_map, 1U, NULL)); /* Сбор без выходного снимка не выполняется. */
+ set_gas_watch_init(NULL); /* Инициализация проверяется на безопасное поведение с нулевым указателем. */
+ sample.valid[0] = 0U; /* Отключаем первый канал, чтобы проверить младший бит первого байта bitmap. */
+ sample.valid[7] = 0U; /* Отключаем последний канал первого байта bitmap. */
+ sample.valid[8] = 0U; /* Отключаем первый канал второго байта bitmap, проверяя перенос границы восьми каналов. */
+ sample.valid[63] = 0U; /* Отключаем последний разрешённый канал в старшем бите восьмого байта. */
+ CHECK(set_gas_watch_encode_sample(&sample, payload, sizeof payload) == sample_size); /* Изменение достоверности сохраняет длину максимального пакета. */
+ CHECK(payload[16] == 0x7EU && payload[17] == 0xFEU && payload[23] == 0x7FU); /* Раскладка bitmap соответствует индексам 0, 7, 8 и 63 без смещения. */
+ CHECK(set_gas_watch_decode(payload, sample_size, 1U, SET_GAS_WATCH_MAX, &sequence, ×tamp, decoded, valid, SET_GAS_WATCH_MAX) == (int)SET_GAS_WATCH_MAX); /* Максимальный пакет с разреженной маской остаётся допустимым. */
+ CHECK(decoded[0] == 0U && decoded[7] == 0U && decoded[8] == 0U && decoded[63] == 0U); /* Недостоверные слова принудительно обнуляются на всех проверенных границах маски. */
+ CHECK(sample.values[0] == 1000U && sample.values[63] == 1063U); /* Каноническое обнуление относится к wire-пакету и не меняет исходный снимок. */
+ memcpy(before, payload, sizeof payload); /* Сохраняем полный выход перед ошибками модели сериализации. */
+ sample.valid[63] = 2U; /* Признак достоверности допускает только значения 0 и 1. */
+ CHECK(set_gas_watch_encode_sample(&sample, payload, sizeof payload) == 0U); /* Неверный признак в последнем канале проверяется до записи первого байта заголовка. */
+ CHECK(memcmp(payload, before, sizeof payload) == 0); /* Поздняя ошибка модели не оставляет частично записанный пакет. */
+ sample.valid[63] = 0U; /* Восстанавливаем двоичный признак перед проверкой неверного количества каналов. */
+ sample.count = SET_GAS_WATCH_MAX + 1U; /* Число 65 находится за пределами обоих встроенных массивов снимка. */
+ CHECK(set_gas_watch_encode_sample(&sample, payload, sizeof payload) == 0U); /* Проверка количества предотвращает выход за границу даже при достаточном output. */
+ CHECK(memcmp(payload, before, sizeof payload) == 0); /* Неверное количество тоже не меняет выходной буфер. */
+} /* Завершение проверок некорректных входов и предельных размеров. */
+
+int main(void) /* Один независимый исполняемый тест запускается общей системой CTest. */
+{ /* Начало последовательного запуска контрактных сценариев. */
+ test_reference(); /* Эталонные байты фиксируют совместимость C, Python и JVM. */
+ test_atomic_apply(); /* Ошибки настройки и повторные запросы не должны повреждать подписку. */
+ test_capture_and_timing(); /* Снимок и приборный таймер проверяются отдельно от транспорта. */
+ test_malformed_and_limits(); /* Входные границы проверяются и на устройстве, и в хостовом ABI. */
+ if (failures != 0U) { /* Любое нарушение делает запуск неуспешным для CTest и CI. */
+ printf("GAS_WATCH2: %u failures\n", failures); /* Итог сообщает число обнаруженных нарушений контракта. */
+ return 1; /* Ненулевой код обозначает, что хотя бы одна контрактная проверка не прошла. */
+ } /* Завершение ветки ошибочного результата. */
+ puts("GAS_WATCH2 contract tests passed"); /* Краткая строка подтверждает завершение всех сценариев. */
+ return 0; /* Нулевой код передаёт общий успех системе сборки. */
+} /* Завершение тестового исполняемого файла. */
diff --git a/c/set-protocol/tests/test_modbus_server.c b/c/set-protocol/tests/test_modbus_server.c
index eb264cc..75a86d9 100644
--- a/c/set-protocol/tests/test_modbus_server.c
+++ b/c/set-protocol/tests/test_modbus_server.c
@@ -12,7 +12,7 @@
static uint16_t holding[32];
static uint16_t input[32];
-static uint16_t coils;
+static uint16_t discretes = 0x0014U; /* Входы 2 и 4 установлены для проверки сохранённого битового чтения типа 0x5. */
static bool read_word(void *user, pcan_modbus_bank_t bank,
uint16_t address, uint16_t *value)
@@ -21,7 +21,7 @@ static bool read_word(void *user, pcan_modbus_bank_t bank,
if (address >= 32U) return false;
if (bank == PCAN_MODBUS_HOLDING) *value = holding[address];
else if (bank == PCAN_MODBUS_INPUT) *value = input[address];
- else if (bank == PCAN_MODBUS_COIL) *value = (uint16_t)((coils >> address) & 1U);
+ else if (bank == PCAN_MODBUS_DISCRETE && address < 16U) *value = (uint16_t)((discretes >> address) & 1U); /* Не сдвигаем 16-битное поле на недопустимое число позиций. */
else return false;
return true;
}
@@ -32,10 +32,7 @@ static bool write_word(void *user, pcan_modbus_bank_t bank,
(void)user;
if (address >= 32U) return false;
if (bank == PCAN_MODBUS_HOLDING) holding[address] = value;
- else if (bank == PCAN_MODBUS_COIL && address < 16U) {
- if (value) coils |= (uint16_t)(1U << address);
- else coils &= (uint16_t)~(1U << address);
- } else return false;
+ else return false; /* Битовые входы и новый слотный GAS не записываются через Modbus provider. */
return true;
}
@@ -84,10 +81,11 @@ int main(void)
CHECK(pcan_modbus_server_handle(&server, &req, &rsp) == PCAN_MODBUS_RESPONSE);
CHECK(holding[3] == 0x1122U && holding[4] == 0x3344U);
- req = request(PCAN_MODBUS_COIL, 2U, 3U);
- req.dlc = 2U; req.data[0] = 0x05U; req.data[1] = 0U;
+ req = request(PCAN_MODBUS_DISCRETE, 2U, 3U); /* Читаем три входа 2…4 через сохранённый тип 0x5. */
CHECK(pcan_modbus_server_handle(&server, &req, &rsp) == PCAN_MODBUS_RESPONSE);
- CHECK(coils == 0x0014U);
+ CHECK(rsp.dlc == 2U && rsp.data[0] == 0x05U && rsp.data[1] == 0U); /* Маска прочитанных входов должна сохранять порядок младших битов. */
+ req = request(PCAN_MSG_GAS_SLOT, 2U, 3U); /* Передаём бывший номер Coil, теперь занятый независимым GAS SLOT. */
+ CHECK(pcan_modbus_server_handle(&server, &req, &rsp) == PCAN_MODBUS_NOT_FOR_US); /* Слотный кадр не должен вызывать Modbus provider и менять его счётчики. */
req = request(PCAN_MODBUS_INPUT, 0U, 5U);
CHECK(pcan_modbus_server_handle(&server, &req, &rsp) == PCAN_MODBUS_HANDLED_NO_RESPONSE);
diff --git a/c/set-protocol/tools/build_host.py b/c/set-protocol/tools/build_host.py
index 911da88..3c6dcda 100644
--- a/c/set-protocol/tools/build_host.py
+++ b/c/set-protocol/tools/build_host.py
@@ -27,10 +27,12 @@ SOURCES = [
"set_regmap.c", "set_emu_ump.c", "set_emu_tms.c", "set_emu_igbt.c", "set_emu_server.c", "set_boot.c",
"firmware_image.c", "altera_logic.c",
"altera_stream.c",
+ "set_gas_watch.c", # Добавляет общий GAS_WATCH2 в прямые MSVC/CC сборки, чтобы новые ctypes/JNI символы были доступны.
"set_protocol.c", "set_can.c", "set_firmware.c", "set_telemetry.c", "set_plot.c", "set_trends.c", "set_spectrum.c", "set_signal.c", "set_wavegen.c",
"balsam_can.c", "set_crc.c", "periph28335.c", "tms2812.c", "gui_catalog.c", "gui_frame.c", "pcan_abi.c", "pcan_crc.c",
"pcan_frame.c", "pcan_id.c", "pcan_link.c", "pcan_ring.c",
"pcan_gas.c", "pcan_modbus_server.c",
+ "pcan_gas_slot.c", # Собирает единый слотный кодек также при прямой MSVC/CC сборке для ctypes и JNI.
)
]
@@ -94,6 +96,7 @@ def main() -> int:
SOURCES.extend([
ROOT / "ports" / "android" / "set_plot_jni.c",
ROOT / "ports" / "android" / "setprotocol_jni.c",
+ ROOT / "ports" / "android" / "gas_slots_jni.c", # Даёт host JVM тот же адаптер слотной адресации, который используется Android.
])
output = args.output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
diff --git a/doc/setcan/protocan/examples/gas-slot-vectors.json b/doc/setcan/protocan/examples/gas-slot-vectors.json
new file mode 100644
index 0000000..3d11862
--- /dev/null
+++ b/doc/setcan/protocan/examples/gas-slot-vectors.json
@@ -0,0 +1,55 @@
+{
+ "profile": "GAS SLOT 0x4 / Word0 mask",
+ "words": [
+ 14,
+ 4660,
+ 43981,
+ 65535
+ ],
+ "payload_hex": "0E003412CDABFFFF",
+ "vectors": [
+ {
+ "mode": 5,
+ "local_slot": 0,
+ "request_id": "0x11540500",
+ "response_id": "0x19540500"
+ },
+ {
+ "mode": 6,
+ "local_slot": 0,
+ "request_id": "0x11640600",
+ "response_id": "0x19640600"
+ },
+ {
+ "mode": 7,
+ "local_slot": 0,
+ "request_id": "0x11740700",
+ "response_id": "0x19740700"
+ },
+ {
+ "mode": 8,
+ "local_slot": 0,
+ "request_id": "0x11840800",
+ "response_id": "0x19840800"
+ },
+ {
+ "mode": 16,
+ "local_slot": 0,
+ "request_id": "0x12041000",
+ "response_id": "0x1A041000"
+ },
+ {
+ "mode": 31,
+ "local_slot": 66,
+ "request_id": "0x12F41F42",
+ "response_id": "0x1AF41F42",
+ "words": [
+ 6,
+ 4660,
+ 43981,
+ 0
+ ],
+ "payload_hex": "06003412CDAB0000"
+ }
+ ]
+}
diff --git a/python/logic_analyzer/decoders/set_can.py b/python/logic_analyzer/decoders/set_can.py
index f0d0867..075623c 100644
--- a/python/logic_analyzer/decoders/set_can.py
+++ b/python/logic_analyzer/decoders/set_can.py
@@ -6,7 +6,7 @@
from .set_uart import number, parse_frame
TYPES = {0: 'BROADCAST', 1: 'DISCRETE', 2: 'ANALOG', 3: 'GAS',
- 4: 'MODBUS_COIL', 5: 'MODBUS_DISCRETE', 6: 'MODBUS_HOLDING',
+ 4: 'GAS_SLOT', 5: 'MODBUS_DISCRETE', 6: 'MODBUS_HOLDING', # Тип 0x4 теперь означает масочный слотный GAS, номера остальных Modbus-типов сохранены.
7: 'MODBUS_INPUT', 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 '
@@ -42,7 +42,14 @@ def legacy(ident, data, profile='protocan'):
raise ValueError('Invalid GAS register payload/range')
result['registers'] = [(body + i//2, number(data[i:i+2])) for i in range(0, len(data), 2)]
text += ' ' + ('READ' if not data else ' '.join('R%04X=%04X' % pair for pair in result['registers']))
- elif 4 <= msg <= 7:
+ elif msg == 4: # Слотный GAS определяется номером типа, без эвристики по первым байтам данных.
+ from protocan.gas_slots import GasSlots # Проверку Word0-маски, длины и порядка октетов выполняет единственный C-кодек.
+ values = GasSlots().decode_address(ident, data) # Не ограничиваем анализатор адресами семейства PERIPH.
+ result['slot'] = body # Адрес слота отделён от списка обычных регистров GAS 0x3.
+ 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 == 8:
text += ' info=%02X code=%02X' % (body >> 8, body & 255)
diff --git a/python/protocan/README.md b/python/protocan/README.md
index bd9f1b4..b62e697 100644
--- a/python/protocan/README.md
+++ b/python/protocan/README.md
@@ -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, без настройки профиля.
diff --git a/python/protocan/gas_slots.py b/python/protocan/gas_slots.py
new file mode 100644
index 0000000..e2e19db
--- /dev/null
+++ b/python/protocan/gas_slots.py
@@ -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.
diff --git a/python/protocan/gas_watch.py b/python/protocan/gas_watch.py
new file mode 100644
index 0000000..f6ba056
--- /dev/null
+++ b/python/protocan/gas_watch.py
@@ -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-декодер.
diff --git a/python/protocan/protocan.py b/python/protocan/protocan.py
index 5a31d62..da3383e 100644
--- a/python/protocan/protocan.py
+++ b/python/protocan/protocan.py
@@ -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
diff --git a/python/protocan/protocol.py b/python/protocan/protocol.py
index 2b7ae73..c582de2 100644
--- a/python/protocan/protocol.py
+++ b/python/protocan/protocol.py
@@ -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
diff --git a/python/set_devices/qt_ports/can_frame_builder.py b/python/set_devices/qt_ports/can_frame_builder.py
index 0e99116..008428b 100644
--- a/python/set_devices/qt_ports/can_frame_builder.py
+++ b/python/set_devices/qt_ports/can_frame_builder.py
@@ -128,10 +128,12 @@ class CanFrameFields(QWidget): # Компонент содержит тольк
else: # Типы без отдельного подтипа используют Body по собственному назначению.
sub_label = "—" # Подпись явно показывает отсутствие подтипа.
arg_label = "Body (обычно 0)" # Для PULSE сохраняем исходное описание аргумента.
- if msg_type in (pc.MsgType.MODBUS_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # Все четыре вида Modbus задают начальный адрес.
+ 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 под адрес.
arg_label = "Адрес регистра (0..65535)" # Полный 16-битный диапазон указан в подписи.
+ elif msg_type == pc.MsgType.GAS_SLOT: # Новый тип 0x4 использует весь Body под номер слота, а не StrAdr/RegCount.
+ arg_label = "Адрес слота (0..65535), Data: маска + 3 слова" # Напоминаем оператору о Word0 с маской и фиксированных позициях трёх значений.
elif msg_type == pc.MsgType.ERROR: # Код ошибки вводится в отдельный байт Body.
arg_label = "ErrorCode (0..255)" # Показываем допустимый однобайтовый диапазон.
elif settings: # SETTINGS редактируется двухбайтовым компонентом.
@@ -146,7 +148,7 @@ 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_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # У четырёх видов Modbus одинаковый формат Body.
+ 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.ERROR: # Сохраняем существующую категорию ошибки с нулевым кодом источника.
return pc.merge_error(0, arg) # Размещение кода ошибки остаётся ответственностью общего ядра.
diff --git a/python/tests/test_gas_slots.py b/python/tests/test_gas_slots.py
new file mode 100644
index 0000000..cf3aec3
--- /dev/null
+++ b/python/tests/test_gas_slots.py
@@ -0,0 +1,63 @@
+import json # Общие машинные эталоны независимы от клиентского построителя кадра.
+from pathlib import Path # Находим спецификацию относительно репозитория.
+import unittest # Проверяем реальные ctypes-привязки без заглушки протокольного ядра.
+from protocan.gas_slots import GasSlots # Все правила wire-формата выполняет C, а не тестовая модель.
+from protocan import protocan as pc # Проверяем штатный CAN-декодер, который вызывают GUI и журналы.
+
+class GasSlotTests(unittest.TestCase): # Интеграционные проверки обоих типов GAS и границ адресного API.
+ def setUp(self): # Каждая проверка использует настоящую собранную shared-библиотеку.
+ self.codec = GasSlots() # Отсутствующая библиотека не заменяется Python fallback.
+
+ def test_vectors(self): # Проверяем ID, маску и три значения по нормативным независимым байтам.
+ path = Path(__file__).resolve().parents[2] / 'doc/setcan/protocan/examples/gas-slot-vectors.json' # Те же примеры опубликованы в документации.
+ vectors = json.loads(path.read_text(encoding='utf-8')) # UTF-8 сохраняет названия и описание профиля.
+ for item in vectors['vectors']: # Охватываем обе группы адресов и неполный последний слот PERIPH.
+ words = item.get('words', vectors['words']) # Последний слот имеет только два действующих значения.
+ expected = item.get('payload_hex', vectors['payload_hex']) # Его отдельный эталон не включает несуществующее третье значение.
+ can_id, data = self.codec.frame(item['mode'], item['local_slot']) # Пустой запрос не несёт служебной маски.
+ self.assertEqual((can_id, data), (int(item['request_id'], 16), b'')) # Тип 0x4 и адрес должны совпасть с фиксированным эталоном.
+ reply, payload = self.codec.frame(item['mode'], item['local_slot'], words, route=1) # Ответ явно содержит маску достоверности.
+ self.assertEqual(reply, int(item['response_id'], 16)) # Направление не меняет узел или слот.
+ self.assertEqual(payload.hex().upper(), expected) # Проверяем все восемь октетов, включая расположение Word0.
+ self.assertEqual(self.codec.decode(reply, payload), (item['mode'], item['local_slot'], tuple(words))) # Полный round-trip сохраняет маску отдельно от трёх значений.
+
+ def test_invalid(self): # Неверные координаты, маски и длины не должны стать другой командой.
+ for mode, slot in ((32, 0), (5, 67), (-1, 0), (2**32, 0)): # Верхняя граница страницы после маски — LocalSlot 66.
+ with self.assertRaises(ValueError): self.codec.frame(mode, slot) # Ошибка не маскируется до допустимого адреса.
+ for payload in (b'\0', b'\0' * 6, b'\0' * 9, b'\0' * 8): # В том числе запрещена полная запись с пустой маской.
+ with self.assertRaises(ValueError): self.codec.decode(0x19540500, payload) # Точный размер и Word0 проверяет C.
+ for mask in (0, 1, 0x10, 0xFFFF): # Бит 0 и старшие биты не адресуют данные слота.
+ with self.assertRaises(ValueError): self.codec.frame(5, 0, [mask, 1, 2, 3]) # Не заменяем отвергнутую маску полной записью.
+ with self.assertRaises(ValueError): self.codec.frame(5, 66, [14, 1, 2, 3]) # Третье значение неполного слота не существует.
+ with self.assertRaises(ValueError): self.codec.frame(5, 0, [2, 65536, 0, 0]) # Переполнение u16 не превращается в допустимый ноль.
+ can_id, payload = self.codec.frame(5, 0, [4, 0, 0x1234, 0], route=1) # Корректный частичный ответ для проверки транспортных флагов.
+ with self.assertRaises(ValueError): self.codec.decode(can_id, payload, flags=3) # RTR отвергается до обращения к provider.
+
+ def test_types_are_unambiguous(self): # Один и тот же payload получает разный смысл только по номеру типа.
+ can_id, payload = self.codec.frame(5, 0, [4, 0, 0x1234, 0], route=1) # Word2 выбран, Word1/Word3 передаются в фиксированных позициях и игнорируются.
+ slot = pc.decode(can_id, payload) # Никакого параметра gas_profile для типа 0x4 больше нет.
+ self.assertIn('slot=0x0500', slot.summary) # Адрес показывается в единицах слота.
+ self.assertIn('mask=0x0004', slot.summary) # В CAN-журнале видна частичная запись.
+ self.assertIsNone(slot.registers) # Старые потребители не получают фиктивный последовательный список регистров.
+ self.assertTrue(any('Word2' in name for name, value in slot.fields)) # Только выбранная позиция отображается как достоверное значение.
+ self.assertFalse(any('Word1' in name or 'Word3' in name for name, value in slot.fields)) # Неотмеченные поля не публикуются как измерения.
+ register = pc.decode(0x19530500, payload) # Тип 0x3 всегда интерпретируется как обычные последовательные слова.
+ self.assertEqual(register.registers, [(0x0500, 4), (0x0501, 0), (0x0502, 0x1234), (0x0503, 0)]) # Нулевое слово payload не является маской регистрового GAS.
+ self.assertTrue(pc.decode(can_id, payload + b'\0').warnings) # Девятый октет не теряется до проверки слотного формата.
+ overflow = pc.decode(0x1953FFFF, b'\x01\x00\x02\x00') # По максимальному адресу нельзя разместить два последовательных регистра.
+ self.assertTrue(overflow.warnings) # Ошибка диапазона должна быть видна в журнале.
+ self.assertIsNone(overflow.registers) # Некорректный адрес 0x10000 не становится источником тренда.
+
+ def test_generic_address_and_analyzer(self): # Тип 0x4 должен работать также за пределами PERIPH и его HEX-страниц.
+ can_id, payload = self.codec.frame_address(7, 14, 0xFFFF, [2, 0x5678, 0xDEAD, 0], route=1) # Неотмеченное значение сохраняется как обычный заполнитель и не является командой записи.
+ self.assertEqual(can_id, 0x1FE4FFFF) # Полный адрес FFFF принадлежит отдельному инвертору 7/E.
+ self.assertEqual(self.codec.decode_address(can_id, payload), (2, 0x5678, 0xDEAD, 0)) # Общий декодер проверяет маску, не навязывая страницу mode.
+ self.assertIn('slot=0xFFFF', pc.decode(can_id, payload).summary) # Штатный GUI-декодер использует общий адресный API.
+ with self.assertRaises(ValueError): self.codec.frame_address(7, 14, 0x10000) # Не допускаем оборачивания в слот ноль.
+ from logic_analyzer.decoders.set_can import legacy # Проверяем тот же публичный путь анализа аппаратного захвата.
+ decoded = legacy(can_id, payload) # Анализатор вызывает общий C-кодек слотного формата.
+ self.assertEqual(decoded['slot_words'], [(1, 0x5678)]) # Служебная маска и невыбранные слова не становятся данными анализатора.
+ self.assertNotIn('registers', decoded) # Не смешиваем координаты слота с плоским регистровым адресом.
+
+if __name__ == '__main__': # Поддерживаем прямой запуск без pytest.
+ unittest.main() # Любой отказ native-кодека отражается стандартным статусом тестов.
diff --git a/python/tests/test_gas_watch.py b/python/tests/test_gas_watch.py
new file mode 100644
index 0000000..4b5460a
--- /dev/null
+++ b/python/tests/test_gas_watch.py
@@ -0,0 +1,169 @@
+# Проверяем неизменяемые модели и настоящий C ABI GAS_WATCH2 на общих эталонных байтах.
+# Python не подменяет C-кодек: проверки wire-пакетов выполняются только с указанной SETPROTOCOL_LIBRARY.
+
+import ctypes # Загружаем настоящую общую библиотеку для проверки границы Python/C.
+import json # Читаем общий JSON-fixture, используемый также C- и JVM-проверками.
+import os # Путь к проверяемой сборке получает тестовый процесс через SETPROTOCOL_LIBRARY.
+import unittest # Запускаем воспроизводимые проверки модели, подписок и ошибочных данных.
+from dataclasses import FrozenInstanceError # Проверяем запрет изменения уже созданных моделей.
+from pathlib import Path # Находим эталон относительно исходников независимо от текущего каталога.
+
+from protocan.gas_watch import GasWatchConfig, GasWatchSample, NativeGasWatch # Проверяем публичный тонкий порт без доступа к другому Python-кодеку.
+from protocan.protocol import MessageType # Проверяем выделение новых типов сообщений без изменения старых значений.
+
+FIXTURE_PATH = Path(__file__).resolve().parents[2] / "c/set-protocol/tests/fixtures/gas-watch-v2.json" # Общие байты находятся рядом с C-тестами как единый контракт портов.
+
+
+class GasWatchModelTests(unittest.TestCase): # Эти проверки не требуют DLL, поскольку относятся к представлению моделей Python.
+ def test_models_copy_sequences_and_are_frozen(self): # Изменение исходных списков не должно менять ожидаемый ACK и принятую выборку.
+ addresses = [0, 1, 0xFF01] # Повторяем три разрозненных адреса из пользовательского примера.
+ config = GasWatchConfig(1, 100, addresses) # Переданный список должен быть независимо сохранён как кортеж.
+ addresses[0] = 99 # Моделируем последующее редактирование таблицы сигналов GUI.
+ self.assertEqual((0, 1, 0xFF01), config.addresses) # Проверяем сохранение исходного порядка подписки.
+ with self.assertRaises(FrozenInstanceError): # Идентификатор уже сформированной модели нельзя менять на месте.
+ config.config_id = 2 # Присваивание проверяет защиту frozen dataclass, а не изменение состояния устройства.
+ values = [0, None, 65535] # Корректный ноль и недостоверное значение должны оставаться различимыми.
+ sample = GasWatchSample(1, 2, 3, values) # Публичная модель также должна копировать изменяемый контейнер.
+ values[0] = 5 # Моделируем повторное использование входного списка вызывающим приложением.
+ self.assertEqual((0, None, 65535), sample.values) # Ранее созданная выборка не должна изменяться вслед за списком.
+ with self.assertRaises(FrozenInstanceError): # Метка времени проверенной выборки остаётся неизменяемой.
+ sample.timestamp_ms = 4 # Присваивание должно быть отклонено механизмом dataclass.
+
+ def test_integer_ranges_prevent_ctypes_narrowing(self): # Python обязан отвергать значения, которые ctypes незаметно усекло бы до C-типа.
+ for config_id in (0, -1, 0x100000000, True, 1.5): # Проверяем нулевой, отрицательный, переполненный и нецелые идентификаторы.
+ with self.subTest(config_id=config_id), self.assertRaises(ValueError): # Каждый ошибочный id должен отказать до вызова C.
+ GasWatchConfig(config_id, 100, (0,)) # Остальные поля допустимы и не маскируют проверяемую ошибку.
+ for period in (-1, 65536, True, 1.5): # Период представляет uint16_t в миллисекундах без преобразования bool и float.
+ with self.subTest(period=period), self.assertRaises(ValueError): # Проверяем все источники возможного сужения периода.
+ GasWatchConfig(1, period, (0,)) # Валидный идентификатор и адрес изолируют неверный период.
+ for address in (-1, 65536, True, 1.5): # GAS-регистр задаётся uint16_t, а не произвольным Python-числом.
+ with self.subTest(address=address), self.assertRaises(ValueError): # Любой недопустимый элемент массива должен отказать.
+ GasWatchConfig(1, 100, (address,)) # Один адрес позволяет проверить границу независимо от числа каналов.
+ with self.assertRaises(ValueError): # Подписка не должна превышать общий предел 64 каналов.
+ GasWatchConfig(1, 100, tuple(range(65))) # 65 уникальных допустимых адресов изолируют ограничение размера.
+ with self.assertRaises(ValueError): # Значения публичной модели также не должны выходить за 16 бит.
+ GasWatchSample(1, 2, 3, (65536,)) # Отдельная ошибочная величина проверяет защиту модели результата.
+
+ def test_old_library_has_explicit_rebuild_error(self): # Устаревшая библиотека должна дать понятную ошибку без Python fallback.
+ with self.assertRaisesRegex(RuntimeError, "set_gas_watch.c"): # Сообщение должно назвать недостающую реализацию для пересборки.
+ NativeGasWatch(object()) # Объект без новых ABI-символов воспроизводит отсутствие функции в старой DLL.
+
+ def test_message_types_preserve_legacy_values(self): # Новые wire-типы не должны переопределять существующий GAS_WATCH.
+ self.assertEqual((0x12, 0x13), (MessageType.GAS_WATCH_SET, MessageType.GAS_WATCH_DATA)) # Сохраняем совместимость старого профиля v1.
+ self.assertEqual((0x14, 0x15), (MessageType.GAS_WATCH2_SET, MessageType.GAS_WATCH2_DATA)) # Выделенные типы однозначно выбирают новый формат.
+
+
+@unittest.skipUnless(os.environ.get("SETPROTOCOL_LIBRARY"), "Укажите SETPROTOCOL_LIBRARY для проверки настоящего C-ядра") # Без явного пути не подменяем C тестовым Python-кодеком.
+class NativeGasWatchTests(unittest.TestCase): # Проверяем реальные builder, ACK-валидатор и DATA-декодер общей библиотеки.
+ @classmethod # Одну DLL и общий fixture безопасно переиспользуют отдельные экземпляры адаптера каждого теста.
+ def setUpClass(cls): # Загружаем внешний контракт один раз до тестов C ABI.
+ cls.library = ctypes.CDLL(os.environ["SETPROTOCOL_LIBRARY"]) # Указанный путь должен вести к сборке с set_gas_watch.c.
+ cls.fixture = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) # Эталонные данные читаются в обязательной кодировке UTF-8.
+ cls.request_bytes = bytes.fromhex(cls.fixture["request_hex"]) # Фиксированные байты отделяют ожидаемый wire-вектор от реализации C.
+ cls.sample_bytes = bytes.fromhex(cls.fixture["sample_hex"]) # DATA проверяется теми же байтами, что и другие языковые порты.
+
+ def setUp(self): # Изолируем pending и active между независимыми сценариями.
+ self.core = NativeGasWatch(self.library) # Каждый тест начинает с отсутствующей подтверждённой конфигурацией.
+ self.config = GasWatchConfig(self.fixture["config_id"], self.fixture["period_ms"], self.fixture["addresses"]) # Сохраняем порядок общих трёх адресов.
+
+ def activate(self): # Повторяем нормальный цикл отправки конфигурации и её точного подтверждения.
+ payload = self.core.request(self.config) # Запрос формирует настоящий C-builder.
+ self.core.validate_ack(payload, self.config) # Полное эхо устройства проверяет настоящий C ACK-валидатор.
+
+ def test_shared_fixture_request_ack_and_sample(self): # Основной сценарий фиксирует совместимость байтов и семантику признаков достоверности.
+ self.assertEqual(self.request_bytes, self.core.request(self.config)) # Сопоставляем результат C-builder с общим независимым эталоном.
+ with self.assertRaises(RuntimeError): # DATA до ACK не имеет подтверждённого соответствия каналам.
+ self.core.sample(self.sample_bytes) # Даже корректный пакет должен быть отвергнут до активации конфигурации.
+ self.core.validate_ack(self.request_bytes, self.config) # Активируем подписку только по полному точному эху.
+ sample = self.core.sample(self.sample_bytes) # C проверяет config_id, количество, длину, padding и значения invalid-каналов.
+ expected_values = tuple(value if valid else None for value, valid in zip(self.fixture["values"], self.fixture["valid"])) # Преобразуем семантику fixture, не декодируя wire-пакет.
+ self.assertEqual(GasWatchSample(self.config.config_id, self.fixture["sequence"], self.fixture["timestamp_ms"], expected_values), sample) # Сверяем всю неизменяемую модель результата.
+ zero_sample = bytes.fromhex("0200030078563412040302014433221105000000003412") # Фиксированный вектор содержит достоверный ноль, invalid-ноль и слово 0x1234.
+ self.assertEqual((0, None, 0x1234), self.core.sample(zero_sample).values) # Недостоверное значение не должно превращаться в отображаемый ноль.
+
+ def test_native_request_rejects_duplicates_and_inconsistent_stop(self): # Логические ограничения протокола должны проверяться C, а не вторым валидатором Python.
+ self.activate() # Действующая подписка нужна для проверки сохранения состояния при отказе builder.
+ invalid = (GasWatchConfig(2, 100, (0, 0)), GasWatchConfig(2, 0, (0,)), GasWatchConfig(2, 100, ())) # Проверяем дубликаты и оба несовместимых сочетания периода с количеством.
+ for config in invalid: # Каждая ошибочная конфигурация должна быть отвергнута независимо от предыдущей попытки.
+ with self.subTest(config=config), self.assertRaises(ValueError): # Отказ C-builder преобразуется в явную ошибку Python.
+ self.core.request(config) # Никакой неправильный запрос не должен менять активную подписку.
+ self.assertEqual(self.config.config_id, self.core.sample(self.sample_bytes).config_id) # После отказа прежние подтверждённые DATA продолжают приниматься.
+ stop = GasWatchConfig(self.config.config_id + 1, 0, ()) # Пустой список с нулевым периодом представляет правильную команду отключения.
+ self.assertEqual(bytes.fromhex("020000007956341200000000"), self.core.request(stop)) # C-builder должен поддерживать отдельный идентификатор остановки.
+ self.core.validate_ack(bytes.fromhex("020000007956341200000000"), stop) # Полный ACK пустой конфигурации также должен проходить C-проверку.
+
+ def test_ack_checks_full_configuration_and_preserves_active(self): # Равного количества каналов недостаточно для принятия подтверждения.
+ self.activate() # Создаём активную подписку, которая должна пережить ошибочный ACK следующей команды.
+ changed = GasWatchConfig(self.config.config_id + 1, 200, (0xFF01, 1, 0)) # Новый порядок того же числа адресов получает новый идентификатор.
+ changed_request = self.core.request(changed) # Pending меняется, а действующий профиль остаётся активным до ACK.
+ self.assertEqual(self.config.config_id, self.core.sample(self.sample_bytes).config_id) # Старые DATA разрешены, пока устройство не подтвердило переключение.
+ for supplied in (self.config, changed): # Отдельно проверяем поздний ACK и пакет, не соответствующий новой конфигурации.
+ with self.subTest(supplied=supplied), self.assertRaises(ValueError): # Ни один вариант не должен активировать неподтверждённую подписку.
+ self.core.validate_ack(self.request_bytes, supplied) # Вариант old отвергает pending-проверка, вариант new — C-сравнение эха.
+ altered = bytearray(changed_request) # Моделируем ACK с тем же id, периодом и count, но другим порядком адресов.
+ altered[-2:] = b"\x02\x00" # Меняем один адрес в конце вектора, не затрагивая геометрию ответа.
+ with self.assertRaises(ValueError): # Полное сравнение адресов должно обнаружить подмену при прежнем количестве каналов.
+ self.core.validate_ack(bytes(altered), changed) # C проверяет весь список и не разрешает частичную подписку.
+ self.core.validate_ack(changed_request, changed) # Корректное полное эхо атомарно переводит pending в active.
+ with self.assertRaises(ValueError): # Пакет прежнего id после успешного переключения становится устаревшим.
+ self.core.sample(self.sample_bytes) # Количество осталось тем же, поэтому проверка должна опираться на идентификатор.
+ new_sample = bytes.fromhex("0200030079563412fffffffff0ffffff037b00feff0000") # Фиксированный DATA-вектор относится к новому config_id 0x12345679.
+ self.assertEqual(changed.config_id, self.core.sample(new_sample).config_id) # Новый подтверждённый id разрешает данные именно новой конфигурации.
+
+ def test_malformed_ack_and_sample_do_not_poison_subscription(self): # Ошибки отдельных пакетов не должны разрушать рабочую подписку.
+ self.core.request(self.config) # Pending необходим для проверки точного формата первого подтверждения.
+ for payload in (self.request_bytes[:-1], self.request_bytes + b"\x00"): # Отсечённые и дополненные ACK имеют неправильную длину.
+ with self.subTest(ack=payload), self.assertRaises(ValueError): # C должен отвергнуть и неполное, и нестрогое эхо.
+ self.core.validate_ack(payload, self.config) # Pending сохраняется, позволяя принять следующий корректный ACK.
+ self.core.validate_ack(self.request_bytes, self.config) # Корректное подтверждение после ошибок всё ещё активирует исходную подписку.
+ malformed = ( # Список фиксированных искажений покрывает геометрию, достоверность и привязку к конфигурации.
+ self.sample_bytes[:-1], # Отбрасываем последнее слово частично: общая длина больше не соответствует count.
+ self.sample_bytes + b"\x00", # Лишний байт также нарушает точную геометрию пакета.
+ bytes.fromhex("0100030078563412fffffffff0ffffff037b00feff0000"), # Версия 1 не должна интерпретироваться как новый формат версии 2.
+ bytes.fromhex("0200030079563412fffffffff0ffffff037b00feff0000"), # Другой id с прежним count моделирует поздние DATA другой подписки.
+ bytes.fromhex("0200020078563412fffffffff0ffffff037b00feff"), # Корректная геометрия двух значений не совпадает с тремя ожидаемыми адресами.
+ bytes.fromhex("0200030078563412fffffffff0ffffff837b00feff0000"), # Старший padding-бит bitmap для трёх каналов обязан быть нулём.
+ bytes.fromhex("0200030078563412fffffffff0ffffff037b00feff0100"), # Значение invalid-канала обязано быть нулём, иначе данные неоднозначны.
+ ) # Закрываем перечень независимых отрицательных векторов C-декодера.
+ for payload in malformed: # Проверяем каждый отказ с тем же неизменным состоянием подписки.
+ with self.subTest(data=payload), self.assertRaises(ValueError): # Ни один некорректный пакет не должен превращаться в модель.
+ self.core.sample(payload) # Все проверки структуры пакета выполняются общим C-декодером.
+ self.assertEqual(self.config.config_id, self.core.sample(self.sample_bytes).config_id) # Следующий корректный пакет доказывает сохранение active после ошибки.
+
+ def test_failed_decode_keeps_all_c_outputs_unchanged(self): # Проверяем обещание ABI не публиковать частичные результаты при ошибке пакета.
+ data = (ctypes.c_uint8 * len(self.sample_bytes)).from_buffer_copy(self.sample_bytes) # Передаём эталонные байты, изменяя только ожидаемый id C-вызова.
+ sequence = ctypes.c_uint32(11) # Отличимый sentinel обнаруживает запись счётчика до окончания проверки.
+ timestamp = ctypes.c_uint32(22) # Независимый sentinel защищает проверку метки времени.
+ values = (ctypes.c_uint16 * 3)(31, 32, 33) # Отличимые слова обнаруживают частичное заполнение выходного массива.
+ valid = (ctypes.c_uint8 * 3)(41, 42, 43) # Признаки также не должны перезаписываться при устаревшем id.
+ result = self.core.lib.set_gas_watch_decode(data, len(data), self.config.config_id + 1, 3, ctypes.byref(sequence), ctypes.byref(timestamp), values, valid, 3) # Проверяем настоящий C ABI без обёртки создания модели.
+ self.assertEqual(-1, result) # Несовпадающий expected_config_id обязан отвергнуть даже корректно сформированный пакет.
+ self.assertEqual((11, 22, (31, 32, 33), (41, 42, 43)), (sequence.value, timestamp.value, tuple(values), tuple(valid))) # Каждый выход должен сохранить исходный sentinel.
+
+ def test_pending_replacement_id_reuse_and_reset(self): # Проверяем гонки нескольких запросов и очистку состояния при переподключении.
+ self.activate() # Ранее подтверждённая конфигурация задаёт исходный active.
+ changed = GasWatchConfig(self.config.config_id + 1, 100, (1, 0, 0xFF01)) # Первый новый порядок начинает ожидать подтверждение.
+ self.core.request(changed) # Старый active остаётся доступным, пока новый pending не подтверждён.
+ newest = GasWatchConfig(self.config.config_id + 2, 100, (0xFF01, 0, 1)) # Следующая команда заменяет ожидаемый список ещё до первого ACK.
+ newest_request = self.core.request(newest) # Актуальным pending становится только последний запрос.
+ with self.assertRaises(ValueError): # ACK уже заменённого pending не должен возвращать старое ожидание.
+ self.core.validate_ack(self.request_bytes, changed) # Проверка pending срабатывает до анализа неподходящего payload.
+ for reused in (GasWatchConfig(self.config.config_id, 200, self.config.addresses), GasWatchConfig(newest.config_id, 200, newest.addresses)): # Active и pending id нельзя переиспользовать с другим содержимым.
+ with self.subTest(reused=reused), self.assertRaises(ValueError): # Такая подмена лишила бы DATA возможности определить порядок значений.
+ self.core.request(reused) # Ошибка не должна уничтожать последний ожидаемый запрос.
+ self.core.validate_ack(newest_request, newest) # Последний корректный pending всё ещё подтверждается после отвергнутых попыток.
+ self.core.reset() # Переподключение удаляет как ожидаемую, так и подтверждённую подписку.
+ with self.assertRaises(RuntimeError): # Старая выборка после reset требует нового цикла подтверждения.
+ self.core.sample(self.sample_bytes) # Данные прошлого соединения не должны отображаться автоматически.
+ with self.assertRaises(ValueError): # Ни один ACK прошлого соединения больше не ожидается.
+ self.core.validate_ack(newest_request, newest) # Reset запрещает позднее восстановление прежнего active.
+
+ def test_maximum_subscription_uses_real_c_builder(self): # Проверяем безопасную передачу полного массива из 64 каналов через ctypes.
+ config = GasWatchConfig(0xFFFFFFFF, 65535, tuple(range(64))) # Используем максимальные id и период при допустимом числе уникальных адресов.
+ payload = self.core.request(config) # Размер payload определяет настоящий C-builder в общем транспортном буфере.
+ self.assertEqual(140, len(payload)) # Версия 2 задаёт 12 байт заголовка и 64 двухбайтных адреса, всего 140 байт.
+ self.core.validate_ack(payload, config) # Полное эхо максимального массива проверяется без усечения и частичного принятия.
+
+
+if __name__ == "__main__": # Разрешаем запуск этого набора проверок напрямую наряду с unittest discovery.
+ unittest.main() # Стандартный runner сообщает результаты, ошибки DLL и пропущенные C-проверки.