363 lines
16 KiB
C
363 lines
16 KiB
C
#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;
|
||
}
|