diff --git a/README.md b/README.md index 963e27e..6cbd444 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ templates/ ### Python +Экспериментальный [`c/gas-logger`](c/gas-logger/README.md): непрерывная запись +в два банка RAM, снимки через GAS/Modbus/ProtoCAN, JSON-карта имён и адресов, +проектный порт ПМ35 и MCU-порты C28x / STM32. Клиент в `set_devices.gas_logger`. + | Модуль | Что делает | Зависимости | |---|---|---| | [`python/setprotocol/firmware_database.py`](python/setprotocol/firmware_database.py) | база прошивок: HTTPS-каталог, скачивание с SHA-256 и кэшем, публикация в Gitea без SETGUI; [подключение и CLI](tools/firmware-publish/DATABASE.md) | stdlib, Python 3.10+ | diff --git a/c/gas-logger/CMakeLists.txt b/c/gas-logger/CMakeLists.txt new file mode 100644 index 0000000..cfa4bdf --- /dev/null +++ b/c/gas-logger/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.16) +project(gas_logger C) +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) +add_library(gas_logger src/gas_logger.c) +target_include_directories(gas_logger PUBLIC include) +add_library(gas_logger_pm35 ports/pm35/gas_logger_pm35.c) +target_include_directories(gas_logger_pm35 PUBLIC ports/pm35) +target_link_libraries(gas_logger_pm35 PUBLIC gas_logger) +add_library(gas_logger_pcan src/gas_logger_pcan.c) +target_include_directories(gas_logger_pcan PUBLIC ../set-protocol/include) +target_link_libraries(gas_logger_pcan PUBLIC gas_logger) +add_executable(test_gas_logger tests/test_gas_logger.c) +target_link_libraries(test_gas_logger PRIVATE gas_logger) +enable_testing() +add_test(NAME gas_logger_core COMMAND test_gas_logger) diff --git a/c/gas-logger/README.md b/c/gas-logger/README.md new file mode 100644 index 0000000..f17c4db --- /dev/null +++ b/c/gas-logger/README.md @@ -0,0 +1,177 @@ +# GAS Logger — экспериментальная версия 1 + +Непрерывный регистратор с двумя кольцевыми банками, неизменяемым снимком и +общей картой GAS / ProtoCAN GAS / Modbus FC03/FC06. Весь код и проектные/MCU +адаптеры находятся в `templates`; приложения подключают ревизию сабмодулем. +Ядро C99 не содержит HAL, malloc, глобальных переменных, драйвера связи или +прерываний. Хранение — RAM, после отключения питания данные исчезают. + +Приложение → проектный источник сигналов → `gas_logger` → MCU-порт блокировки. +GAS/Modbus/CAN диспетчеры → тот же экземпляр `gas_logger`. + +## Состав + +| Файл | Назначение / зависимости | +|---|---| +| `include/gas_logger.h`, `src/gas_logger.c` | переносимое ядро, только стандартный C99 | +| `ports/pm35/` | источники ПМ35 и кэш напряжений ПМ67, сгенерированная карта | +| `ports/c28x/` | сохранение/восстановление IRQ для TI F2812/F28335 | +| `ports/stm32/` | PRIMASK для STM32 F1/F4/G4, CMSIS проекта | +| `src/gas_logger_pcan.c` | регион сервиса `pcan_gas`, только MCU с поддержкой `uint8_t` | +| `../../python/set_devices/gas_logger.py` | валидация карты, генератор C, клиент снимков; stdlib | +| `../../python/set_devices/gas_logger_maps/pm35.json` | имена, адреса GAS/CAN/Modbus, источники и регистры сервиса | +| `tests/` | ядро и сквозной тест клиента с настоящим C-регистратором | + +## JSON и адреса + +JSON — исходное описание. `ports/pm35/pm35_map.h` — автоматически созданная +копия для прошивки. `tools/generate_map.py --check` проверяет их соответствие. +Изменения выполняются в JSON, затем запускается генератор. CRC32 канонического +JSON публикуется платой: клиент откажется читать/записывать при несовпадении +карты. Это идентификатор совместимости, не механизм аутентификации. + +Все адреса — **нулевые индексы 16-битных регистров**, без префикса 4xxxx. +В поле `can.address` указан MsgBody для **ProtoCAN GAS**, а не полный CAN ID. +DeviceType/DeviceID/Route выбирает транспорт проекта. Идентификаторы legacy +CAN ПМ35 и ПМ67 не переименовываются в GAS: проект сначала обновляет их кэш, +после чего регистратор читает его через `source`. + +Экспериментальный профиль ПМ35: + +| GAS | Modbus FC03 | ProtoCAN GAS | Имя | Источник | +|---|---|---|---|---| +| 0x5000 | 0x5000 | 0x5000 | Команды УМП | modbus[127] | +| 0x5001 | 0x5001 | 0x5001 | Ток, 0.1 мА | modbus[28] | +| 0x5002 | 0x5002 | 0x5002 | Код ЦАП | modbus[123] | +| 0x5003 | 0x5003 | 0x5003 | Начальная уставка | modbus[80] | +| 0x5004 | 0x5004 | 0x5004 | Конечная уставка | modbus[81] | +| 0x5005 | 0x5005 | 0x5005 | ЗПТ1 | pm67_voltage[0], ранее принятый CAN адрес 305 | +| 0x5006 | 0x5006 | 0x5006 | ЗПТ2 | pm67_voltage[1], ранее принятый CAN адрес 8 | + +Адреса 0x5000/0x6000 — выделение **для тестового профиля**, не утверждение о +поддержке этих окон существующей прошивкой. Перед включением в другую карту +проект обязан проверить отсутствие пересечений со своими регионами. + +## Карта сервиса + +База одинакова для всех трёх транспортов: `0x6000`; имена и смещения также +перечислены в JSON. Многословные числа: сначала младшее 16-битное слово. +Endian байтов задаёт транспорт: Modbus BE, ProtoCAN GAS LE. + +| Смещение | Доступ | Значение | +|---|---|---| +| 0, 1 | R | сигнатура 0x474C, версия 1 | +| 2 | R | бит 0 запись, бит 1 снимок закреплён | +| 3..5 | R | каналов, слов в записи, ёмкость банка | +| 6, 7 | R | записей в текущем банке / снимке | +| 8..9 | R | поколение снимка u32 | +| 10..11 | R | CRC32 JSON-карты | +| 12..13 | R | следующий номер записи u32 | +| 14..15 | R | пропущенные снимки: запрос во время закреплённого снимка | +| 16..17 | R | ошибки источников при записи | +| 18 | W | 1 START, 2 SNAPSHOT, 3 RELEASE | +| 19..20 | W | поколение, разрешённое к освобождению | +| 0x100.. | R | записи снимка по прямому адресу, без команд курсора | + +Запись: `time_ms:u32, sequence:u32, event:u16, channels[N]:u16`. +В профиле ПМ35 — 12 слов, 760 записей на банк, всего 18240 слов RAM для двух +банков. Блочное чтение разрешено по любой границе, максимум 125 слов. +Клиент собирает записи из блоков, в том числе неполных по размеру записи. +Классический CAN использует до 4 слов в кадре. Для UART подходит 120/125 слов. +Для 760 записей этого профиля требуется 76 чтений по 120 слов без записи +курсора; состав полей отличается от старого 42-словного УМП, поэтому это не +прямое измерение ускорения прежнего лога. + +## Непрерывная запись и снимки + +`START` идемпотентен: включает запись, не очищает историю. STOP-команды нет. +`SNAPSHOT` кратко блокирует IRQ, переключает активный банк и закрепляет старый. +Во время загрузки новые записи продолжают поступать в другой кольцевой банк. +Снимок не перезаписывается, пока клиент не передаст совпадающее поколение и +`RELEASE`. Одновременные управляющие клиенты не поддерживаются. + +Повторный SNAPSHOT при занятом снимке возвращает BUSY и увеличивает счётчик. +Пустой банк возвращает EMPTY. Долгий обрыв связи не останавливает запись, но +закреплённый снимок остаётся занят: нужно явно продолжить чтение (`resume=True`) +либо освободить его. Автоматического таймаута освобождения нет. +После переключения новый банк начинает историю с нуля; непрерывность означает +продолжение сбора, а не дублирование предыстории между снимками. + +Триггер/постинтервал определяет проект. Например, ПМ35 вызывает `gl_snapshot()` +по завершении своей секунды после отключения, а не при начале события. +Этот модуль не заменяет автоматически старую логику УМП. + +## Контракт порта + +```c +uint32_t enter(void *user); /* сохранить и запретить IRQ */ +void leave(void *user, uint32_t previous); /* восстановить ровно previous */ +int source(void *user, uint16_t gas, uint16_t *value); +``` + +`source` выполняется под блокировкой: только быстрые чтения RAM. Ни запросов +CAN/Modbus, ни ожиданий периферии. Ошибка любого канала отбрасывает всю запись. +Кэш CAN обновляется под той же блокировкой. Для RTOS/многоядерных платформ +нужен порт с подходящей межпоточной синхронизацией; PRIMASK — для одного ядра. +На стеке блочного чтения до 125 слов; при захвате до 64 слов каналов. +Массивы банков не должны перекрываться друг с другом и контекстом. + +## Подключение ПМ35 / F28335 + +```c +#include "gas_logger_pm35.h" +#include "gas_logger_c28x.h" +static gas_logger logger; +static uint16_t bank0[9120], bank1[9120]; /* разместить в RAM по linker map */ +static gl_pm35_context source; +void logger_init(volatile uint16_t *modbus, volatile uint16_t *pm67) { + source.modbus = modbus; source.pm67_voltage = pm67; + source.enter = gl_c28x_enter; source.leave = gl_c28x_leave; + source.irq_user = 0; + if (gl_pm35_init(&logger, &source, bank0, bank1, 9120) == GL_OK) + gl_write(&logger, 0x6012, GL_START); +} +/* Из существующего таймера: gl_capture(&logger, time_ms, event); */ +``` + +Включить `src/gas_logger.c`, `ports/pm35/gas_logger_pm35.c` и +`ports/c28x/gas_logger_c28x.c`. Include-пути — `include`, `ports/pm35`, +`ports/c28x`. F2812/F28335 используют одни intrinsics TI. +Для STM32 подключить `ports/stm32/gas_logger_stm32.c`, выбрать +`GL_STM32_DEVICE_HEADER` (`stm32f1xx.h`/`stm32f4xx.h`/`stm32g4xx.h`) и передать +`gl_stm32_enter/leave`. Проектный PM35-адаптер применим к STM-эмулятору только +если его массивы соответствуют указанному контракту. + +FC03 диспетчер вызывает `gl_modbus_read()`, FC06 — `gl_modbus_write()`. +GL_ADDRESS → exception 2, GL_ARGUMENT → 3, GL_BUSY → 6, остальные ошибки → 4. +Ответ на запись отправляется лишь после GL_OK. CRC/кадры остаются в транспорте. +Для `pcan_gas` добавляется регион `gl_pcan_region_init()`; сигнальные адреса +маршрутизируются отдельно в `gl_can_read()` (или уже существующие GAS-регионы). +Стандартные GAS-записи CAN не имеют ACK: CAN-адаптер клиента должен подтвердить +результат чтением статуса; просто отправить CAN-кадр недостаточно. +Передача UART/CAN выполняется **после** снятия IRQ-блокировки. + +## Клиент и проверка + +```python +from set_devices.gas_logger import GasLoggerClient +client = GasLoggerClient(read_registers, write_register, block_words=120) +client.start() # после проверки сигнатуры и JSON CRC +records = client.download() # фиксирует, читает, проверяет, освобождает +``` + +Вызовы транспорта синхронные, подтверждённые; для GUI запускать в его рабочем +потоке. Исключение/отмена оставляет снимок закреплённым для явного продолжения. +Записи с таймаутом автоматически не повторяются. + +```powershell +python c/gas-logger/tools/generate_map.py --check +python c/gas-logger/tests/run_tests.py --cc C:/msys64/mingw64/bin/gcc.exe +``` + +Либо `cmake -S c/gas-logger -B `, сборка и `ctest --test-dir `. +Ветка `codex/test-gas-logger`: потребитель SETGUI импортирует модуль из +`third_party/templates` и включает JSON в пакет. Производственные прошивки +ПМ35/166 пока не переключены; адреса не включены в их текущие диспетчеры. +Нужны подключение к таймеру/карте конкретной платы, проверка linker RAM и +испытание на устройстве. Проверки на хосте не измеряют аппаратные тайминги. diff --git a/c/gas-logger/include/gas_logger.h b/c/gas-logger/include/gas_logger.h new file mode 100644 index 0000000..a7eba35 --- /dev/null +++ b/c/gas-logger/include/gas_logger.h @@ -0,0 +1,70 @@ +/* Portable continuous recorder. All addresses/counts are 16-bit WORDS, + * including on C28x (CHAR_BIT=16). No heap, UART, HAL or global state. */ +#ifndef GAS_LOGGER_H +#define GAS_LOGGER_H +#include +#ifdef __cplusplus +extern "C" { +#endif +#define GL_MAGIC 0x474CU +#define GL_VERSION 1U +#define GL_HEADER_WORDS 5U /* time_ms:u32, sequence:u32, event:u16, values... */ +#define GL_DATA_OFFSET 0x100U +#define GL_MAX_CHANNELS 64U +#define GL_COMMAND 18U +#define GL_RELEASE_LO 19U +#define GL_RELEASE_HI 20U +#define GL_START 1U +#define GL_SNAPSHOT 2U +#define GL_RELEASE 3U +typedef enum { GL_OK=0, GL_ADDRESS, GL_ARGUMENT, GL_BUSY, GL_EMPTY, + GL_STALE, GL_SOURCE_ERROR } gl_result; +typedef struct { + uint16_t gas, modbus, can; +} gl_channel; +typedef struct { + uint16_t base, channels, capacity; + uint32_t schema_id; + const gl_channel *map; +} gl_config; +/* enter returns the previous IRQ state; leave MUST restore it, not enable + * IRQs unconditionally. source executes under this lock: bounded RAM reads + * only, never I/O/waits. Cache remote CAN values before sampling. */ +typedef struct { + void *user; + uint32_t (*enter)(void *user); + void (*leave)(void *user, uint32_t state); + int (*source)(void *user, uint16_t gas, uint16_t *value); +} gl_port; +typedef struct { + gl_config config; + gl_port port; + uint16_t *banks[2]; + uint16_t active, count, next, recording, pinned, snap_count, snap_next; + uint32_t generation, release_generation, sequence, missed, source_errors; +} gas_logger; +/* Each distinct bank must have >=capacity*(channels+5) uint16_t elements. + * All callbacks and a nonoverlapping register map are required. */ +gl_result gl_init(gas_logger *log, const gl_config *config, const gl_port *port, + uint16_t *bank0, uint16_t *bank1, uint32_t bank_words); +/* Called by the project timer/event hook. A failed source discards the whole + * sample. Sequence/time remain uint32, wrapping modulo 2^32. */ +gl_result gl_capture(gas_logger *log, uint32_t time_ms, uint16_t event); +/* Snapshot swaps banks, pins the old one, and NEVER stops acquisition. + * Another snapshot while pinned returns BUSY and increments missed. */ +gl_result gl_snapshot(gas_logger *log); +/* Atomic, all-or-nothing block read. Snapshot records are chronological; + * reads past snap_count are rejected, not zero-filled. */ +gl_result gl_read(gas_logger *log, uint16_t address, uint16_t *out, uint16_t count); +/* No STOP/reset command. START is idempotent. RELEASE requires the generation + * staged in base+19/20. One controlling client per device. */ +gl_result gl_write(gas_logger *log, uint16_t address, uint16_t value); +/* Register aliases for FC03/06 and ProtoCAN GAS. Transports keep their own + * framing/CRC; addresses are zero-based and words preserve their values. */ +gl_result gl_modbus_read(gas_logger *log, uint16_t address, uint16_t *out, uint16_t count); +gl_result gl_modbus_write(gas_logger *log, uint16_t address, uint16_t value); +gl_result gl_can_read(gas_logger *log, uint16_t address, uint16_t *out, uint16_t count); +#ifdef __cplusplus +} +#endif +#endif diff --git a/c/gas-logger/include/gas_logger_pcan.h b/c/gas-logger/include/gas_logger_pcan.h new file mode 100644 index 0000000..52b7e1d --- /dev/null +++ b/c/gas-logger/include/gas_logger_pcan.h @@ -0,0 +1,8 @@ +#ifndef GAS_LOGGER_PCAN_H +#define GAS_LOGGER_PCAN_H +#include "gas_logger.h" +#include "pcan_gas.h" +/* Service region; signal aliases may be registered separately in the project's + * GAS dispatcher. CAN callbacks use the logger's alias map. */ +void gl_pcan_region_init(pcan_gas_region_t *region,gas_logger *log); +#endif diff --git a/c/gas-logger/ports/c28x/gas_logger_c28x.c b/c/gas-logger/ports/c28x/gas_logger_c28x.c new file mode 100644 index 0000000..1a5136b --- /dev/null +++ b/c/gas-logger/ports/c28x/gas_logger_c28x.c @@ -0,0 +1,4 @@ +#include "gas_logger_c28x.h" +/* __disable_interrupts/__restore_interrupts are TI C28x compiler intrinsics. */ +uint32_t gl_c28x_enter(void *user) { (void)user; return __disable_interrupts(); } +void gl_c28x_leave(void *user,uint32_t state) { (void)user; __restore_interrupts((unsigned int)state); } diff --git a/c/gas-logger/ports/c28x/gas_logger_c28x.h b/c/gas-logger/ports/c28x/gas_logger_c28x.h new file mode 100644 index 0000000..a38d6b7 --- /dev/null +++ b/c/gas-logger/ports/c28x/gas_logger_c28x.h @@ -0,0 +1,7 @@ +#ifndef GAS_LOGGER_C28X_H +#define GAS_LOGGER_C28X_H +#include +/* TI C2000 compiler; F2812/F28335. Supply these as gl_port lock callbacks. */ +uint32_t gl_c28x_enter(void *user); +void gl_c28x_leave(void *user,uint32_t state); +#endif diff --git a/c/gas-logger/ports/pm35/gas_logger_pm35.c b/c/gas-logger/ports/pm35/gas_logger_pm35.c new file mode 100644 index 0000000..fd109bc --- /dev/null +++ b/c/gas-logger/ports/pm35/gas_logger_pm35.c @@ -0,0 +1,22 @@ +#include "gas_logger_pm35.h" +#include "pm35_map.h" +static uint32_t enter(void *u) { gl_pm35_context *p=u; return p->enter(p->irq_user); } +static void leave(void *u,uint32_t state) { gl_pm35_context *p=u; p->leave(p->irq_user,state); } +static int source(void *u,uint16_t gas,uint16_t *v) +{ + gl_pm35_context *p=u; + uint16_t i; + for(i=0;ipm67_voltage[gl_pm35_source_address[i]] : + p->modbus[gl_pm35_source_address[i]]; + return 1; + } + return 0; +} +gl_result gl_pm35_init(gas_logger *l,gl_pm35_context *p,uint16_t *a,uint16_t *b,uint32_t n) +{ + gl_port port; + if(!p || !p->modbus || !p->pm67_voltage || !p->enter || !p->leave) return GL_ARGUMENT; + port.user=p;port.enter=enter;port.leave=leave;port.source=source; + return gl_init(l,&gl_pm35_config,&port,a,b,n); +} diff --git a/c/gas-logger/ports/pm35/gas_logger_pm35.h b/c/gas-logger/ports/pm35/gas_logger_pm35.h new file mode 100644 index 0000000..e6ce6a6 --- /dev/null +++ b/c/gas-logger/ports/pm35/gas_logger_pm35.h @@ -0,0 +1,16 @@ +#ifndef GAS_LOGGER_PM35_H +#define GAS_LOGGER_PM35_H +#include "gas_logger.h" +/* Project adapter: pm35_modbus uses the firmware's zero-based modbus[128]. + * pm67_voltage[2] is the latest CAN cache, updated under the same IRQ lock. + * No direct CAN requests are performed while capturing. */ +typedef struct { + volatile uint16_t *modbus; + volatile uint16_t *pm67_voltage; + uint32_t (*enter)(void *); + void (*leave)(void *,uint32_t); + void *irq_user; +} gl_pm35_context; +gl_result gl_pm35_init(gas_logger *log, gl_pm35_context *context, + uint16_t *bank0, uint16_t *bank1, uint32_t bank_words); +#endif diff --git a/c/gas-logger/ports/pm35/pm35_map.h b/c/gas-logger/ports/pm35/pm35_map.h new file mode 100644 index 0000000..f0cfefa --- /dev/null +++ b/c/gas-logger/ports/pm35/pm35_map.h @@ -0,0 +1,19 @@ +/* Generated from pm35.json. Do not edit. */ +#ifndef GL_PM35_MAP_H +#define GL_PM35_MAP_H +#include "gas_logger.h" +static const gl_channel gl_pm35_channels[] = { + {20480U, 20480U, 20480U}, /* commands */ + {20481U, 20481U, 20481U}, /* current */ + {20482U, 20482U, 20482U}, /* dac */ + {20483U, 20483U, 20483U}, /* go */ + {20484U, 20484U, 20484U}, /* stop */ + {20485U, 20485U, 20485U}, /* zpt1 */ + {20486U, 20486U, 20486U}, /* zpt2 */ +}; +static const uint16_t gl_pm35_source_space[] = { 0U, 0U, 0U, 0U, 0U, 1U, 1U }; +static const uint16_t gl_pm35_source_address[] = { 127U, 28U, 123U, 80U, 81U, 0U, 1U }; +static const gl_config gl_pm35_config = { + 24576U, 7U, 760U, 0x635F84CBUL, gl_pm35_channels +}; +#endif diff --git a/c/gas-logger/ports/stm32/gas_logger_stm32.c b/c/gas-logger/ports/stm32/gas_logger_stm32.c new file mode 100644 index 0000000..6c0822d --- /dev/null +++ b/c/gas-logger/ports/stm32/gas_logger_stm32.c @@ -0,0 +1,8 @@ +#include "gas_logger_stm32.h" +#ifndef GL_STM32_DEVICE_HEADER +#define GL_STM32_DEVICE_HEADER "stm32f4xx.h" +#endif +#include GL_STM32_DEVICE_HEADER +uint32_t gl_stm32_enter(void *user) +{ uint32_t saved; (void)user; saved=__get_PRIMASK(); __disable_irq(); return saved; } +void gl_stm32_leave(void *user,uint32_t state) { (void)user; __set_PRIMASK(state); } diff --git a/c/gas-logger/ports/stm32/gas_logger_stm32.h b/c/gas-logger/ports/stm32/gas_logger_stm32.h new file mode 100644 index 0000000..c42cb6d --- /dev/null +++ b/c/gas-logger/ports/stm32/gas_logger_stm32.h @@ -0,0 +1,8 @@ +#ifndef GAS_LOGGER_STM32_H +#define GAS_LOGGER_STM32_H +#include +/* Define GL_STM32_DEVICE_HEADER to the project's CMSIS device header. + * F1/F4/G4 use the same PRIMASK contract. No HAL/interrupt handler ownership. */ +uint32_t gl_stm32_enter(void *user); +void gl_stm32_leave(void *user,uint32_t state); +#endif diff --git a/c/gas-logger/src/gas_logger.c b/c/gas-logger/src/gas_logger.c new file mode 100644 index 0000000..6dd68e1 --- /dev/null +++ b/c/gas-logger/src/gas_logger.c @@ -0,0 +1,161 @@ +#include "gas_logger.h" +#include +#include + +static uint16_t width(const gas_logger *l) { return l->config.channels + GL_HEADER_WORDS; } +static int service(const gl_config *c, uint16_t a) +{ + uint32_t end = (uint32_t)c->base + GL_DATA_OFFSET + + (uint32_t)c->capacity * (c->channels + GL_HEADER_WORDS); + return a >= c->base && (uint32_t)a < end; +} +gl_result gl_init(gas_logger *l, const gl_config *c, const gl_port *p, + uint16_t *b0, uint16_t *b1, uint32_t words) +{ + uint16_t i,j; + uint32_t needed; + if (!l || !c || !p || !b0 || !b1 || b0 == b1 || !c->map || + !p->enter || !p->leave || !p->source || !c->capacity || + !c->channels || c->channels > GL_MAX_CHANNELS) return GL_ARGUMENT; + needed = (uint32_t)c->capacity * (c->channels + GL_HEADER_WORDS); + if (needed > words || (uint32_t)c->base + GL_DATA_OFFSET + needed > 65536UL) + return GL_ARGUMENT; + for (i=0;ichannels;i++) { + if (service(c,c->map[i].gas) || service(c,c->map[i].modbus) || + service(c,c->map[i].can)) return GL_ARGUMENT; + for (j=0;jmap[i].gas == c->map[j].gas || + c->map[i].modbus == c->map[j].modbus || + c->map[i].can == c->map[j].can) return GL_ARGUMENT; + } + memset(l,0,sizeof(*l)); l->config=*c; l->port=*p; l->banks[0]=b0; l->banks[1]=b1; + return GL_OK; +} +gl_result gl_capture(gas_logger *l, uint32_t time, uint16_t event) +{ + uint16_t values[GL_MAX_CHANNELS],i,*record; + uint32_t irq = l->port.enter(l->port.user); + if (!l->recording) { l->port.leave(l->port.user,irq); return GL_BUSY; } + for (i=0;iconfig.channels;i++) { + if (!l->port.source(l->port.user,l->config.map[i].gas,&values[i])) { + l->source_errors++; l->port.leave(l->port.user,irq); return GL_SOURCE_ERROR; + } + } + record=l->banks[l->active]+(uint32_t)l->next*width(l); + record[0]=(uint16_t)time; record[1]=(uint16_t)(time>>16); + record[2]=(uint16_t)l->sequence; record[3]=(uint16_t)(l->sequence>>16); + record[4]=event; + for (i=0;iconfig.channels;i++) record[GL_HEADER_WORDS+i]=values[i]; + l->sequence++; + if (++l->next == l->config.capacity) l->next=0; + if (l->count < l->config.capacity) l->count++; + l->port.leave(l->port.user,irq); return GL_OK; +} +static gl_result snapshot(gas_logger *l) +{ + if (l->pinned) { l->missed++; return GL_BUSY; } + if (!l->count) return GL_EMPTY; + l->snap_count=l->count; l->snap_next=l->next; + l->active^=1U; l->count=0; l->next=0; l->pinned=1; + l->generation++; if (!l->generation) l->generation=1; + return GL_OK; +} +gl_result gl_snapshot(gas_logger *l) +{ + uint32_t irq=l->port.enter(l->port.user); + gl_result r=snapshot(l); l->port.leave(l->port.user,irq); return r; +} +static uint16_t status(const gas_logger *l, uint16_t offset) +{ + switch(offset) { + case 0:return GL_MAGIC; case 1:return GL_VERSION; + case 2:return l->recording | (l->pinned<<1); + case 3:return l->config.channels; case 4:return width(l); + case 5:return l->config.capacity; case 6:return l->count; case 7:return l->snap_count; + case 8:return (uint16_t)l->generation; case 9:return (uint16_t)(l->generation>>16); + case 10:return (uint16_t)l->config.schema_id; case 11:return (uint16_t)(l->config.schema_id>>16); + case 12:return (uint16_t)l->sequence; case 13:return (uint16_t)(l->sequence>>16); + case 14:return (uint16_t)l->missed; case 15:return (uint16_t)(l->missed>>16); + case 16:return (uint16_t)l->source_errors; case 17:return (uint16_t)(l->source_errors>>16); + default:return 0; + } +} +static gl_result read_locked(gas_logger *l, uint16_t a, uint16_t *out, uint16_t n) +{ + uint16_t i,j,slot; + uint32_t off; + if (a>=l->config.base && (uint32_t)a+n <= (uint32_t)l->config.base+18) { + for(i=0;iconfig.base)); + return GL_OK; + } + if ((uint32_t)a >= (uint32_t)l->config.base+GL_DATA_OFFSET) { + off=(uint32_t)a-l->config.base-GL_DATA_OFFSET; + if (l->pinned && off+n <= (uint32_t)l->snap_count*width(l)) { + for(i=0;isnap_count==l->config.capacity) + slot=(uint16_t)(((uint32_t)slot+l->snap_next)%l->config.capacity); + out[i]=l->banks[l->active^1U][(uint32_t)slot*width(l)+(off+i)%width(l)]; + } + return GL_OK; + } + } + /* A live signal block must be completely mapped before any read. */ + for(i=0;iconfig.channels;j++) if(l->config.map[j].gas==a+i) break; + if(j==l->config.channels) return GL_ADDRESS; + } + for(i=0;iport.source(l->port.user,(uint16_t)(a+i),&out[i])) return GL_SOURCE_ERROR; + return GL_OK; +} +gl_result gl_read(gas_logger *l, uint16_t a, uint16_t *out, uint16_t n) +{ + uint16_t temp[125],i; + uint32_t irq; + gl_result r; + if(!out || !n || n>125 || (uint32_t)a+n>65536UL) return GL_ARGUMENT; + irq=l->port.enter(l->port.user); r=read_locked(l,a,temp,n); + if(r==GL_OK) for(i=0;iport.leave(l->port.user,irq); return r; +} +gl_result gl_write(gas_logger *l, uint16_t a, uint16_t v) +{ + uint32_t irq=l->port.enter(l->port.user); + gl_result r=GL_OK; + if((uint32_t)a==(uint32_t)l->config.base+GL_RELEASE_LO) + l->release_generation=(l->release_generation&0xFFFF0000UL)|v; + else if((uint32_t)a==(uint32_t)l->config.base+GL_RELEASE_HI) + l->release_generation=(l->release_generation&0xFFFFUL)|((uint32_t)v<<16); + else if((uint32_t)a!=(uint32_t)l->config.base+GL_COMMAND) r=GL_ADDRESS; + else if(v==GL_START) l->recording=1; + else if(v==GL_SNAPSHOT) r=snapshot(l); + else if(v==GL_RELEASE) { + if(!l->pinned || l->release_generation!=l->generation) r=GL_STALE; + else { l->pinned=0; l->snap_count=0; } + } else r=GL_ARGUMENT; + l->port.leave(l->port.user,irq); return r; +} +static gl_result alias_read(gas_logger *l,uint16_t a,uint16_t *out,uint16_t n,int can) +{ + uint16_t i,j,gas=0,found; + if(!n || n>125 || (uint32_t)a+n>65536UL) return GL_ARGUMENT; + if(service(&l->config,a)) return gl_read(l,a,out,n); + for(i=0;iconfig.channels;j++) + if((can?l->config.map[j].can:l->config.map[j].modbus)==a+i) { + if(!i) gas=l->config.map[j].gas; + if((uint32_t)gas+i!=l->config.map[j].gas) return GL_ADDRESS; + found=1;break; + } + if(!found) return GL_ADDRESS; + } + return gl_read(l,gas,out,n); +} +gl_result gl_modbus_read(gas_logger *l,uint16_t a,uint16_t *o,uint16_t n) +{ return alias_read(l,a,o,n,0); } +gl_result gl_can_read(gas_logger *l,uint16_t a,uint16_t *o,uint16_t n) +{ return alias_read(l,a,o,n,1); } +gl_result gl_modbus_write(gas_logger *l,uint16_t a,uint16_t v) +{ return gl_write(l,a,v); } diff --git a/c/gas-logger/src/gas_logger_pcan.c b/c/gas-logger/src/gas_logger_pcan.c new file mode 100644 index 0000000..1f316a3 --- /dev/null +++ b/c/gas-logger/src/gas_logger_pcan.c @@ -0,0 +1,13 @@ +#include "gas_logger_pcan.h" +static pcan_gas_status_t result(gl_result r) +{ return r==GL_OK ? PCAN_GAS_OK : r==GL_ADDRESS ? PCAN_GAS_NO_REG : PCAN_GAS_REJECTED; } +static pcan_gas_status_t read_word(const pcan_gas_region_t *r,uint16_t o,uint16_t *v) +{ return result(gl_can_read(r->user,(uint16_t)(r->base+o),v,1)); } +static pcan_gas_status_t write_word(const pcan_gas_region_t *r,uint16_t o,uint16_t v) +{ return result(gl_write(r->user,(uint16_t)(r->base+o),v)); } +void gl_pcan_region_init(pcan_gas_region_t *r,gas_logger *l) +{ + r->base=l->config.base; + r->count=(uint16_t)(GL_DATA_OFFSET+(uint32_t)l->config.capacity*(l->config.channels+GL_HEADER_WORDS)); + r->storage=0;r->read=read_word;r->write=write_word;r->flags=0;r->user=l;r->name="gas-logger"; +} diff --git a/c/gas-logger/tests/host_device.c b/c/gas-logger/tests/host_device.c new file mode 100644 index 0000000..e221b8b --- /dev/null +++ b/c/gas-logger/tests/host_device.c @@ -0,0 +1,22 @@ +/* Host-only firmware harness for the actual PM35 port and Python client. */ +#include "gas_logger_pm35.h" +#include "gas_logger_pcan.h" +#include +static gas_logger log; +static uint16_t bank0[9120],bank1[9120],modbus[128],voltage[2]; +static uint32_t irq; +static uint32_t enter(void *u) { uint32_t old=irq;(void)u;irq=1;return old; } +static void leave(void *u,uint32_t state) { (void)u;assert(irq==1);irq=state; } +static gl_pm35_context context={modbus,voltage,enter,leave,0}; +int host_init(void) { irq=0;return gl_pm35_init(&log,&context,bank0,bank1,9120); } +int host_capture(uint32_t time,uint16_t value) +{ modbus[28]=value;modbus[127]=128;voltage[0]=1234;voltage[1]=5678;return gl_capture(&log,time,1); } +int host_read(uint16_t address,uint16_t *out,uint16_t count) +{ return gl_modbus_read(&log,address,out,count); } +int host_write(uint16_t address,uint16_t value) { return gl_modbus_write(&log,address,value); } +int host_pcan_read(uint16_t address,uint16_t *out) +{ + pcan_gas_region_t region;gl_pcan_region_init(®ion,&log); + if(address=region.base+region.count) return GL_ADDRESS; + return region.read(®ion,(uint16_t)(address-region.base),out); +} diff --git a/c/gas-logger/tests/run_tests.py b/c/gas-logger/tests/run_tests.py new file mode 100644 index 0000000..d12fd16 --- /dev/null +++ b/c/gas-logger/tests/run_tests.py @@ -0,0 +1,46 @@ +"""Build in a temporary directory; run core + Python/C end-to-end tests.""" +import argparse +import ctypes +import importlib.util +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + +parser=argparse.ArgumentParser() +parser.add_argument('--cc',default=shutil.which('gcc')) +args=parser.parse_args() +if not args.cc:raise SystemExit('Specify --cc /path/to/gcc') +root=Path(__file__).resolve().parents[1] +templates=root.parents[1] +sys.path.insert(0,str(templates/'python')) +env=dict(os.environ) +env['PATH']=str(Path(args.cc).resolve().parent)+os.pathsep+env.get('PATH','') +flags=[args.cc,'-std=c99','-Wall','-Wextra','-Werror','-pedantic','-I'+str(root/'include')] +with tempfile.TemporaryDirectory(prefix='gas-logger-') as directory: + binary=Path(directory)/'core.exe' + subprocess.run(flags+[str(root/'src/gas_logger.c'),str(root/'tests/test_gas_logger.c'),'-o',str(binary)],check=True,env=env) + subprocess.run([str(binary)],check=True,env=env) + lib=Path(directory)/('device.dll' if os.name=='nt' else 'device.so') + subprocess.run(flags+([] if os.name=='nt' else ['-fPIC'])+['-shared','-I'+str(root/'ports/pm35'), + '-I'+str(templates/'c/set-protocol/include'), + str(root/'src/gas_logger.c'),str(root/'src/gas_logger_pcan.c'), + str(root/'ports/pm35/gas_logger_pm35.c'),str(root/'tests/host_device.c'), + '-o',str(lib)],check=True,env=env) + # Child releases the loaded DLL before TemporaryDirectory cleanup on Windows. + runner='''import ctypes,importlib.util,sys,unittest +spec=importlib.util.spec_from_file_location('test_client',sys.argv[1]) +m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +m.DEVICE=ctypes.CDLL(sys.argv[2]) +m.DEVICE.host_capture.argtypes=[ctypes.c_uint32,ctypes.c_uint16] +m.DEVICE.host_read.argtypes=[ctypes.c_uint16,ctypes.POINTER(ctypes.c_uint16),ctypes.c_uint16] +m.DEVICE.host_write.argtypes=[ctypes.c_uint16,ctypes.c_uint16] +m.DEVICE.host_pcan_read.argtypes=[ctypes.c_uint16,ctypes.POINTER(ctypes.c_uint16)] +r=unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromModule(m)) +sys.exit(not r.wasSuccessful())''' + env['PYTHONPATH']=str(templates/'python') + subprocess.run([sys.executable,'-c',runner,str(root/'tests/test_client.py'),str(lib)],check=True,env=env) +subprocess.run([sys.executable,str(root/'tools/generate_map.py'),'--check'],check=True,env=env) diff --git a/c/gas-logger/tests/test_client.py b/c/gas-logger/tests/test_client.py new file mode 100644 index 0000000..bb8a2e0 --- /dev/null +++ b/c/gas-logger/tests/test_client.py @@ -0,0 +1,111 @@ +"""Integration: Python client -> register callbacks -> compiled C PM35 logger.""" +import copy +import ctypes +import unittest +from set_devices.gas_logger import GasLoggerClient, load_map, validate_map + +DEVICE = None # set by run_tests.py before loading the suite + + +class ClientTests(unittest.TestCase): + def setUp(self): + self.lib=DEVICE + self.assertEqual(self.lib.host_init(),0) + self.client=GasLoggerClient(self.read,self.write) + self.client.start() + for i in range(800): + self.assertEqual(self.lib.host_capture(i,i),0) + + def read(self,address,count): + out=(ctypes.c_uint16*count)() + result=self.lib.host_read(address,out,count) + if result: raise RuntimeError('read '+str(result)) + return list(out) + + def write(self,address,value): + result=self.lib.host_write(address,value) + if result: raise RuntimeError('write '+str(result)) + + def test_download_keeps_recording_and_order(self): + def progress(done,total): + for i in range(20): self.assertEqual(self.lib.host_capture(900+done+i,99),0) + records=self.client.download(progress=progress) + self.assertEqual([r['time_ms'] for r in records],list(range(40,800))) + self.assertEqual([r['current'] for r in records],list(range(40,800))) + self.assertTrue(all(r['zpt2']==5678 for r in records)) + self.assertEqual(self.client.status()[2],1) + self.assertGreater(self.client.status()[6],0) + + def test_cancel_retains_snapshot_and_explicit_resume(self): + cancel=[False] + def progress(done,total): cancel[0]=True + with self.assertRaises(InterruptedError): + self.client.download(cancelled=lambda:cancel[0],progress=progress) + self.assertEqual(self.client.status()[2],3) + with self.assertRaises(RuntimeError): self.client.download() + self.assertEqual(len(self.client.download(resume=True)),760) + + def test_partial_reply_leaves_pin(self): + original=self.client.read + self.client.read=lambda a,n: original(a,n)[:-1] if a>=0x6100 else original(a,n) + with self.assertRaises(ValueError): self.client.download() + self.client.read=original + self.assertEqual(self.client.status()[2],3) + + def test_no_writes_for_wrong_schema(self): + m=load_map();m['channels'][0]['name']='Other' + writes=[] + client=GasLoggerClient(self.read,lambda *a:writes.append(a),m) + with self.assertRaises(ValueError): client.download() + self.assertEqual(writes,[]) + + def test_generation_mismatch_does_not_release(self): + self.write(0x6012,2) + with self.assertRaises(ValueError):self.client.release(999) + self.assertEqual(self.client.status()[2],3) + + def test_classic_can_chunks_and_pcan_region(self): + def read(a,n): + words=[] + for i in range(n): + out=ctypes.c_uint16() + self.assertEqual(self.lib.host_pcan_read(a+i,ctypes.byref(out)),0) + words.append(out.value) + return words + client=GasLoggerClient(read,self.write,block_words=4) + self.assertEqual(len(client.download()),760) + + def test_json_rejects_collisions_and_drift(self): + base=load_map() + for change in ( + lambda m:m['channels'][0].update(gas=0x6000), + lambda m:m['channels'][1].update(gas=m['channels'][0]['gas']), + lambda m:m['service'].update(data_offset=512), + lambda m:m['service']['registers'][0].update(key='wrong'), + lambda m:m.update(capacity=65535), + ): + m=copy.deepcopy(base);change(m) + with self.assertRaises(ValueError):validate_map(m) + + def test_replaced_snapshot_is_rejected_before_release(self): + changed=[False] + def progress(done,total): + if changed[0]:return + changed[0]=True + self.client.release(1) + self.assertEqual(self.lib.host_capture(2000,999),0) + self.write(0x6012,2) + # Simulates a second, unsupported controlling client. We must never + # accept a mixture of generations or release the newer snapshot. + with self.assertRaises((ValueError,RuntimeError)): + self.client.download(progress=progress) + status=self.client.status() + self.assertEqual(status[2],3) + self.assertEqual(status[8],2) + + def test_start_does_not_reset_history(self): + before=self.client.status() + self.client.start() + after=self.client.status() + self.assertEqual(before[6],after[6]) + self.assertEqual(before[12:14],after[12:14]) diff --git a/c/gas-logger/tests/test_gas_logger.c b/c/gas-logger/tests/test_gas_logger.c new file mode 100644 index 0000000..3582be3 --- /dev/null +++ b/c/gas-logger/tests/test_gas_logger.c @@ -0,0 +1,54 @@ +#include "gas_logger.h" +#include +#include +typedef struct { uint32_t irq; uint16_t value; int fail; } board; +static uint32_t enter(void *u) { board *b=u; uint32_t old=b->irq; b->irq=1; return old; } +static void leave(void *u,uint32_t state) { board *b=u; assert(b->irq==1); b->irq=state; } +static int source(void *u,uint16_t a,uint16_t *v) +{ board *b=u; assert(b->irq==1); if(b->fail && a==11) return 0; *v=b->value+a; return 1; } +int main(void) +{ + gl_channel channels[]={{10,100,200},{11,101,201}}; + gl_config c={0x6000,2,3,0x12345678UL,channels}; + board b={0,0,0}; + gl_port p={&b,enter,leave,source}; + gas_logger l; + uint16_t bank0[21],bank1[21],out[125],i; + assert(gl_init(&l,&c,&p,bank0,bank1,20)==GL_ARGUMENT); + assert(gl_init(&l,&c,&p,bank0,bank1,21)==GL_OK); + assert(gl_snapshot(&l)==GL_EMPTY); + assert(gl_capture(&l,0,0)==GL_BUSY); + assert(gl_write(&l,0x6012,GL_START)==GL_OK); + for(i=0;i<5;i++) { b.value=i; assert(gl_capture(&l,i,2)==GL_OK); } + assert(gl_snapshot(&l)==GL_OK && l.recording && l.count==0); + assert(gl_read(&l,0x6100,out,21)==GL_OK); + assert(out[0]==2 && out[7]==3 && out[14]==4 && out[5]==12); + for(i=5;i<15;i++) { b.value=i; assert(gl_capture(&l,i,3)==GL_OK); } + assert(gl_snapshot(&l)==GL_BUSY && l.missed==1 && l.recording); + assert(gl_read(&l,0x6100,out,21)==GL_OK && out[0]==2 && out[14]==4); + assert(gl_read(&l,0x6114,out,2)==GL_ADDRESS); + assert(gl_read(&l,65535,out,2)==GL_ARGUMENT); + assert(gl_write(&l,0x6012,GL_RELEASE)==GL_STALE); + assert(gl_write(&l,0x6013,1)==GL_OK); + assert(gl_write(&l,0x6014,0)==GL_OK); + assert(gl_write(&l,0x6012,GL_RELEASE)==GL_OK && l.recording); + assert(gl_read(&l,0x6100,out,1)==GL_ADDRESS); + assert(gl_snapshot(&l)==GL_OK); + assert(gl_read(&l,0x6100,out,21)==GL_OK && out[0]==12 && out[14]==14); + assert(gl_modbus_read(&l,100,out,2)==GL_OK && out[0]==24 && out[1]==25); + assert(gl_can_read(&l,200,out,2)==GL_OK && out[0]==24 && out[1]==25); + b.fail=1; out[0]=555;out[1]=666; + assert(gl_read(&l,10,out,2)==GL_SOURCE_ERROR && out[0]==555 && out[1]==666); + assert(gl_capture(&l,15,0)==GL_SOURCE_ERROR && l.count==0 && l.source_errors==1); + b.fail=0;b.irq=1; + assert(gl_capture(&l,16,0)==GL_OK && b.irq==1); + b.irq=0;l.sequence=0xffffffffUL; + assert(gl_capture(&l,0xffffffffUL,0)==GL_OK && l.sequence==0); + assert(gl_write(&l,0x6012,0)==GL_ARGUMENT && l.recording); + c.base=0xff00; + assert(gl_init(&l,&c,&p,bank0,bank1,21)==GL_ARGUMENT); + c.base=0x6000;channels[1].can=200; + assert(gl_init(&l,&c,&p,bank0,bank1,21)==GL_ARGUMENT); + assert(b.irq==0); + puts("GAS logger core: PASS");return 0; +} diff --git a/c/gas-logger/tools/generate_map.py b/c/gas-logger/tools/generate_map.py new file mode 100644 index 0000000..692f8c2 --- /dev/null +++ b/c/gas-logger/tools/generate_map.py @@ -0,0 +1,19 @@ +"""Regenerate/verify the firmware mirror of the packaged JSON map.""" +from pathlib import Path +import argparse +import sys +root = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(root / 'python')) +from set_devices.gas_logger import load_map, c_header + +parser = argparse.ArgumentParser() +parser.add_argument('--check', action='store_true') +args = parser.parse_args() +target = root / 'c/gas-logger/ports/pm35/pm35_map.h' +expected = c_header(load_map()) +if args.check: + if not target.exists() or target.read_text(encoding='utf-8') != expected: + raise SystemExit('pm35_map.h differs from JSON; run generate_map.py') +else: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(expected, encoding='utf-8') diff --git a/python/pyproject.toml b/python/pyproject.toml index bd1277b..72f8fab 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -18,4 +18,5 @@ where = ["."] include = ["firmware_image*", "altera_logic*", "logic_analyzer*", "set_devices*", "protocan*", "setprotocol*"] [tool.setuptools.package-data] +set_devices = ["gas_logger_maps/*.json"] protocan = ["native/*.dll", "native/*.so", "native/*.dylib"] diff --git a/python/set_devices/gas_logger.py b/python/set_devices/gas_logger.py new file mode 100644 index 0000000..1aff229 --- /dev/null +++ b/python/set_devices/gas_logger.py @@ -0,0 +1,190 @@ +"""Experimental GAS recorder map and transport-independent snapshot client. + +read(address, count) -> words; write(address, value) must await a confirmed +success or raise. Run download in the transport's worker, never a Qt UI slot. +No retries of writes: a timed-out snapshot may already exist on the device. +""" +from __future__ import annotations +import json +import re +import zlib +from pathlib import Path + +MAGIC, VERSION = 0x474C, 1 +DATA_OFFSET, HEADER_WORDS = 0x100, 5 + + +def validate_map(data): + if data.get('format') != 'set-gas-logger' or data.get('version') != VERSION: + raise ValueError('Unsupported GAS logger map') + def integer(value, low, high): + if type(value) is not int or not low <= value <= high: + raise ValueError('Invalid map integer') + return value + base = integer(data['service']['base'], 0, 65535) + if data['service']['data_offset'] != DATA_OFFSET: + raise ValueError('Invalid snapshot data offset') + capacity = integer(data['capacity'], 1, 65535) + channels = data['channels'] + if not 1 <= len(channels) <= 64: + raise ValueError('Invalid channel count') + end = base + DATA_OFFSET + capacity * (len(channels) + HEADER_WORDS) + if end > 65536: + raise ValueError('Logger window exceeds GAS') + seen = {key: set() for key in ('key', 'gas', 'modbus', 'can')} + for channel in channels: + if not re.fullmatch(r'[a-z][a-z0-9_]*', channel['key']): + raise ValueError('Invalid channel key') + if not isinstance(channel['name'], str) or not channel['name'].strip(): + raise ValueError('Missing channel name') + if channel['type'] not in ('u16', 'i16'): + raise ValueError('Unsupported channel type') + if channel['can']['protocol'] != 'protocan_gas': + raise ValueError('CAN mapping must use ProtoCAN GAS') + if channel['modbus']['function'] != 3: + raise ValueError('Modbus mapping must use FC03') + for key in seen: + value = channel[key] + if key in ('modbus', 'can'): + value = value['address'] + if key != 'key': + integer(value, 0, 65535) + if base <= value < end: + raise ValueError('Signal overlaps logger service') + if value in seen[key]: + raise ValueError('Duplicate ' + key) + seen[key].add(value) + source = channel['source'] + if source['space'] not in ('pm35_modbus', 'pm67_cache'): + raise ValueError('Unknown PM35 source') + integer(source['address'], 0, 127 if source['space'] == 'pm35_modbus' else 1) + expected = ['magic', 'version', 'flags', 'channels', 'record_words', 'capacity', + 'live_count', 'snapshot_count', 'generation_lo', 'generation_hi', + 'schema_lo', 'schema_hi', 'sequence_lo', 'sequence_hi', + 'missed_lo', 'missed_hi', 'source_errors_lo', 'source_errors_hi', + 'command', 'release_generation_lo', 'release_generation_hi'] + registers = data['service']['registers'] + if [(r['offset'], r['key'], r['access']) for r in registers] != [ + (i, key, 'r' if i < 18 else 'w') for i, key in enumerate(expected)]: + raise ValueError('Service map does not match protocol version 1') + return data + + +def load_map(path=None): + path = Path(path) if path else Path(__file__).with_name('gas_logger_maps') / 'pm35.json' + return validate_map(json.loads(path.read_text(encoding='utf-8'))) + + +def schema_id(mapping): + validate_map(mapping) + raw = json.dumps(mapping, ensure_ascii=True, sort_keys=True, separators=(',', ':')).encode('ascii') + return zlib.crc32(raw) & 0xffffffff + + +def c_header(mapping): + """Generate the checked-in C mirror; --check in CI catches drift.""" + validate_map(mapping) + lines = ['/* Generated from pm35.json. Do not edit. */', + '#ifndef GL_PM35_MAP_H', '#define GL_PM35_MAP_H', '#include "gas_logger.h"', + 'static const gl_channel gl_pm35_channels[] = {'] + for c in mapping['channels']: + lines.append(' {%dU, %dU, %dU}, /* %s */' % ( + c['gas'], c['modbus']['address'], c['can']['address'], c['key'])) + lines += ['};', 'static const uint16_t gl_pm35_source_space[] = { ' + ', '.join( + '0U' if c['source']['space'] == 'pm35_modbus' else '1U' for c in mapping['channels']) + ' };', + 'static const uint16_t gl_pm35_source_address[] = { ' + ', '.join( + str(c['source']['address'])+'U' for c in mapping['channels'])+' };', + 'static const gl_config gl_pm35_config = {', + ' %dU, %dU, %dU, 0x%08XUL, gl_pm35_channels' % ( + mapping['service']['base'], len(mapping['channels']), mapping['capacity'], schema_id(mapping)), + '};', '#endif', ''] + return '\n'.join(lines) + + +def _u32(words, offset): + return words[offset] | words[offset + 1] << 16 + + +class GasLoggerClient: + def __init__(self, read, write, mapping=None, *, block_words=120): + self.mapping = load_map() if mapping is None else validate_map(mapping) + if not 1 <= block_words <= 125: + raise ValueError('Block size must be 1..125 words') + self.read, self.write, self.block_words = read, write, block_words + self.base = self.mapping['service']['base'] + + def _read(self, address, count): + result = tuple(self.read(address, count)) + if len(result) != count or any(type(w) is not int or not 0 <= w <= 65535 for w in result): + raise ValueError('Incomplete or invalid GAS response') + return result + + def status(self): + # Classic CAN may return only four words. Generation and geometry are + # stable with one control client; the sequence/live counters are diagnostic. + words = tuple(w for offset in range(0, 18, self.block_words) + for w in self._read(self.base + offset, min(self.block_words, 18-offset))) + width = len(self.mapping['channels']) + HEADER_WORDS + if (words[:2] != (MAGIC, VERSION) or words[3] != len(self.mapping['channels']) + or words[4] != width or words[5] != self.mapping['capacity'] + or _u32(words, 10) != schema_id(self.mapping)): + raise ValueError('Device logger/schema does not match JSON map') + if words[2] & ~3 or words[6] > words[5] or words[7] > words[5]: + raise ValueError('Invalid logger status') + return words + + def start(self): + self.status() # never write to an unrecognized device + self.write(self.base+18, 1) + + def release(self, generation): + status = self.status() + if not status[2] & 2 or _u32(status, 8) != generation: + raise ValueError('Snapshot generation changed') + self.write(self.base+19, generation & 0xffff) + self.write(self.base+20, generation >> 16) + self.write(self.base+18, 3) + + def download(self, *, resume=False, cancelled=lambda: False, progress=lambda done,total: None): + """Pin/copy/verify/release; cancellation/errors retain the pin for resume. + + resume=True explicitly takes over the retained snapshot. A link failure + during release may have released it already; inspect status, don't replay. + """ + status = self.status() + if cancelled(): + raise InterruptedError('Snapshot download cancelled') + if status[2] & 2: + if not resume: + raise RuntimeError('Snapshot already pinned; resume or release explicitly') + else: + if resume: + raise RuntimeError('No retained snapshot to resume') + self.write(self.base+18, 2) + status = self.status() + if not status[2] & 2 or not status[7]: + raise ValueError('Device did not pin a nonempty snapshot') + generation, count, width = _u32(status, 8), status[7], status[4] + words = [] + total = count * width + while len(words) < total: + if cancelled(): + raise InterruptedError('Snapshot download cancelled; snapshot retained') + words.extend(self._read(self.base+DATA_OFFSET+len(words), min(self.block_words,total-len(words)))) + progress(len(words),total) + final = self.status() + if not final[2] & 2 or _u32(final,8) != generation or final[7] != count: + raise ValueError('Snapshot changed during download') + records = [] + for offset in range(0,total,width): + record = {'time_ms': _u32(words,offset), 'sequence': _u32(words,offset+2), 'event': words[offset+4]} + if records and record['sequence'] != ((records[-1]['sequence']+1) & 0xffffffff): + raise ValueError('Snapshot record sequence is not contiguous') + for i,c in enumerate(self.mapping['channels']): + v=words[offset+HEADER_WORDS+i] + record[c['key']] = v-65536 if c['type']=='i16' and v>=32768 else v + records.append(record) + if cancelled(): + raise InterruptedError('Snapshot download cancelled; snapshot retained') + self.release(generation) + return records diff --git a/python/set_devices/gas_logger_maps/pm35.json b/python/set_devices/gas_logger_maps/pm35.json new file mode 100644 index 0000000..220fbbd --- /dev/null +++ b/python/set_devices/gas_logger_maps/pm35.json @@ -0,0 +1,274 @@ +{ + "format": "set-gas-logger", + "version": 1, + "project": "pm35", + "mcu_ports": [ + "tms320f2812", + "tms320f28335", + "stm32f1", + "stm32f4", + "stm32g4" + ], + "address_convention": "zero_based_16_bit_registers", + "capacity": 760, + "service": { + "base": 24576, + "data_offset": 256, + "registers": [ + { + "offset": 0, + "key": "magic", + "name": "Сигнатура", + "access": "r" + }, + { + "offset": 1, + "key": "version", + "name": "Версия", + "access": "r" + }, + { + "offset": 2, + "key": "flags", + "name": "Флаги записи и снимка", + "access": "r" + }, + { + "offset": 3, + "key": "channels", + "name": "Количество каналов", + "access": "r" + }, + { + "offset": 4, + "key": "record_words", + "name": "Слов в записи", + "access": "r" + }, + { + "offset": 5, + "key": "capacity", + "name": "Ёмкость банка", + "access": "r" + }, + { + "offset": 6, + "key": "live_count", + "name": "Записей в текущем банке", + "access": "r" + }, + { + "offset": 7, + "key": "snapshot_count", + "name": "Записей в снимке", + "access": "r" + }, + { + "offset": 8, + "key": "generation_lo", + "name": "Поколение: младшее слово", + "access": "r" + }, + { + "offset": 9, + "key": "generation_hi", + "name": "Поколение: старшее слово", + "access": "r" + }, + { + "offset": 10, + "key": "schema_lo", + "name": "CRC карты: младшее слово", + "access": "r" + }, + { + "offset": 11, + "key": "schema_hi", + "name": "CRC карты: старшее слово", + "access": "r" + }, + { + "offset": 12, + "key": "sequence_lo", + "name": "Номер записи: младшее слово", + "access": "r" + }, + { + "offset": 13, + "key": "sequence_hi", + "name": "Номер записи: старшее слово", + "access": "r" + }, + { + "offset": 14, + "key": "missed_lo", + "name": "Пропущено снимков: младшее слово", + "access": "r" + }, + { + "offset": 15, + "key": "missed_hi", + "name": "Пропущено снимков: старшее слово", + "access": "r" + }, + { + "offset": 16, + "key": "source_errors_lo", + "name": "Ошибки источников: младшее слово", + "access": "r" + }, + { + "offset": 17, + "key": "source_errors_hi", + "name": "Ошибки источников: старшее слово", + "access": "r" + }, + { + "offset": 18, + "key": "command", + "name": "Команда", + "access": "w" + }, + { + "offset": 19, + "key": "release_generation_lo", + "name": "Освобождаемое поколение: младшее слово", + "access": "w" + }, + { + "offset": 20, + "key": "release_generation_hi", + "name": "Освобождаемое поколение: старшее слово", + "access": "w" + } + ] + }, + "channels": [ + { + "key": "commands", + "name": "Команды УМП", + "type": "u16", + "gas": 20480, + "modbus": { + "function": 3, + "address": 20480 + }, + "can": { + "protocol": "protocan_gas", + "address": 20480 + }, + "source": { + "space": "pm35_modbus", + "address": 127 + } + }, + { + "key": "current", + "name": "Задание тока, 0.1 мА", + "type": "u16", + "gas": 20481, + "modbus": { + "function": 3, + "address": 20481 + }, + "can": { + "protocol": "protocan_gas", + "address": 20481 + }, + "source": { + "space": "pm35_modbus", + "address": 28 + } + }, + { + "key": "dac", + "name": "Код ЦАП", + "type": "u16", + "gas": 20482, + "modbus": { + "function": 3, + "address": 20482 + }, + "can": { + "protocol": "protocan_gas", + "address": 20482 + }, + "source": { + "space": "pm35_modbus", + "address": 123 + } + }, + { + "key": "go", + "name": "Начальная уставка, мА", + "type": "u16", + "gas": 20483, + "modbus": { + "function": 3, + "address": 20483 + }, + "can": { + "protocol": "protocan_gas", + "address": 20483 + }, + "source": { + "space": "pm35_modbus", + "address": 80 + } + }, + { + "key": "stop", + "name": "Конечная уставка, мА", + "type": "u16", + "gas": 20484, + "modbus": { + "function": 3, + "address": 20484 + }, + "can": { + "protocol": "protocan_gas", + "address": 20484 + }, + "source": { + "space": "pm35_modbus", + "address": 81 + } + }, + { + "key": "zpt1", + "name": "ЗПТ1: U1 + U2", + "type": "u16", + "gas": 20485, + "modbus": { + "function": 3, + "address": 20485 + }, + "can": { + "protocol": "protocan_gas", + "address": 20485 + }, + "source": { + "space": "pm67_cache", + "address": 0 + } + }, + { + "key": "zpt2", + "name": "ЗПТ2: U3 + U4", + "type": "u16", + "gas": 20486, + "modbus": { + "function": 3, + "address": 20486 + }, + "can": { + "protocol": "protocan_gas", + "address": 20486 + }, + "source": { + "space": "pm67_cache", + "address": 1 + } + } + ] +}