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