feat(gas-logger): добавь непрерывный логгер и JSON-карту

This commit is contained in:
2026-10-01 22:54:29 +03:00
parent 675ce063f0
commit f7a6f767e1
22 changed files with 1250 additions and 0 deletions

View File

@@ -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+ |

View 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
View 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 и
испытание на устройстве. Проверки на хосте не измеряют аппаратные тайминги.

View 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

View 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

View 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); }

View 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

View 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);
}

View 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

View 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

View 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); }

View 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

View 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); }

View 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";
}

View 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(&region,&log);
if(address<region.base || (uint32_t)address>=region.base+region.count) return GL_ADDRESS;
return region.read(&region,(uint16_t)(address-region.base),out);
}

View 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)

View 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])

View 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;
}

View 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')

View File

@@ -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"]

View 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

View 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
}
}
]
}