Add embedded storage drivers and extend firmware metadata
This commit is contained in:
10
c/settings-backup/CMakeLists.txt
Normal file
10
c/settings-backup/CMakeLists.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(settings_backup C)
|
||||
add_library(settings_backup STATIC Src/settings_backup.c)
|
||||
target_include_directories(settings_backup PUBLIC Inc)
|
||||
set_target_properties(settings_backup PROPERTIES C_STANDARD 99 C_STANDARD_REQUIRED YES)
|
||||
|
||||
enable_testing()
|
||||
add_executable(test_settings_backup Tests/test_settings_backup.c)
|
||||
target_link_libraries(test_settings_backup PRIVATE settings_backup)
|
||||
add_test(NAME test_settings_backup COMMAND test_settings_backup)
|
||||
125
c/settings-backup/Inc/settings_backup.h
Normal file
125
c/settings-backup/Inc/settings_backup.h
Normal file
@@ -0,0 +1,125 @@
|
||||
#ifndef SETTINGS_BACKUP_H
|
||||
#define SETTINGS_BACKUP_H
|
||||
|
||||
/* Переносимое ядро резервирования не включает HAL, FatFs, AppStorage и Modbus. */
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define SETTINGS_BACKUP_SCHEMA_VERSION 1U
|
||||
#define SETTINGS_BACKUP_MAX_ROOMS 32U
|
||||
#define SETTINGS_BACKUP_MAX_SENSORS 32U
|
||||
#define SETTINGS_BACKUP_MAX_PATH 160U
|
||||
|
||||
/* Календарь передаётся приложением вместе с признаком доверия к RTC. */
|
||||
typedef struct {
|
||||
uint16_t year;
|
||||
uint8_t month;
|
||||
uint8_t date;
|
||||
uint8_t hours;
|
||||
uint8_t minutes;
|
||||
uint8_t seconds;
|
||||
uint8_t valid;
|
||||
} SettingsBackupDateTime;
|
||||
|
||||
/* Одна комната содержит только документированные инженерные параметры. */
|
||||
typedef struct {
|
||||
uint16_t setpoint_x10;
|
||||
uint8_t hysteresis_x10;
|
||||
uint8_t calibration_start_pct;
|
||||
uint16_t full_open_time_100ms;
|
||||
uint8_t target_position_pct;
|
||||
uint8_t confirmed_position_pct;
|
||||
} SettingsBackupRoom;
|
||||
|
||||
/* В JSON всегда попадает полный ROM, включая family и Dallas CRC. */
|
||||
typedef struct {
|
||||
uint8_t rom[8];
|
||||
uint8_t room;
|
||||
} SettingsBackupSensor;
|
||||
|
||||
/* Снимок создаётся адаптером только из committed-записи AppStorage. */
|
||||
typedef struct {
|
||||
uint32_t revision;
|
||||
uint32_t content_crc32;
|
||||
uint8_t requested_backend;
|
||||
uint8_t active_backend;
|
||||
uint8_t room_count;
|
||||
uint8_t sensor_count;
|
||||
SettingsBackupDateTime rtc;
|
||||
SettingsBackupRoom rooms[SETTINGS_BACKUP_MAX_ROOMS];
|
||||
SettingsBackupSensor sensors[SETTINGS_BACKUP_MAX_SENSORS];
|
||||
} SettingsBackupSnapshot;
|
||||
|
||||
/* Короткий набор результатов позволяет порту отобразить SD absent/full/I/O. */
|
||||
typedef enum {
|
||||
SETTINGS_BACKUP_PORT_OK = 0,
|
||||
SETTINGS_BACKUP_PORT_NOT_READY,
|
||||
SETTINGS_BACKUP_PORT_NO_SPACE,
|
||||
SETTINGS_BACKUP_PORT_IO_ERROR
|
||||
} SettingsBackupPortResult;
|
||||
|
||||
/* Наблюдаемое состояние не скрывает retry и отказ носителя от Modbus/GUI. */
|
||||
typedef enum {
|
||||
SETTINGS_BACKUP_IDLE = 0,
|
||||
SETTINGS_BACKUP_PENDING,
|
||||
SETTINGS_BACKUP_LAST_OK,
|
||||
SETTINGS_BACKUP_LAST_NOT_READY,
|
||||
SETTINGS_BACKUP_LAST_NO_SPACE,
|
||||
SETTINGS_BACKUP_LAST_IO_ERROR,
|
||||
SETTINGS_BACKUP_LAST_INVALID_SNAPSHOT
|
||||
} SettingsBackupStatus;
|
||||
|
||||
/* Все операции платформы ограничены одной файловой транзакцией за вызов. */
|
||||
typedef struct {
|
||||
uint32_t (*tick_ms)(void *context);
|
||||
SettingsBackupPortResult (*mount)(void *context);
|
||||
void (*unmount)(void *context);
|
||||
SettingsBackupPortResult (*find_identical)(void *context,
|
||||
uint32_t revision, uint32_t crc32, uint8_t *found);
|
||||
SettingsBackupPortResult (*resolve_paths)(void *context,
|
||||
const SettingsBackupSnapshot *snapshot, char *final_path,
|
||||
uint32_t final_capacity, char *temporary_path,
|
||||
uint32_t temporary_capacity);
|
||||
SettingsBackupPortResult (*atomic_write)(void *context,
|
||||
const char *temporary_path, const char *final_path,
|
||||
const char *utf8_json, uint32_t length);
|
||||
SettingsBackupPortResult (*apply_retention)(void *context,
|
||||
uint16_t maximum_copies);
|
||||
} SettingsBackupPort;
|
||||
|
||||
/* Контекст хранит pending-снимок: вызывающий может освобождать свой буфер. */
|
||||
typedef struct {
|
||||
SettingsBackupPort port;
|
||||
void *port_context;
|
||||
SettingsBackupSnapshot pending_snapshot;
|
||||
char *json_buffer;
|
||||
uint32_t json_capacity;
|
||||
char final_path[SETTINGS_BACKUP_MAX_PATH];
|
||||
char temporary_path[SETTINGS_BACKUP_MAX_PATH];
|
||||
uint32_t next_retry_ms;
|
||||
uint16_t maximum_copies;
|
||||
uint8_t phase;
|
||||
uint8_t retry_count;
|
||||
uint8_t pending;
|
||||
SettingsBackupStatus status;
|
||||
} SettingsBackup;
|
||||
|
||||
/* Инициализация не обращается к SD и потому безопасна во время старта МК. */
|
||||
uint8_t SettingsBackup_Init(SettingsBackup *instance,
|
||||
const SettingsBackupPort *port, void *port_context,
|
||||
char *json_buffer, uint32_t json_capacity, uint16_t maximum_copies);
|
||||
|
||||
/* Новая committed-ревизия заменяет только ещё не записанный pending-снимок. */
|
||||
uint8_t SettingsBackup_Notify(SettingsBackup *instance,
|
||||
const SettingsBackupSnapshot *snapshot);
|
||||
|
||||
/* Service делает не более одного mount/scan/write/retention шага за проход. */
|
||||
void SettingsBackup_Service(SettingsBackup *instance);
|
||||
|
||||
/* Форматирование и CRC открыты для host-тестов без файловой системы. */
|
||||
uint32_t SettingsBackup_CalculateContentCrc(const SettingsBackupSnapshot *snapshot);
|
||||
int SettingsBackup_FormatJson(const SettingsBackupSnapshot *snapshot,
|
||||
char *output, uint32_t capacity);
|
||||
SettingsBackupStatus SettingsBackup_GetStatus(const SettingsBackup *instance);
|
||||
|
||||
#endif /* SETTINGS_BACKUP_H */
|
||||
11
c/settings-backup/Inc/settings_backup_config.h
Normal file
11
c/settings-backup/Inc/settings_backup_config.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#ifndef SETTINGS_BACKUP_CONFIG_H
|
||||
#define SETTINGS_BACKUP_CONFIG_H
|
||||
|
||||
/* Политика продукта отделена от переносимого автомата и FatFs-порта. */
|
||||
#define SETTINGS_BACKUP_JSON_BUFFER_SIZE 12288U
|
||||
#define SETTINGS_BACKUP_MAXIMUM_COPIES 24U
|
||||
#define SETTINGS_BACKUP_RETRY_MS 30000UL
|
||||
#define SETTINGS_BACKUP_RETRY_LIMIT 3U
|
||||
#define SETTINGS_BACKUP_COOLDOWN_MS 300000UL
|
||||
|
||||
#endif /* SETTINGS_BACKUP_CONFIG_H */
|
||||
89
c/settings-backup/README.md
Normal file
89
c/settings-backup/README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# SettingsBackup
|
||||
|
||||
`SettingsBackup` сохраняет только подтверждённые конфигурации в JSON на
|
||||
SD-карту. Ядро `Src/settings_backup.c` не включает STM32 HAL, FatFs, Modbus,
|
||||
RTC или `AppStorage`: календарь и нормализованный снимок передаёт приложение,
|
||||
а файловые операции задаются callbacks структуры `SettingsBackupPort`.
|
||||
|
||||
## Размещение
|
||||
|
||||
При достоверном RTC используется путь:
|
||||
|
||||
```text
|
||||
niceOne/settings/YYYY-MM_EnglishMonth/settings_YYYY-MM-DD_HH-MM-SS.json
|
||||
```
|
||||
|
||||
Например:
|
||||
|
||||
```text
|
||||
niceOne/settings/2026-07_July/settings_2026-07-17_14-30-05.json
|
||||
```
|
||||
|
||||
Названия месяцев фиксированы на английском и состоят только из FAT-безопасных
|
||||
ASCII-символов. При недостоверном RTC файл помещается в `invalid_rtc` и получает
|
||||
имя `settings_invalid_rtc_rev_N.json`; это имя не выдаётся за календарную дату.
|
||||
Коллизии разрешаются суффиксами `_1`, `_2` и далее. Проверяются одновременно
|
||||
итоговое имя и `.tmp`, поэтому незавершённая транзакция после перезапуска не
|
||||
перезаписывается.
|
||||
|
||||
## Schema v1
|
||||
|
||||
Корневые поля: `schema`, `schema_version`, `snapshot_revision`,
|
||||
`snapshot_crc32`, `rtc`, `storage`, `units`, `rooms`, `sensors` и
|
||||
`restore_policy`. Все 32 комнаты содержат уставку и гистерезис в десятых долях
|
||||
градуса, старт калибровки и время полного открытия в 100 мс, целевое и
|
||||
подтверждённое положение в процентах. Датчик содержит полный 16-символьный HEX
|
||||
ROM: family, шесть serial-байтов и Dallas CRC, а также номер комнаты.
|
||||
|
||||
В `RoomSettings` v1 во Flash фиксируется достигнутый безопасный checkpoint.
|
||||
Поэтому `target_position_pct` резервной копии равен
|
||||
`confirmed_position_pct`; ещё не подтверждённая цель Modbus/GUI принципиально
|
||||
не попадает в файл.
|
||||
|
||||
## Когда создаётся копия
|
||||
|
||||
`settings_backup_app.c` сравнивает номер committed-записи. Только при его
|
||||
изменении он физически перечитывает 512 байт через
|
||||
`AppStorage_GetConfirmedSnapshot`, где кольцевой журнал повторно проверяет
|
||||
version, длину, commit marker и CRC. На старте та же ревизия ищется во всех
|
||||
месячных каталогах по content CRC; revision остаётся в файле для аудита, но
|
||||
повторный commit тех же настроек не создаёт дубль. Десятиминутный температурный
|
||||
журнал не является trigger backup.
|
||||
|
||||
Запись атомарна: уникальный `.tmp` создаётся с `FA_CREATE_NEW`, полностью
|
||||
записывается, синхронизируется, закрывается и только затем переименовывается в
|
||||
`.json`. Старые JSON не удаляются при ошибке open/write/sync/close/rename.
|
||||
Ошибки absent/full/removal переводят автомат в диагностическое состояние и
|
||||
назначают ограниченный retry без задержек и busy wait в main loop.
|
||||
|
||||
Retention задаётся `SETTINGS_BACKUP_MAXIMUM_COPIES` (по умолчанию 24). При
|
||||
превышении лимита сначала удаляется самый старый подтверждённый duplicate hash,
|
||||
затем самый старый уникальный файл; последняя корректная копия не удаляется.
|
||||
|
||||
## Восстановление
|
||||
|
||||
Автоматического восстановления нет. Наличие JSON никогда не вызывает запись в
|
||||
`AppStorage`. Будущая отдельная команда должна прочитать выбранный файл только
|
||||
для проверки, подтвердить schema/version, UTF-8/JSON, диапазоны 32 комнат,
|
||||
полные ROM и Dallas CRC, content CRC, совместимость backend и получить явное
|
||||
подтверждение оператора. Лишь после этого отдельная реализация может собрать
|
||||
новый AppStorage image и выполнить обычный committed write с обратным чтением.
|
||||
|
||||
## Перенос на другую платформу
|
||||
|
||||
Скопируйте `Inc`, `Src` и конфигурацию. Реализуйте callbacks mount, global
|
||||
dedupe scan, collision-safe path resolve, atomic write и retention. Каждая
|
||||
callback-функция должна иметь ограниченный timeout и не выполняться из ISR.
|
||||
FatFs-порт данного проекта находится в `Port/FatFs`; это единственный модуль
|
||||
библиотеки, который включает `ff.h` и использует общий том SD logger.
|
||||
|
||||
Модельные тесты запускаются так:
|
||||
|
||||
```text
|
||||
python -m unittest discover -s Libraries/SettingsBackup/Tests -p "test_*.py"
|
||||
```
|
||||
|
||||
|
||||
## Shared source
|
||||
|
||||
Canonical source: `templates/c/settings-backup`. Used by `home/climate`; its old paths are compatibility includes. Board-specific ports remain in the application. Change this library, not the forwarding files.
|
||||
362
c/settings-backup/Src/settings_backup.c
Normal file
362
c/settings-backup/Src/settings_backup.c
Normal file
@@ -0,0 +1,362 @@
|
||||
#include "../Inc/settings_backup.h"
|
||||
#include "../Inc/settings_backup_config.h"
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/*
|
||||
* Архитектура переносимого автомата
|
||||
* ---------------------------------
|
||||
* 1. Приложение передаёт только подтверждённый нормализованный snapshot.
|
||||
* 2. Notify копирует его, поэтому lifetime исходного указателя не важен.
|
||||
* 3. Canonical CRC охватывает только пользовательскую конфигурацию.
|
||||
* 4. RTC и backend остаются metadata и не создают ложный content duplicate.
|
||||
* 5. Mount, поиск, resolve, write и retention разнесены по main-loop проходам.
|
||||
* 6. Ни один переход не содержит delay, busy wait или прямого доступа к HAL.
|
||||
* 7. Ошибка файловой callback закрывает логическую попытку и ставит retry.
|
||||
* 8. Первые три retry короткие; дальнейшие выполняются после cooldown.
|
||||
* 9. Новая committed revision немедленно заменяет устаревший pending snapshot.
|
||||
* 10. JSON строится перед write целиком и не передаётся порту усечённым.
|
||||
* 11. Все числовые единицы зафиксированы отдельным объектом schema.
|
||||
* 12. ROM выводится как полный uppercase HEX без locale и разделителей.
|
||||
* 13. Никакие строки проекта не попадают в JSON, поэтому секреты исключены.
|
||||
* 14. Автомат никогда не читает JSON и принципиально не восстанавливает Flash.
|
||||
* 15. Диагностический getter не меняет phase и пригоден для Modbus polling.
|
||||
*/
|
||||
|
||||
/* Фазы разделяют потенциально медленные FAT-операции между main-loop проходами. */
|
||||
enum {
|
||||
BACKUP_PHASE_MOUNT = 0,
|
||||
BACKUP_PHASE_DEDUPE,
|
||||
BACKUP_PHASE_RESOLVE,
|
||||
BACKUP_PHASE_WRITE,
|
||||
BACKUP_PHASE_RETENTION
|
||||
};
|
||||
|
||||
/* Добавляет форматированный фрагмент и никогда не оставляет усечённый JSON. */
|
||||
static uint8_t append_text(char *output, uint32_t capacity, uint32_t *used,
|
||||
const char *format, ...)
|
||||
{
|
||||
va_list arguments;
|
||||
int length;
|
||||
|
||||
if ((*used >= capacity) || (format == NULL)) {
|
||||
return 0U;
|
||||
}
|
||||
va_start(arguments, format);
|
||||
length = vsnprintf(output + *used, capacity - *used, format, arguments);
|
||||
va_end(arguments);
|
||||
if ((length < 0) || ((uint32_t)length >= (capacity - *used))) {
|
||||
return 0U;
|
||||
}
|
||||
*used += (uint32_t)length;
|
||||
return 1U;
|
||||
}
|
||||
|
||||
/* CRC32 IEEE считается по стабильному бинарному представлению настроек. */
|
||||
static uint32_t crc32_add(uint32_t crc, uint8_t value)
|
||||
{
|
||||
uint8_t bit;
|
||||
|
||||
crc ^= value;
|
||||
for (bit = 0U; bit < 8U; ++bit) {
|
||||
crc = ((crc & 1U) != 0U) ? (crc >> 1U) ^ 0xEDB88320UL : crc >> 1U;
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
/* Время и backend исключены: hash обозначает именно пользовательские настройки. */
|
||||
uint32_t SettingsBackup_CalculateContentCrc(const SettingsBackupSnapshot *snapshot)
|
||||
{
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
uint8_t index;
|
||||
uint8_t byte;
|
||||
|
||||
if ((snapshot == NULL) || (snapshot->room_count > SETTINGS_BACKUP_MAX_ROOMS) ||
|
||||
(snapshot->sensor_count > SETTINGS_BACKUP_MAX_SENSORS)) {
|
||||
return 0U;
|
||||
}
|
||||
crc = crc32_add(crc, snapshot->room_count);
|
||||
crc = crc32_add(crc, snapshot->sensor_count);
|
||||
for (index = 0U; index < snapshot->room_count; ++index) {
|
||||
const SettingsBackupRoom *room = &snapshot->rooms[index];
|
||||
crc = crc32_add(crc, (uint8_t)room->setpoint_x10);
|
||||
crc = crc32_add(crc, (uint8_t)(room->setpoint_x10 >> 8U));
|
||||
crc = crc32_add(crc, room->hysteresis_x10);
|
||||
crc = crc32_add(crc, room->calibration_start_pct);
|
||||
crc = crc32_add(crc, (uint8_t)room->full_open_time_100ms);
|
||||
crc = crc32_add(crc, (uint8_t)(room->full_open_time_100ms >> 8U));
|
||||
crc = crc32_add(crc, room->target_position_pct);
|
||||
crc = crc32_add(crc, room->confirmed_position_pct);
|
||||
}
|
||||
for (index = 0U; index < snapshot->sensor_count; ++index) {
|
||||
for (byte = 0U; byte < 8U; ++byte) {
|
||||
crc = crc32_add(crc, snapshot->sensors[index].rom[byte]);
|
||||
}
|
||||
crc = crc32_add(crc, snapshot->sensors[index].room);
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
/* Формирует schema v1: все строки ASCII/UTF-8 и не требуют locale/float. */
|
||||
int SettingsBackup_FormatJson(const SettingsBackupSnapshot *snapshot,
|
||||
char *output, uint32_t capacity)
|
||||
{
|
||||
uint32_t used = 0U;
|
||||
uint8_t index;
|
||||
uint8_t byte;
|
||||
|
||||
if ((snapshot == NULL) || (output == NULL) || (capacity == 0U) ||
|
||||
(snapshot->room_count > SETTINGS_BACKUP_MAX_ROOMS) ||
|
||||
(snapshot->sensor_count > SETTINGS_BACKUP_MAX_SENSORS)) {
|
||||
return -1;
|
||||
}
|
||||
output[0] = '\0';
|
||||
if (!append_text(output, capacity, &used,
|
||||
"{\n \"schema\":\"niceOne.settings\",\n \"schema_version\":1,\n"
|
||||
" \"snapshot_revision\":%lu,\n \"snapshot_crc32\":\"%08lX\",\n",
|
||||
(unsigned long)snapshot->revision,
|
||||
(unsigned long)snapshot->content_crc32)) {
|
||||
return -1;
|
||||
}
|
||||
if (snapshot->rtc.valid != 0U) {
|
||||
if (!append_text(output, capacity, &used,
|
||||
" \"rtc\":{\"valid\":true,\"datetime\":\"%04u-%02u-%02uT%02u:%02u:%02u\"},\n",
|
||||
snapshot->rtc.year, snapshot->rtc.month, snapshot->rtc.date,
|
||||
snapshot->rtc.hours, snapshot->rtc.minutes, snapshot->rtc.seconds)) {
|
||||
return -1;
|
||||
}
|
||||
} else if (!append_text(output, capacity, &used,
|
||||
" \"rtc\":{\"valid\":false,\"datetime\":null},\n")) {
|
||||
return -1;
|
||||
}
|
||||
if (!append_text(output, capacity, &used,
|
||||
" \"storage\":{\"requested_backend\":\"%s\",\"active_backend\":\"%s\"},\n"
|
||||
" \"units\":{\"temperature\":\"0.1_degC\",\"hysteresis\":\"0.1_degC\","
|
||||
"\"position\":\"percent\",\"full_open_time\":\"100_ms\"},\n \"rooms\":[\n",
|
||||
snapshot->requested_backend ? "external_spi_nor" : "internal_flash",
|
||||
snapshot->active_backend ? "external_spi_nor" : "internal_flash")) {
|
||||
return -1;
|
||||
}
|
||||
for (index = 0U; index < snapshot->room_count; ++index) {
|
||||
const SettingsBackupRoom *room = &snapshot->rooms[index];
|
||||
if (!append_text(output, capacity, &used,
|
||||
" {\"room\":%u,\"setpoint_x10\":%u,\"hysteresis_x10\":%u,"
|
||||
"\"calibration\":{\"start_position_pct\":%u,\"full_open_time_100ms\":%u},"
|
||||
"\"valve\":{\"target_position_pct\":%u,\"confirmed_position_pct\":%u}}%s\n",
|
||||
(unsigned)(index + 1U), room->setpoint_x10, room->hysteresis_x10,
|
||||
room->calibration_start_pct, room->full_open_time_100ms,
|
||||
room->target_position_pct, room->confirmed_position_pct,
|
||||
(index + 1U < snapshot->room_count) ? "," : "")) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (!append_text(output, capacity, &used, " ],\n \"sensors\":[\n")) {
|
||||
return -1;
|
||||
}
|
||||
for (index = 0U; index < snapshot->sensor_count; ++index) {
|
||||
if (!append_text(output, capacity, &used, " {\"rom\":\"")) {
|
||||
return -1;
|
||||
}
|
||||
for (byte = 0U; byte < 8U; ++byte) {
|
||||
if (!append_text(output, capacity, &used, "%02X",
|
||||
snapshot->sensors[index].rom[byte])) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (!append_text(output, capacity, &used, "\",\"room\":%u}%s\n",
|
||||
snapshot->sensors[index].room,
|
||||
(index + 1U < snapshot->sensor_count) ? "," : "")) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
if (!append_text(output, capacity, &used,
|
||||
" ],\n \"restore_policy\":\"manual_validation_only\"\n}\n")) {
|
||||
return -1;
|
||||
}
|
||||
return (int)used;
|
||||
}
|
||||
|
||||
/* Проверяет инженерные диапазоны до помещения снимка в очередь. */
|
||||
static uint8_t snapshot_is_valid(const SettingsBackupSnapshot *snapshot)
|
||||
{
|
||||
uint8_t index;
|
||||
|
||||
if ((snapshot == NULL) || (snapshot->revision == 0U) ||
|
||||
(snapshot->room_count != SETTINGS_BACKUP_MAX_ROOMS) ||
|
||||
(snapshot->sensor_count > SETTINGS_BACKUP_MAX_SENSORS)) {
|
||||
return 0U;
|
||||
}
|
||||
for (index = 0U; index < snapshot->room_count; ++index) {
|
||||
const SettingsBackupRoom *room = &snapshot->rooms[index];
|
||||
if ((room->setpoint_x10 < 50U) || (room->setpoint_x10 > 400U) ||
|
||||
(room->hysteresis_x10 < 1U) || (room->hysteresis_x10 > 100U) ||
|
||||
(room->calibration_start_pct > 100U) ||
|
||||
(room->target_position_pct > 100U) ||
|
||||
(room->confirmed_position_pct > 100U) ||
|
||||
(room->full_open_time_100ms < 10U) ||
|
||||
(room->full_open_time_100ms > 36000U)) {
|
||||
return 0U;
|
||||
}
|
||||
}
|
||||
return 1U;
|
||||
}
|
||||
|
||||
/* Инициализация валидирует полный набор callback, нужный атомарному протоколу. */
|
||||
uint8_t SettingsBackup_Init(SettingsBackup *instance,
|
||||
const SettingsBackupPort *port, void *port_context,
|
||||
char *json_buffer, uint32_t json_capacity, uint16_t maximum_copies)
|
||||
{
|
||||
if ((instance == NULL) || (port == NULL) || (json_buffer == NULL) ||
|
||||
(json_capacity < 1024U) || (port->tick_ms == NULL) ||
|
||||
(port->mount == NULL) || (port->unmount == NULL) ||
|
||||
(port->find_identical == NULL) || (port->resolve_paths == NULL) ||
|
||||
(port->atomic_write == NULL) || (port->apply_retention == NULL)) {
|
||||
return 0U;
|
||||
}
|
||||
memset(instance, 0, sizeof(*instance));
|
||||
instance->port = *port;
|
||||
instance->port_context = port_context;
|
||||
instance->json_buffer = json_buffer;
|
||||
instance->json_capacity = json_capacity;
|
||||
instance->maximum_copies = (maximum_copies == 0U) ? 1U : maximum_copies;
|
||||
instance->status = SETTINGS_BACKUP_IDLE;
|
||||
return 1U;
|
||||
}
|
||||
|
||||
/* Pending копируется целиком и получает вычисленный canonical CRC. */
|
||||
uint8_t SettingsBackup_Notify(SettingsBackup *instance,
|
||||
const SettingsBackupSnapshot *snapshot)
|
||||
{
|
||||
if ((instance == NULL) || (snapshot_is_valid(snapshot) == 0U)) {
|
||||
if (instance != NULL) {
|
||||
instance->status = SETTINGS_BACKUP_LAST_INVALID_SNAPSHOT;
|
||||
}
|
||||
return 0U;
|
||||
}
|
||||
instance->pending_snapshot = *snapshot;
|
||||
instance->pending_snapshot.content_crc32 =
|
||||
SettingsBackup_CalculateContentCrc(&instance->pending_snapshot);
|
||||
instance->phase = BACKUP_PHASE_MOUNT;
|
||||
instance->retry_count = 0U;
|
||||
instance->pending = 1U;
|
||||
instance->status = SETTINGS_BACKUP_PENDING;
|
||||
return 1U;
|
||||
}
|
||||
|
||||
/* Ошибка закрывает том и назначает ограниченный retry/cooldown без busy wait. */
|
||||
static void schedule_retry(SettingsBackup *instance,
|
||||
SettingsBackupPortResult result)
|
||||
{
|
||||
uint32_t delay_ms;
|
||||
|
||||
/* Порт завершает владение попыткой; общий физический том может оставить
|
||||
* смонтированным, если его разделяет с независимым temperature logger. */
|
||||
instance->port.unmount(instance->port_context);
|
||||
instance->status = (result == SETTINGS_BACKUP_PORT_NOT_READY) ?
|
||||
SETTINGS_BACKUP_LAST_NOT_READY :
|
||||
(result == SETTINGS_BACKUP_PORT_NO_SPACE) ?
|
||||
SETTINGS_BACKUP_LAST_NO_SPACE : SETTINGS_BACKUP_LAST_IO_ERROR;
|
||||
/* Счётчик насыщать не требуется: после 255 ошибок unsigned wrap только
|
||||
* вернёт одну короткую попытку и не нарушит сохранность данных. */
|
||||
++instance->retry_count;
|
||||
delay_ms = (instance->retry_count <= SETTINGS_BACKUP_RETRY_LIMIT) ?
|
||||
SETTINGS_BACKUP_RETRY_MS : SETTINGS_BACKUP_COOLDOWN_MS;
|
||||
instance->next_retry_ms = instance->port.tick_ms(instance->port_context) + delay_ms;
|
||||
instance->phase = BACKUP_PHASE_MOUNT;
|
||||
}
|
||||
|
||||
/* Автомат намеренно выполняет только одну callback-операцию на каждом проходе. */
|
||||
void SettingsBackup_Service(SettingsBackup *instance)
|
||||
{
|
||||
SettingsBackupPortResult result;
|
||||
uint8_t found = 0U;
|
||||
int json_length;
|
||||
uint32_t now_ms;
|
||||
|
||||
if ((instance == NULL) || (instance->pending == 0U)) {
|
||||
return;
|
||||
}
|
||||
now_ms = instance->port.tick_ms(instance->port_context);
|
||||
if ((instance->next_retry_ms != 0U) &&
|
||||
((int32_t)(now_ms - instance->next_retry_ms) < 0)) {
|
||||
return;
|
||||
}
|
||||
instance->next_retry_ms = 0U;
|
||||
if (instance->phase == BACKUP_PHASE_MOUNT) {
|
||||
/* Mount — единственная операция этой итерации; scan начнётся позже. */
|
||||
result = instance->port.mount(instance->port_context);
|
||||
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||
schedule_retry(instance, result);
|
||||
return;
|
||||
}
|
||||
instance->phase = BACKUP_PHASE_DEDUPE;
|
||||
return;
|
||||
}
|
||||
if (instance->phase == BACKUP_PHASE_DEDUPE) {
|
||||
/* Restart dedupe обязан выполняться до выбора нового имени файла. */
|
||||
result = instance->port.find_identical(instance->port_context,
|
||||
instance->pending_snapshot.revision,
|
||||
instance->pending_snapshot.content_crc32, &found);
|
||||
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||
schedule_retry(instance, result);
|
||||
return;
|
||||
}
|
||||
if (found != 0U) {
|
||||
instance->pending = 0U;
|
||||
instance->status = SETTINGS_BACKUP_LAST_OK;
|
||||
instance->port.unmount(instance->port_context);
|
||||
return;
|
||||
}
|
||||
instance->phase = BACKUP_PHASE_RESOLVE;
|
||||
return;
|
||||
}
|
||||
if (instance->phase == BACKUP_PHASE_RESOLVE) {
|
||||
/* Resolve резервирует концептуальную пару final/temp без их создания. */
|
||||
result = instance->port.resolve_paths(instance->port_context,
|
||||
&instance->pending_snapshot, instance->final_path,
|
||||
sizeof(instance->final_path), instance->temporary_path,
|
||||
sizeof(instance->temporary_path));
|
||||
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||
schedule_retry(instance, result);
|
||||
return;
|
||||
}
|
||||
instance->phase = BACKUP_PHASE_WRITE;
|
||||
return;
|
||||
}
|
||||
if (instance->phase == BACKUP_PHASE_WRITE) {
|
||||
/* Форматирование RAM не касается FAT; atomicity начинается в callback. */
|
||||
json_length = SettingsBackup_FormatJson(&instance->pending_snapshot,
|
||||
instance->json_buffer, instance->json_capacity);
|
||||
if (json_length < 0) {
|
||||
schedule_retry(instance, SETTINGS_BACKUP_PORT_IO_ERROR);
|
||||
return;
|
||||
}
|
||||
result = instance->port.atomic_write(instance->port_context,
|
||||
instance->temporary_path, instance->final_path,
|
||||
instance->json_buffer, (uint32_t)json_length);
|
||||
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||
schedule_retry(instance, result);
|
||||
return;
|
||||
}
|
||||
instance->phase = BACKUP_PHASE_RETENTION;
|
||||
return;
|
||||
}
|
||||
/* Retention выполняется только после успешного rename новой good copy. */
|
||||
result = instance->port.apply_retention(instance->port_context,
|
||||
instance->maximum_copies);
|
||||
if (result != SETTINGS_BACKUP_PORT_OK) {
|
||||
schedule_retry(instance, result);
|
||||
return;
|
||||
}
|
||||
instance->pending = 0U;
|
||||
instance->status = SETTINGS_BACKUP_LAST_OK;
|
||||
instance->port.unmount(instance->port_context);
|
||||
}
|
||||
|
||||
/* Getter не меняет автомат и безопасен для диагностического регистра. */
|
||||
SettingsBackupStatus SettingsBackup_GetStatus(const SettingsBackup *instance)
|
||||
{
|
||||
return (instance == NULL) ? SETTINGS_BACKUP_LAST_IO_ERROR : instance->status;
|
||||
}
|
||||
43
c/settings-backup/Tests/test_settings_backup.c
Normal file
43
c/settings-backup/Tests/test_settings_backup.c
Normal file
@@ -0,0 +1,43 @@
|
||||
#include "settings_backup.h"
|
||||
#include <assert.h>
|
||||
#include <string.h>
|
||||
typedef struct { uint32_t now; unsigned writes, unmounts; int fail; } mock_t;
|
||||
static uint32_t tick(void *p) { return ((mock_t*)p)->now; }
|
||||
static SettingsBackupPortResult mount(void *p) { (void)p; return SETTINGS_BACKUP_PORT_OK; }
|
||||
static void unmount(void *p) { ++((mock_t*)p)->unmounts; }
|
||||
static SettingsBackupPortResult find(void *p,uint32_t r,uint32_t c,uint8_t *found)
|
||||
{ (void)p; (void)r; (void)c; *found=0; return SETTINGS_BACKUP_PORT_OK; }
|
||||
static SettingsBackupPortResult paths(void *p,const SettingsBackupSnapshot *s,char *f,uint32_t fc,char *t,uint32_t tc)
|
||||
{ (void)p; (void)s; assert(fc>8 && tc>8); memcpy(f,"ok.json",8); memcpy(t,"ok.tmp",7); return SETTINGS_BACKUP_PORT_OK; }
|
||||
static SettingsBackupPortResult write_json(void *p,const char *t,const char *f,const char *json,uint32_t n)
|
||||
{
|
||||
mock_t *m=p; (void)t; (void)f;
|
||||
assert(n==strlen(json) && n>10 && json[0]=='{');
|
||||
if (m->fail) return SETTINGS_BACKUP_PORT_IO_ERROR;
|
||||
++m->writes; return SETTINGS_BACKUP_PORT_OK;
|
||||
}
|
||||
static SettingsBackupPortResult retain(void *p,uint16_t n)
|
||||
{ (void)p; assert(n==2); return SETTINGS_BACKUP_PORT_OK; }
|
||||
int main(void)
|
||||
{
|
||||
SettingsBackup b; SettingsBackupSnapshot s={0}; mock_t m={0}; char json[16000];
|
||||
SettingsBackupPort port={tick,mount,unmount,find,paths,write_json,retain};
|
||||
unsigned i; uint32_t crc;
|
||||
s.revision=1; s.room_count=SETTINGS_BACKUP_MAX_ROOMS;
|
||||
for(i=0;i<s.room_count;++i) { s.rooms[i].setpoint_x10=200; s.rooms[i].hysteresis_x10=10; s.rooms[i].full_open_time_100ms=100; }
|
||||
crc=SettingsBackup_CalculateContentCrc(&s);
|
||||
s.rtc.year=2026; s.active_backend=1;
|
||||
assert(SettingsBackup_CalculateContentCrc(&s)==crc);
|
||||
assert(SettingsBackup_FormatJson(&s,json,5)<0);
|
||||
assert(SettingsBackup_Init(&b,&port,&m,json,sizeof(json),2));
|
||||
assert(SettingsBackup_Notify(&b,&s));
|
||||
s.rooms[0].setpoint_x10=300;
|
||||
assert(b.pending_snapshot.rooms[0].setpoint_x10==200);
|
||||
m.fail=1;
|
||||
for(i=0;i<4;++i) SettingsBackup_Service(&b);
|
||||
assert(m.writes==0 && b.pending && b.status==SETTINGS_BACKUP_LAST_IO_ERROR);
|
||||
m.fail=0; m.now=b.next_retry_ms;
|
||||
for(i=0;i<5;++i) SettingsBackup_Service(&b);
|
||||
assert(m.writes==1 && !b.pending && b.status==SETTINGS_BACKUP_LAST_OK);
|
||||
return 0;
|
||||
}
|
||||
196
c/settings-backup/Tests/test_settings_backup_model.py
Normal file
196
c/settings-backup/Tests/test_settings_backup_model.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Модельные проверки полного контракта JSON backup без реальной SD-карты."""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
|
||||
MONTHS = (
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Snapshot:
|
||||
"""Подтверждённый снимок модели соответствует metadata C-ядра."""
|
||||
|
||||
revision: int
|
||||
payload: bytes
|
||||
rtc: datetime | None
|
||||
|
||||
@property
|
||||
def crc(self) -> int:
|
||||
"""CRC моделирует стабильный content hash без календаря."""
|
||||
return zlib.crc32(self.payload) & 0xFFFFFFFF
|
||||
|
||||
|
||||
class FakeCard:
|
||||
"""In-memory FAT-модель фиксирует атомарность, коллизии и отказы."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.files: dict[str, bytes] = {}
|
||||
self.ready = True
|
||||
self.fail_step: str | None = None
|
||||
|
||||
def resolve(self, snapshot: Snapshot) -> tuple[str, str]:
|
||||
"""Выбирает календарное либо честно помеченное fallback-имя."""
|
||||
if snapshot.rtc:
|
||||
month = f"{snapshot.rtc:%Y-%m}_{MONTHS[snapshot.rtc.month - 1]}"
|
||||
stem = f"settings_{snapshot.rtc:%Y-%m-%d_%H-%M-%S}"
|
||||
else:
|
||||
month = "invalid_rtc"
|
||||
stem = f"settings_invalid_rtc_rev_{snapshot.revision}"
|
||||
root = PurePosixPath("niceOne/settings") / month
|
||||
suffix = 0
|
||||
while True:
|
||||
marker = "" if suffix == 0 else f"_{suffix}"
|
||||
final = str(root / f"{stem}{marker}.json")
|
||||
temporary = str(root / f"{stem}{marker}.tmp")
|
||||
if final not in self.files and temporary not in self.files:
|
||||
return final, temporary
|
||||
suffix += 1
|
||||
|
||||
def atomic_write(self, temporary: str, final: str, content: bytes) -> bool:
|
||||
"""Сбой любого этапа никогда не изменяет старый final JSON."""
|
||||
if not self.ready or self.fail_step == "open":
|
||||
return False
|
||||
self.files[temporary] = b""
|
||||
if self.fail_step == "write":
|
||||
return False
|
||||
self.files[temporary] = content
|
||||
if self.fail_step in {"sync", "close", "rename"}:
|
||||
return False
|
||||
self.files[final] = self.files.pop(temporary)
|
||||
return True
|
||||
|
||||
|
||||
def document(snapshot: Snapshot) -> bytes:
|
||||
"""Минимальная schema-модель проверяет UTF-8 и обязательные разделы."""
|
||||
rooms = [
|
||||
{
|
||||
"room": room,
|
||||
"setpoint_x10": 200,
|
||||
"hysteresis_x10": 10,
|
||||
"calibration": {"start_position_pct": 0, "full_open_time_100ms": 300},
|
||||
"valve": {"target_position_pct": 50, "confirmed_position_pct": 50},
|
||||
}
|
||||
for room in range(1, 33)
|
||||
]
|
||||
sensors = [{"rom": "280102030405069E", "room": 1}]
|
||||
return (json.dumps(
|
||||
{
|
||||
"schema": "niceOne.settings",
|
||||
"schema_version": 1,
|
||||
"snapshot_revision": snapshot.revision,
|
||||
"snapshot_crc32": f"{snapshot.crc:08X}",
|
||||
"rtc": {"valid": snapshot.rtc is not None,
|
||||
"datetime": snapshot.rtc.isoformat() if snapshot.rtc else None},
|
||||
"storage": {"requested_backend": "internal_flash",
|
||||
"active_backend": "internal_flash"},
|
||||
"units": {"temperature": "0.1_degC", "position": "percent"},
|
||||
"rooms": rooms,
|
||||
"sensors": sensors,
|
||||
"restore_policy": "manual_validation_only",
|
||||
}, ensure_ascii=False, indent=2,
|
||||
) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
class SettingsBackupModelTests(unittest.TestCase):
|
||||
"""Каждый тест соответствует одному или нескольким пунктам task.MD."""
|
||||
|
||||
def test_calendar_tree_and_fat_safe_english_month(self) -> None:
|
||||
card = FakeCard()
|
||||
final, temporary = card.resolve(Snapshot(7, b"a", datetime(2026, 7, 17, 14, 30, 5)))
|
||||
self.assertEqual(final, "niceOne/settings/2026-07_July/settings_2026-07-17_14-30-05.json")
|
||||
self.assertTrue(temporary.endswith(".tmp"))
|
||||
self.assertNotIn(":", final)
|
||||
|
||||
def test_json_schema_contains_all_32_rooms_full_rom_and_units(self) -> None:
|
||||
snapshot = Snapshot(9, b"confirmed", datetime(2026, 12, 31, 23, 59, 59))
|
||||
parsed = json.loads(document(snapshot).decode("utf-8"))
|
||||
self.assertEqual(parsed["schema_version"], 1)
|
||||
self.assertEqual(len(parsed["rooms"]), 32)
|
||||
self.assertEqual(len(parsed["sensors"][0]["rom"]), 16)
|
||||
self.assertIn("target_position_pct", parsed["rooms"][0]["valve"])
|
||||
self.assertIn("confirmed_position_pct", parsed["rooms"][0]["valve"])
|
||||
self.assertEqual(parsed["restore_policy"], "manual_validation_only")
|
||||
|
||||
def test_hash_excludes_rtc_and_prevents_restart_duplicate(self) -> None:
|
||||
first = Snapshot(12, b"same settings", datetime(2026, 7, 1))
|
||||
restart = Snapshot(13, b"same settings", datetime(2026, 8, 1))
|
||||
self.assertEqual(first.crc, restart.crc)
|
||||
identities = {first.crc}
|
||||
self.assertIn(restart.crc, identities)
|
||||
|
||||
def test_confirmed_revision_change_creates_copy_not_periodic_tick(self) -> None:
|
||||
revisions = [4, 4, 4, 5, 5]
|
||||
writes = []
|
||||
previous = None
|
||||
for revision in revisions:
|
||||
if revision != previous:
|
||||
writes.append(revision)
|
||||
previous = revision
|
||||
self.assertEqual(writes, [4, 5])
|
||||
|
||||
def test_collision_survives_restart_and_checks_temp_too(self) -> None:
|
||||
card = FakeCard()
|
||||
snapshot = Snapshot(3, b"x", datetime(2026, 1, 2, 3, 4, 5))
|
||||
final, temporary = card.resolve(snapshot)
|
||||
card.files[final] = b"old"
|
||||
card.files[temporary] = b"interrupted"
|
||||
next_final, next_temporary = card.resolve(snapshot)
|
||||
self.assertTrue(next_final.endswith("_1.json"))
|
||||
self.assertTrue(next_temporary.endswith("_1.tmp"))
|
||||
|
||||
def test_atomic_failures_preserve_last_good(self) -> None:
|
||||
for failure in ("open", "write", "sync", "close", "rename"):
|
||||
with self.subTest(failure=failure):
|
||||
card = FakeCard()
|
||||
card.fail_step = failure
|
||||
card.files["last.json"] = b"valid"
|
||||
final, temporary = card.resolve(Snapshot(2, b"new", None))
|
||||
self.assertFalse(card.atomic_write(temporary, final, b"new"))
|
||||
self.assertEqual(card.files["last.json"], b"valid")
|
||||
self.assertNotIn(final, card.files)
|
||||
|
||||
def test_absent_removed_or_full_card_is_non_destructive(self) -> None:
|
||||
for failure in (False, "open", "write"):
|
||||
with self.subTest(failure=failure):
|
||||
card = FakeCard()
|
||||
card.ready = bool(failure)
|
||||
card.fail_step = failure if isinstance(failure, str) else None
|
||||
card.files["last.json"] = b"valid"
|
||||
final, temporary = card.resolve(Snapshot(2, b"new", None))
|
||||
self.assertFalse(card.atomic_write(temporary, final, b"new"))
|
||||
self.assertEqual(card.files["last.json"], b"valid")
|
||||
|
||||
def test_invalid_rtc_uses_revision_and_never_fake_date(self) -> None:
|
||||
final, _ = FakeCard().resolve(Snapshot(0x1234, b"x", None))
|
||||
self.assertIn("invalid_rtc", final)
|
||||
self.assertIn("rev_4660", final)
|
||||
self.assertNotRegex(final, r"20\d\d-\d\d-\d\d")
|
||||
|
||||
def test_retention_prefers_old_duplicate_and_keeps_one_good(self) -> None:
|
||||
entries = [
|
||||
("2026-01/a.json", 1), ("2026-02/b.json", 1),
|
||||
("2026-03/c.json", 2),
|
||||
]
|
||||
duplicates = [path for path, crc in entries if sum(v == crc for _, v in entries) > 1]
|
||||
victim = min(duplicates)
|
||||
remaining = [entry for entry in entries if entry[0] != victim]
|
||||
self.assertEqual(victim, "2026-01/a.json")
|
||||
self.assertGreaterEqual(len(remaining), 1)
|
||||
|
||||
def test_restore_is_never_automatic_and_secrets_are_absent(self) -> None:
|
||||
text = document(Snapshot(1, b"x", None)).decode("utf-8")
|
||||
self.assertIn('"restore_policy": "manual_validation_only"', text)
|
||||
for forbidden in ("password", "secret", "unused_flash", "pending_gui"):
|
||||
self.assertNotIn(forbidden, text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user