feat(gas-logger): добавь непрерывный логгер и JSON-карту
This commit is contained in:
@@ -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+ |
|
||||
|
||||
16
c/gas-logger/CMakeLists.txt
Normal file
16
c/gas-logger/CMakeLists.txt
Normal file
@@ -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)
|
||||
177
c/gas-logger/README.md
Normal file
177
c/gas-logger/README.md
Normal file
@@ -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 <build>`, сборка и `ctest --test-dir <build>`.
|
||||
Ветка `codex/test-gas-logger`: потребитель SETGUI импортирует модуль из
|
||||
`third_party/templates` и включает JSON в пакет. Производственные прошивки
|
||||
ПМ35/166 пока не переключены; адреса не включены в их текущие диспетчеры.
|
||||
Нужны подключение к таймеру/карте конкретной платы, проверка linker RAM и
|
||||
испытание на устройстве. Проверки на хосте не измеряют аппаратные тайминги.
|
||||
70
c/gas-logger/include/gas_logger.h
Normal file
70
c/gas-logger/include/gas_logger.h
Normal file
@@ -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 <stdint.h>
|
||||
#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
|
||||
8
c/gas-logger/include/gas_logger_pcan.h
Normal file
8
c/gas-logger/include/gas_logger_pcan.h
Normal file
@@ -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
|
||||
4
c/gas-logger/ports/c28x/gas_logger_c28x.c
Normal file
4
c/gas-logger/ports/c28x/gas_logger_c28x.c
Normal file
@@ -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); }
|
||||
7
c/gas-logger/ports/c28x/gas_logger_c28x.h
Normal file
7
c/gas-logger/ports/c28x/gas_logger_c28x.h
Normal file
@@ -0,0 +1,7 @@
|
||||
#ifndef GAS_LOGGER_C28X_H
|
||||
#define GAS_LOGGER_C28X_H
|
||||
#include <stdint.h>
|
||||
/* 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
|
||||
22
c/gas-logger/ports/pm35/gas_logger_pm35.c
Normal file
22
c/gas-logger/ports/pm35/gas_logger_pm35.c
Normal file
@@ -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;i<gl_pm35_config.channels;i++) if(gl_pm35_channels[i].gas==gas) {
|
||||
*v=gl_pm35_source_space[i] ? p->pm67_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);
|
||||
}
|
||||
16
c/gas-logger/ports/pm35/gas_logger_pm35.h
Normal file
16
c/gas-logger/ports/pm35/gas_logger_pm35.h
Normal file
@@ -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
|
||||
19
c/gas-logger/ports/pm35/pm35_map.h
Normal file
19
c/gas-logger/ports/pm35/pm35_map.h
Normal file
@@ -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
|
||||
8
c/gas-logger/ports/stm32/gas_logger_stm32.c
Normal file
8
c/gas-logger/ports/stm32/gas_logger_stm32.c
Normal file
@@ -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); }
|
||||
8
c/gas-logger/ports/stm32/gas_logger_stm32.h
Normal file
8
c/gas-logger/ports/stm32/gas_logger_stm32.h
Normal file
@@ -0,0 +1,8 @@
|
||||
#ifndef GAS_LOGGER_STM32_H
|
||||
#define GAS_LOGGER_STM32_H
|
||||
#include <stdint.h>
|
||||
/* 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
|
||||
161
c/gas-logger/src/gas_logger.c
Normal file
161
c/gas-logger/src/gas_logger.c
Normal file
@@ -0,0 +1,161 @@
|
||||
#include "gas_logger.h"
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
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;i<c->channels;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;j<i;j++)
|
||||
if (c->map[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;i<l->config.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;i<l->config.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;i<n;i++) out[i]=status(l,(uint16_t)(a+i-l->config.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;i<n;i++) {
|
||||
slot=(uint16_t)((off+i)/width(l));
|
||||
if(l->snap_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;i<n;i++) {
|
||||
for(j=0;j<l->config.channels;j++) if(l->config.map[j].gas==a+i) break;
|
||||
if(j==l->config.channels) return GL_ADDRESS;
|
||||
}
|
||||
for(i=0;i<n;i++)
|
||||
if(!l->port.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;i<n;i++) out[i]=temp[i];
|
||||
l->port.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;i<n;i++) {
|
||||
found=0;
|
||||
for(j=0;j<l->config.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); }
|
||||
13
c/gas-logger/src/gas_logger_pcan.c
Normal file
13
c/gas-logger/src/gas_logger_pcan.c
Normal file
@@ -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";
|
||||
}
|
||||
22
c/gas-logger/tests/host_device.c
Normal file
22
c/gas-logger/tests/host_device.c
Normal file
@@ -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 <assert.h>
|
||||
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 || (uint32_t)address>=region.base+region.count) return GL_ADDRESS;
|
||||
return region.read(®ion,(uint16_t)(address-region.base),out);
|
||||
}
|
||||
46
c/gas-logger/tests/run_tests.py
Normal file
46
c/gas-logger/tests/run_tests.py
Normal file
@@ -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)
|
||||
111
c/gas-logger/tests/test_client.py
Normal file
111
c/gas-logger/tests/test_client.py
Normal file
@@ -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])
|
||||
54
c/gas-logger/tests/test_gas_logger.c
Normal file
54
c/gas-logger/tests/test_gas_logger.c
Normal file
@@ -0,0 +1,54 @@
|
||||
#include "gas_logger.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
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;
|
||||
}
|
||||
19
c/gas-logger/tools/generate_map.py
Normal file
19
c/gas-logger/tools/generate_map.py
Normal file
@@ -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')
|
||||
@@ -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"]
|
||||
|
||||
190
python/set_devices/gas_logger.py
Normal file
190
python/set_devices/gas_logger.py
Normal file
@@ -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
|
||||
274
python/set_devices/gas_logger_maps/pm35.json
Normal file
274
python/set_devices/gas_logger_maps/pm35.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user