Add embedded storage drivers and extend firmware metadata

This commit is contained in:
2026-09-27 01:44:59 +03:00
parent 795a1279b1
commit 2ad29e7ffd
52 changed files with 5801 additions and 3 deletions

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

View 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 */

View 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 */

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

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

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

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