From abd527323658c25a2aa55e02fa8a10b6eee8a606 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 29 Oct 2026 19:58:06 +0100 Subject: [PATCH 01/23] feat: keep the config in NVS and add the cfg partition migration Replace the littlefs config file with an NVS store. The config component becomes a framework (Nvs, Section, Store, Wire, Parse) and every module owns its own section: rf and estop in device_control, wifi, backend and otaUpdate in core, captivePortal in captive_portal, serialInput in serial_console. The store is the new cfg partition (the 1.x littlefs config partition retyped as data/nvs, same offset and size). On the 1.x table it lives in the shared nvs until cfg exists; the first boot that finds cfg erases the leftover littlefs and moves the config over. An empty store is filled once from the 1.x config file, else from defaults. The E-Stop latch is kept in the estop namespace, and WiFi credentials are one blob each. The WiFi driver's own NVS store is compiled out. Add partition_layout (parse the binary table, retype one entry in place and re-seal its MD5, guarded table write with write-back) and Config::MigrateToCfgPartition, which retypes config as cfg. Nothing calls it yet. --- components/captive_portal/CMakeLists.txt | 6 +- .../captiveportal/CaptivePortalConfig.h | 39 + .../src/captiveportal/CaptivePortalConfig.cpp | 99 ++ .../captiveportal/CaptivePortalInstance.cpp | 2 +- .../src/captiveportal/Manager.cpp | 4 +- components/chipset/CMakeLists.txt | 2 +- components/chipset/include/Chipset.h | 2 +- components/config/CMakeLists.txt | 39 +- components/config/host_test/CMakeLists.txt | 11 +- .../config/host_test/main/CMakeLists.txt | 83 +- .../host_test/main/config_test_helpers.cpp | 139 ++- .../host_test/main/config_test_helpers.h | 135 ++- .../config/host_test/main/stubs/fs/ConfigFs.h | 66 -- .../host_test/main/stubs/fs/LfsPartition.h | 83 ++ .../config/host_test/main/test_corrupt.cpp | 244 ++--- .../config/host_test/main/test_defaults.cpp | 205 +++-- .../host_test/main/test_estop_latch.cpp | 104 +++ .../config/host_test/main/test_legacy.cpp | 117 ++- .../config/host_test/main/test_roundtrip.cpp | 317 +++---- .../config/host_test/main/test_storage.cpp | 160 ++++ .../config/host_test/partitions_test.csv | 10 + .../config/host_test/sdkconfig.defaults | 9 +- .../config/include/config/BackendConfig.h | 24 - .../include/config/CaptivePortalConfig.h | 20 - components/config/include/config/Config.h | 113 --- components/config/include/config/ConfigBase.h | 24 - .../config/include/config/EStopConfig.h | 27 - components/config/include/config/Migration.h | 24 + components/config/include/config/Nvs.h | 114 +++ .../config/include/config/OtaUpdateConfig.h | 36 - .../config/{internal/utils.h => Parse.h} | 39 +- components/config/include/config/RFConfig.h | 23 - components/config/include/config/RootConfig.h | 32 - components/config/include/config/Section.h | 251 +++++ .../config/include/config/SerialInputConfig.h | 20 - components/config/include/config/Store.h | 38 + components/config/include/config/WiFiConfig.h | 27 - components/config/include/config/Wire.h | 34 + components/config/src/BackendConfig.cpp | 85 -- components/config/src/CaptivePortalConfig.cpp | 66 -- components/config/src/Config.cpp | 868 ------------------ components/config/src/EStopConfig.cpp | 102 -- components/config/src/Migration.cpp | 99 ++ components/config/src/Nvs.cpp | 483 ++++++++++ components/config/src/OtaUpdateConfig.cpp | 151 --- components/config/src/Parse.cpp | 34 + components/config/src/RFConfig.cpp | 90 -- components/config/src/RootConfig.cpp | 201 ---- components/config/src/Section.cpp | 57 ++ components/config/src/SerialInputConfig.cpp | 66 -- components/config/src/Store.cpp | 339 +++++++ components/config/src/WiFiConfig.cpp | 103 --- components/config/src/WiFiCredentials.cpp | 159 ---- components/config/src/Wire.cpp | 278 ++++++ components/config/src/internal/utils.cpp | 44 - components/core/include/BackendConfig.h | 48 + components/core/include/OtaUpdateConfig.h | 72 ++ .../include/wifi}/WiFiAuthMode.h | 4 +- components/core/include/wifi/WiFiConfig.h | 61 ++ .../include/wifi}/WiFiCredentials.h | 27 +- components/core/src/BackendConfig.cpp | 157 ++++ components/core/src/GatewayClient.cpp | 2 +- .../core/src/GatewayConnectionManager.cpp | 4 +- components/core/src/OtaUpdateConfig.cpp | 258 ++++++ components/core/src/http/JsonAPI.cpp | 4 +- components/core/src/serialization/WSLocal.cpp | 4 +- components/core/src/wifi/WiFiConfig.cpp | 453 +++++++++ components/core/src/wifi/WiFiCredentials.cpp | 228 +++++ components/core/src/wifi/WiFiManager.cpp | 2 +- components/device_control/CMakeLists.txt | 3 +- .../host_test/main/CMakeLists.txt | 5 +- .../host_test/main/host_fakes.h | 3 +- .../host_test/main/stubs/estop/EStopConfig.h | 17 + .../{config/Config.h => radio/RFConfig.h} | 12 +- .../include/estop/EStopConfig.h | 68 ++ .../include/estop/EStopManager.h | 3 + .../device_control/include/radio/RFConfig.h | 45 + .../device_control/src/CommandHandler.cpp | 3 +- .../device_control/src/EStopManager.cpp | 67 +- .../device_control/src/estop/EStopConfig.cpp | 231 +++++ .../device_control/src/radio/RFConfig.cpp | 156 ++++ .../host_test/main/test_config_partition.cpp | 2 +- components/ota/src/OtaUpdateManager.cpp | 4 +- components/partition_layout/CMakeLists.txt | 12 + .../partition_layout/host_test/CMakeLists.txt | 11 + .../host_test/main/CMakeLists.txt | 13 + .../host_test/main/test_main.cpp | 10 + .../host_test/main/test_partition_table.cpp | 195 ++++ .../include/partition_layout/LiveTable.h | 32 + .../include/partition_layout/PartitionTable.h | 78 ++ components/partition_layout/src/LiveTable.cpp | 98 ++ components/partition_layout/src/Md5.cpp | 84 ++ components/partition_layout/src/Md5.h | 11 + .../partition_layout/src/PartitionTable.cpp | 185 ++++ components/serial_console/CMakeLists.txt | 3 +- .../serial_console/SerialInputConfig.h | 41 + .../serial_console/src/SerialInputConfig.cpp | 112 +++ .../serial_console/src/SerialInputHandler.cpp | 5 +- .../src/command_handlers/authtoken.cpp | 4 +- .../src/command_handlers/domain.cpp | 4 +- .../src/command_handlers/echo.cpp | 4 +- .../src/command_handlers/estop.cpp | 4 +- .../src/command_handlers/factoryreset.cpp | 2 +- .../src/command_handlers/hostname.cpp | 4 +- .../src/command_handlers/jsonconfig.cpp | 2 +- .../src/command_handlers/keepalive.cpp | 4 +- .../src/command_handlers/networks.cpp | 4 +- .../src/command_handlers/rawconfig.cpp | 2 +- .../src/command_handlers/rftxpin.cpp | 4 +- .../src/command_handlers/sysinfo.cpp | 4 + docs/STORAGE.md | 94 ++ main/main.cpp | 2 +- partitions/legacy/ota_4mb_v1.csv | 14 + partitions/ota_4mb.csv | 12 +- scripts/tests/test_build_scripts.py | 2 +- sdkconfig.defaults | 9 +- 116 files changed, 5975 insertions(+), 2980 deletions(-) create mode 100644 components/captive_portal/include/captiveportal/CaptivePortalConfig.h create mode 100644 components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp delete mode 100644 components/config/host_test/main/stubs/fs/ConfigFs.h create mode 100644 components/config/host_test/main/stubs/fs/LfsPartition.h create mode 100644 components/config/host_test/main/test_estop_latch.cpp create mode 100644 components/config/host_test/main/test_storage.cpp create mode 100644 components/config/host_test/partitions_test.csv delete mode 100644 components/config/include/config/BackendConfig.h delete mode 100644 components/config/include/config/CaptivePortalConfig.h delete mode 100644 components/config/include/config/Config.h delete mode 100644 components/config/include/config/ConfigBase.h delete mode 100644 components/config/include/config/EStopConfig.h create mode 100644 components/config/include/config/Migration.h create mode 100644 components/config/include/config/Nvs.h delete mode 100644 components/config/include/config/OtaUpdateConfig.h rename components/config/include/config/{internal/utils.h => Parse.h} (50%) delete mode 100644 components/config/include/config/RFConfig.h delete mode 100644 components/config/include/config/RootConfig.h create mode 100644 components/config/include/config/Section.h delete mode 100644 components/config/include/config/SerialInputConfig.h create mode 100644 components/config/include/config/Store.h delete mode 100644 components/config/include/config/WiFiConfig.h create mode 100644 components/config/include/config/Wire.h delete mode 100644 components/config/src/BackendConfig.cpp delete mode 100644 components/config/src/CaptivePortalConfig.cpp delete mode 100644 components/config/src/Config.cpp delete mode 100644 components/config/src/EStopConfig.cpp create mode 100644 components/config/src/Migration.cpp create mode 100644 components/config/src/Nvs.cpp delete mode 100644 components/config/src/OtaUpdateConfig.cpp create mode 100644 components/config/src/Parse.cpp delete mode 100644 components/config/src/RFConfig.cpp delete mode 100644 components/config/src/RootConfig.cpp create mode 100644 components/config/src/Section.cpp delete mode 100644 components/config/src/SerialInputConfig.cpp create mode 100644 components/config/src/Store.cpp delete mode 100644 components/config/src/WiFiConfig.cpp delete mode 100644 components/config/src/WiFiCredentials.cpp create mode 100644 components/config/src/Wire.cpp delete mode 100644 components/config/src/internal/utils.cpp create mode 100644 components/core/include/BackendConfig.h create mode 100644 components/core/include/OtaUpdateConfig.h rename components/{config/include/config => core/include/wifi}/WiFiAuthMode.h (91%) create mode 100644 components/core/include/wifi/WiFiConfig.h rename components/{config/include/config => core/include/wifi}/WiFiCredentials.h (52%) create mode 100644 components/core/src/BackendConfig.cpp create mode 100644 components/core/src/OtaUpdateConfig.cpp create mode 100644 components/core/src/wifi/WiFiConfig.cpp create mode 100644 components/core/src/wifi/WiFiCredentials.cpp create mode 100644 components/device_control/host_test/main/stubs/estop/EStopConfig.h rename components/device_control/host_test/main/stubs/{config/Config.h => radio/RFConfig.h} (57%) create mode 100644 components/device_control/include/estop/EStopConfig.h create mode 100644 components/device_control/include/radio/RFConfig.h create mode 100644 components/device_control/src/estop/EStopConfig.cpp create mode 100644 components/device_control/src/radio/RFConfig.cpp create mode 100644 components/partition_layout/CMakeLists.txt create mode 100644 components/partition_layout/host_test/CMakeLists.txt create mode 100644 components/partition_layout/host_test/main/CMakeLists.txt create mode 100644 components/partition_layout/host_test/main/test_main.cpp create mode 100644 components/partition_layout/host_test/main/test_partition_table.cpp create mode 100644 components/partition_layout/include/partition_layout/LiveTable.h create mode 100644 components/partition_layout/include/partition_layout/PartitionTable.h create mode 100644 components/partition_layout/src/LiveTable.cpp create mode 100644 components/partition_layout/src/Md5.cpp create mode 100644 components/partition_layout/src/Md5.h create mode 100644 components/partition_layout/src/PartitionTable.cpp create mode 100644 components/serial_console/include/serial_console/SerialInputConfig.h create mode 100644 components/serial_console/src/SerialInputConfig.cpp create mode 100644 docs/STORAGE.md create mode 100644 partitions/legacy/ota_4mb_v1.csv diff --git a/components/captive_portal/CMakeLists.txt b/components/captive_portal/CMakeLists.txt index 4cd75554a..b678417e7 100644 --- a/components/captive_portal/CMakeLists.txt +++ b/components/captive_portal/CMakeLists.txt @@ -8,11 +8,13 @@ # captiveportal/Manager.h (std types only); CaptivePortalInstance.h stays in src/ so # esp_http_server and friends do not leak into consumers. idf_component_register( - SRCS "src/captiveportal/CaptivePortalInstance.cpp" + SRCS "src/captiveportal/CaptivePortalConfig.cpp" + "src/captiveportal/CaptivePortalInstance.cpp" "src/captiveportal/Manager.cpp" INCLUDE_DIRS "include" PRIV_INCLUDE_DIRS "src" - PRIV_REQUIRES core config device_control # wifi/gateway/message handlers/AppHooks; Config::; CommandHandler/EStopManager + REQUIRES config serialization # its config section (captiveportal/CaptivePortalConfig.h) + PRIV_REQUIRES core device_control # wifi/gateway/message handlers/AppHooks; Config::; CommandHandler/EStopManager common chipset logging temporal osjson serialization # foundation utilities, json, HubToLocalMessage fbs dns_server fs http hwutil rfc8908 # DNSServer, StaticFs, ContentTypes, PartitionUtils, RFC8908Handler esp_http_server esp_netif esp_timer esp_wifi diff --git a/components/captive_portal/include/captiveportal/CaptivePortalConfig.h b/components/captive_portal/include/captiveportal/CaptivePortalConfig.h new file mode 100644 index 000000000..e36362cbc --- /dev/null +++ b/components/captive_portal/include/captiveportal/CaptivePortalConfig.h @@ -0,0 +1,39 @@ +#pragma once + +// The `captivePortal` config section. Owned by captive_portal and kept in NVS through the config framework +// (config/Section.h); namespace "portal", key always (u8). + +#include "config/Section.h" +#include "serialization/_fbs/HubConfig_generated.h" + +#include + +namespace OpenShock::JSON { + class JsonView; +} + +namespace OpenShock::Config { + struct CaptivePortalConfig { + CaptivePortalConfig(); + CaptivePortalConfig(bool alwaysEnabled); + + bool alwaysEnabled; + + void ToDefault(); + + [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; + + /// @brief Reads this section from `partition`; a missing key is its default, a read error fails. + bool LoadNvs(const char* partition); + /// @brief Writes the keys that differ from `previous` (every key when null) to `partition`. + bool SaveNvs(const char* partition, const CaptivePortalConfig* previous) const; + + bool FromJSON(JSON::JsonView json); + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; + }; + + Section& CaptivePortalSection(); + + [[nodiscard]] std::optional GetCaptivePortalConfig(); + bool SetCaptivePortalConfig(const CaptivePortalConfig& config); +} // namespace OpenShock::Config diff --git a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp new file mode 100644 index 000000000..f442ab366 --- /dev/null +++ b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp @@ -0,0 +1,99 @@ +#include + +#include "captiveportal/CaptivePortalConfig.h" + +const char* const TAG = "Config::CaptivePortalConfig"; + +#include "config/Nvs.h" +#include "config/Parse.h" +#include "Logging.h" + +#include "json/Json.h" + +using namespace OpenShock::Config; +namespace Fbs = OpenShock::Serialization::Configuration; + +static constexpr const char* kNamespace = "portal"; +static constexpr const char* kKeyAlways = "always"; + +static constexpr const char* kNamespaces[] = {kNamespace}; +static Section s_section({.name = "captivePortal", .order = 2, .hubConfigField = Fbs::HubConfig::VT_CAPTIVE_PORTAL, .namespaces = kNamespaces}); + +Section& OpenShock::Config::CaptivePortalSection() +{ + return s_section; +} + +CaptivePortalConfig::CaptivePortalConfig() + : alwaysEnabled(false) +{ +} + +CaptivePortalConfig::CaptivePortalConfig(bool alwaysEnabled) + : alwaysEnabled(alwaysEnabled) +{ +} + +void CaptivePortalConfig::ToDefault() +{ + alwaysEnabled = false; +} + +flatbuffers::Offset CaptivePortalConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + return Serialization::Configuration::CreateCaptivePortalConfig(builder, alwaysEnabled); +} + +bool CaptivePortalConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + alwaysEnabled = json["alwaysEnabled"].tryGetBool().value_or(false); + + return true; +} + +void CaptivePortalConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + json_gen_obj_set_bool(gen, "alwaysEnabled", alwaysEnabled); + JSON::objEnd(gen, name); +} + +bool CaptivePortalConfig::LoadNvs(const char* partition) +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, false); + if (!handle) { + ToDefault(); + return true; + } + return Nvs::Load(*handle, kKeyAlways, alwaysEnabled, false); +} + +bool CaptivePortalConfig::SaveNvs(const char* partition, const CaptivePortalConfig* previous) const +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, true); + if (!handle) { + return false; + } + return handle->SetBool(kKeyAlways, alwaysEnabled, previous != nullptr ? &previous->alwaysEnabled : nullptr) && handle->Commit(); +} + +std::optional OpenShock::Config::GetCaptivePortalConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetCaptivePortalConfig(const CaptivePortalConfig& config) +{ + return s_section.Set(config); +} diff --git a/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp b/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp index b369b79bb..857f616ae 100644 --- a/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp +++ b/components/captive_portal/src/captiveportal/CaptivePortalInstance.cpp @@ -9,7 +9,6 @@ const char* const TAG = "CaptivePortalInstance"; #include "BoardDescriptor.h" #include "captiveportal/Manager.h" #include "CommandHandler.h" -#include "config/Config.h" #include "Convert.h" #include "enums/OtaUpdateChannel.h" #include "GatewayConnectionManager.h" @@ -17,6 +16,7 @@ const char* const TAG = "CaptivePortalInstance"; #include "hwutil/PartitionUtils.h" #include "Logging.h" #include "message_handlers/WebSocket.h" +#include "OtaUpdateConfig.h" #include "RateLimiter.h" #include "rfc8908/RFC8908Handler.h" #include "serialization/WSLocal.h" diff --git a/components/captive_portal/src/captiveportal/Manager.cpp b/components/captive_portal/src/captiveportal/Manager.cpp index d07ef2e58..e8610875f 100644 --- a/components/captive_portal/src/captiveportal/Manager.cpp +++ b/components/captive_portal/src/captiveportal/Manager.cpp @@ -7,13 +7,15 @@ const char* const TAG = "CaptivePortal"; #include "AppHooks.h" +#include "BackendConfig.h" +#include "captiveportal/CaptivePortalConfig.h" #include "captiveportal/CaptivePortalInstance.h" #include "CommandHandler.h" -#include "config/Config.h" #include "FormatHelpers.h" #include "GatewayConnectionManager.h" #include "Logging.h" #include "Temporal.h" +#include "wifi/WiFiConfig.h" #include #include diff --git a/components/chipset/CMakeLists.txt b/components/chipset/CMakeLists.txt index 332035831..868d04bc2 100644 --- a/components/chipset/CMakeLists.txt +++ b/components/chipset/CMakeLists.txt @@ -13,6 +13,6 @@ idf_component_register( SRCS "src/BoardChecks.cpp" INCLUDE_DIRS "include" REQUIRES common # Common.h, BoardDescriptor.h - esp_driver_gpio # driver/gpio.h + esp_hal_gpio # hal/gpio_types.h (gpio_num_t, GPIO_IS_VALID_*) PRIV_REQUIRES logging # OS_LOG* in BoardChecks.cpp ) diff --git a/components/chipset/include/Chipset.h b/components/chipset/include/Chipset.h index 8587768c9..69636601a 100644 --- a/components/chipset/include/Chipset.h +++ b/components/chipset/include/Chipset.h @@ -3,7 +3,7 @@ #include "BoardDescriptor.h" #include "OpenShock.h" -#include +#include // gpio_num_t, GPIO_IS_VALID_*: the types only, no driver #include #include diff --git a/components/config/CMakeLists.txt b/components/config/CMakeLists.txt index 6a0efc4a1..c7d289e67 100644 --- a/components/config/CMakeLists.txt +++ b/components/config/CMakeLists.txt @@ -1,20 +1,31 @@ -# OpenShock configuration model: typed config structs with JSON + flatbuffer -# (de)serialization, persisted to littlefs via the fs component. +# The config framework. It owns no settings: every module defines its own section (a value type plus a +# Config::Section, config/Section.h) and its own accessors, and keeps it in NVS through what is here: # -# Extracted out of `core` into a low-layer component: it depends only on the -# generated wire schema (serialization), JSON, the config filesystem and a few -# foundation utilities; nothing in core. This is what lets device_control (estop/ -# radio/visual/CommandHandler, which read Config::) leave core in a later step -# without a config <-> core cycle. -FILE(GLOB_RECURSE config_sources - ${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp) - +# config/Nvs.h typed access to one NVS namespace, the primitive every section's LoadNvs/SaveNvs uses +# config/Section.h the section template: RAM cache, serialized writers, saving only what changed, the registry +# config/Store.h picking the store (`cfg`, or the shared `nvs` on the 1.x layout), the move between them, +# filling an empty store (the 1.x file, else defaults), factory reset, the whole-config JSON +# config/Wire.h the whole config as the FlatBuffers HubConfig (local protocol Ready message, rawconfig), and +# HubConfig -> JSON, which is also how the 1.x config file is imported +# config/Migration.h retyping the 1.x littlefs `config` partition as the `cfg` store (not wired up yet) +# config/Parse.h field parsing helpers for sections' FromJSON +# +# The 1.x import (Store.cpp's readLegacyConfig, littlefs through fs) is 1.x compatibility code: it goes away on +# master once the release/1.9 branch is cut. idf_component_register( - SRCS ${config_sources} + SRCS "src/Migration.cpp" + "src/Nvs.cpp" + "src/Parse.cpp" + "src/Section.cpp" + "src/Store.cpp" + "src/Wire.cpp" INCLUDE_DIRS "include" - REQUIRES common osjson serialization flatbuffers # in public headers (enums, json/Json.h, HubConfig fbs, TinyVec, flatbuffers::Offset) - esp_wifi esp_hal_gpio # WiFiCredentials.h -> esp_wifi_types.h (wifi_auth_mode_t); RFConfig.h -> hal/gpio_types.h (gpio_num_t) - PRIV_REQUIRES chipset fs logging # .cpp only (GPIO validation, ConfigFs, OS_LOG*/OS_PANIC; SimpleMutex via common) + REQUIRES common osjson # OpenShock.h, SimpleMutex.h, TinyVec.h, util/FunctionRef.h; json/Json.h and json_generator.h + nvs_flash # nvs.h in Nvs.h + serialization flatbuffers # HubConfig in Wire.h + esp_hal_gpio # hal/gpio_types.h in Parse.h + PRIV_REQUIRES fs logging esp_partition # LfsPartition (the 1.x import), OS_LOG*/OS_PANIC, the partition lookups + partition_layout esp_system # the config partition retype, esp_restart ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/components/config/host_test/CMakeLists.txt b/components/config/host_test/CMakeLists.txt index 261b4a330..930a8f649 100644 --- a/components/config/host_test/CMakeLists.txt +++ b/components/config/host_test/CMakeLists.txt @@ -7,13 +7,12 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake) set(COMPONENTS main) # No freertos mock: this suite runs on ESP-IDF's real FreeRTOS POSIX port, so the real -# SimpleMutexes guard the Config:: store exactly as on the device. The driver mock -# provides for the real Chipset.h, the esp_wifi mock the real -# (wifi_auth_mode_t) - neither has a linux implementation; the -# esp_wifi mock in turn needs the esp_netif and lwip mocks. Plus the shared host-test -# stand-ins (Logging.h, openshock_board.h, CONFIG_OPENSHOCK_*). +# SimpleMutexes guard the Config:: store exactly as on the device, and the real nvs_flash +# and esp_partition (linux flash emulation) hold it. The esp_wifi mock provides the real +# (wifi_auth_mode_t), which has no linux implementation; it in turn +# needs the esp_netif and lwip mocks. Plus the shared host-test stand-ins (Logging.h, +# openshock_board.h, CONFIG_OPENSHOCK_*). list(APPEND EXTRA_COMPONENT_DIRS - "$ENV{IDF_PATH}/tools/mocks/driver/" "$ENV{IDF_PATH}/tools/mocks/esp_wifi/" "$ENV{IDF_PATH}/tools/mocks/esp_netif/" "$ENV{IDF_PATH}/tools/mocks/lwip/" diff --git a/components/config/host_test/main/CMakeLists.txt b/components/config/host_test/main/CMakeLists.txt index f4b5dc1c3..792a55fd8 100644 --- a/components/config/host_test/main/CMakeLists.txt +++ b/components/config/host_test/main/CMakeLists.txt @@ -1,51 +1,62 @@ -# Compile config's sources directly (not via the config component, whose logging -# dependency pulls in app_update, which has no linux port). Config.cpp is included, -# so the tests drive the real Config:: store (Init, SetRaw, GetAsJSON / SaveFromJSON) -# under the real SimpleMutexes, and Config::Init finds its partition in the -# firmware's real partition table through ESP-IDF's linux esp_partition. Only the -# filesystem is faked: stubs/fs/ConfigFs.h is an in-memory file store the tests seed -# and inspect. osjson's Json.cpp and the common sources config needs are compiled in -# as well (jsmn / json_generator come from idf_component.yml). +# The config framework (components/config) and every module's config section, compiled directly (not via their +# components, whose logging dependency pulls in app_update, which has no linux port). So the tests drive the store +# as the firmware assembles it: Config::Init, the modules' accessors, jsonconfig, rawconfig and factory reset, under +# the real SimpleMutexes, against ESP-IDF's real NVS over the linux flash emulation and the partition table in +# ../partitions_test.csv. Only the 1.x littlefs reader is faked: stubs/fs/LfsPartition.h is an in-memory file store +# the tests seed with the config file a 1.x hub leaves behind. osjson's Json.cpp and the common sources needed are +# compiled in as well (jsmn / json_generator come from idf_component.yml). +set(_components ../../..) idf_component_register( SRCS "test_main.cpp" "config_test_helpers.cpp" - "test_roundtrip.cpp" # flatbuffer + jsonconfig round trips through the store - "test_defaults.cpp" # missing file / sections / fields - "test_legacy.cpp" # configs written by the Arduino firmware (legacy_fixtures.h) + "test_roundtrip.cpp" # API + jsonconfig + rawconfig round trips through the NVS store + "test_defaults.cpp" # empty store / missing namespaces / missing keys + "test_legacy.cpp" # the 1.x config file (legacy_fixtures.h) imported on first boot "test_corrupt.cpp" # truncated / garbage / tampered input - "../../src/BackendConfig.cpp" - "../../src/CaptivePortalConfig.cpp" - "../../src/Config.cpp" - "../../src/EStopConfig.cpp" - "../../src/OtaUpdateConfig.cpp" - "../../src/RFConfig.cpp" - "../../src/RootConfig.cpp" - "../../src/SerialInputConfig.cpp" - "../../src/WiFiConfig.cpp" - "../../src/WiFiCredentials.cpp" - "../../src/internal/utils.cpp" - "../../../osjson/src/Json.cpp" - "../../../common/src/Convert.cpp" # Json.cpp strict string->int - "../../../common/src/SimpleMutex.cpp" # Config.cpp's store locks - "../../../common/src/StringHelpers.cpp" + "test_storage.cpp" # store selection, the move from the shared nvs into cfg, the registry + "test_estop_latch.cpp" # the E-Stop latch kept beside the estop section + # The framework + "${_components}/config/src/Nvs.cpp" + "${_components}/config/src/Parse.cpp" + "${_components}/config/src/Section.cpp" + "${_components}/config/src/Store.cpp" + "${_components}/config/src/Wire.cpp" + # Each module's section + "${_components}/device_control/src/radio/RFConfig.cpp" + "${_components}/device_control/src/estop/EStopConfig.cpp" + "${_components}/core/src/wifi/WiFiConfig.cpp" + "${_components}/core/src/wifi/WiFiCredentials.cpp" + "${_components}/core/src/BackendConfig.cpp" + "${_components}/core/src/OtaUpdateConfig.cpp" + "${_components}/captive_portal/src/captiveportal/CaptivePortalConfig.cpp" + "${_components}/serial_console/src/SerialInputConfig.cpp" + # What they use + "${_components}/osjson/src/Json.cpp" + "${_components}/common/src/Convert.cpp" # Json.cpp strict string->int; credential keys + "${_components}/common/src/SimpleMutex.cpp" # the section and store locks + "${_components}/common/src/StringHelpers.cpp" INCLUDE_DIRS "." - "stubs" # fs/ConfigFs.h (in-memory) - "../../include" # config/*.h - "../../../chipset/include" # Chipset.h - "../../../common/include" # OpenShock.h, SimpleMutex.h, TinyVec.h, enums/*, util/HexUtils.h - "../../../osjson/include" # json/Json.h - "../../../serialization/include" # serialization/_fbs/HubConfig_generated.h - "../../../flatbuffers/flatbuffers/include" + "stubs" # fs/LfsPartition.h (in-memory) + "${_components}/config/include" # config/*.h + "${_components}/device_control/include" # radio/RFConfig.h, estop/EStopConfig.h + "${_components}/core/include" # wifi/WiFiConfig.h, BackendConfig.h, OtaUpdateConfig.h + "${_components}/captive_portal/include" # captiveportal/CaptivePortalConfig.h + "${_components}/serial_console/include" # serial_console/SerialInputConfig.h + "${_components}/chipset/include" # Chipset.h + "${_components}/common/include" # OpenShock.h, SimpleMutex.h, TinyVec.h, enums/*, util/HexUtils.h + "${_components}/osjson/include" # json/Json.h + "${_components}/serialization/include" # serialization/_fbs/HubConfig_generated.h, VerifiedRoot.h + "${_components}/flatbuffers/flatbuffers/include" REQUIRES unity host_support # Logging.h, openshock_board.h, ESP32 GPIO caps (test/host_support) freertos # SimpleMutex (real POSIX port) - esp_partition # Config::Init's partition lookup (linux flash emulation) - driver # Chipset.h -> driver/gpio.h (tools/mocks/driver) + esp_partition # the partition table (linux flash emulation) + nvs_flash # the store itself esp_wifi # WiFiCredentials.h -> esp_wifi_types.h (tools/mocks/esp_wifi) esp_hal_gpio # hal/gpio_types.h (gpio_num_t) espressif__jsmn espressif__json_generator - WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs (and the sections' registry entries) from being stripped ) # Same as the config component itself. diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index 979ae436a..278c99a57 100644 --- a/components/config/host_test/main/config_test_helpers.cpp +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -1,7 +1,13 @@ +#include + #include "config_test_helpers.h" #include "unity.h" +#include "json/Json.h" + +#include + #include #include #include @@ -9,9 +15,47 @@ using namespace OpenShock; namespace ConfigTest { - Config::RootConfig MakeSampleConfig() + void Full::ToDefault() { - Config::RootConfig config; + rf.ToDefault(); + wifi.ToDefault(); + captivePortal.ToDefault(); + backend.ToDefault(); + serialInput.ToDefault(); + otaUpdate.ToDefault(); + estop.ToDefault(); + } + + bool Full::SaveNvs(const char* partition) const + { + if (!rf.SaveNvs(partition, nullptr) || !wifi.SaveNvs(partition, nullptr) || !captivePortal.SaveNvs(partition, nullptr) || !backend.SaveNvs(partition, nullptr) || !serialInput.SaveNvs(partition, nullptr) || !otaUpdate.SaveNvs(partition, nullptr) + || !estop.SaveNvs(partition, nullptr)) + { + return false; + } + auto root = Config::Nvs::Handle::Open(partition, Keys::Ns::kRoot, true); + return root && root->SetU8(Keys::Root::kSchema, Keys::kSchemaVersion) && root->Commit(); + } + + std::string Full::ToJSON(bool withSensitiveData) const + { + JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + json_gen_start_object(gen); + rf.ToJSON(gen, "rf", withSensitiveData); + wifi.ToJSON(gen, "wifi", withSensitiveData); + captivePortal.ToJSON(gen, "captivePortal", withSensitiveData); + backend.ToJSON(gen, "backend", withSensitiveData); + serialInput.ToJSON(gen, "serialInput", withSensitiveData); + otaUpdate.ToJSON(gen, "otaUpdate", withSensitiveData); + estop.ToJSON(gen, "estop", withSensitiveData); + json_gen_end_object(gen); + return writer.finish(); + } + + Full MakeSampleConfig() + { + Full config; config.rf = Config::RFConfig(GPIO_NUM_25, false); @@ -30,54 +74,97 @@ namespace ConfigTest { return config; } - std::vector Serialize(const Config::RootConfig& config, bool withSensitiveData) + std::vector Serialize(const Full& config, bool withSensitiveData) { // ForceDefaults like the firmware's own writes, so the bytes match what Config stores and GetRaw returns flatbuffers::FlatBufferBuilder builder; builder.ForceDefaults(true); - auto root = config.ToFlatbuffers(builder, withSensitiveData); + auto rf = config.rf.ToFlatbuffers(builder, withSensitiveData); + auto wifi = config.wifi.ToFlatbuffers(builder, withSensitiveData); + auto captivePortal = config.captivePortal.ToFlatbuffers(builder, withSensitiveData); + auto backend = config.backend.ToFlatbuffers(builder, withSensitiveData); + auto serialInput = config.serialInput.ToFlatbuffers(builder, withSensitiveData); + auto otaUpdate = config.otaUpdate.ToFlatbuffers(builder, withSensitiveData); + auto estop = config.estop.ToFlatbuffers(builder, withSensitiveData); + auto root = Serialization::Configuration::CreateHubConfig(builder, rf, wifi, captivePortal, backend, serialInput, otaUpdate, estop); Serialization::Configuration::FinishHubConfigBuffer(builder, root); return std::vector(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); } - bool Deserialize(const uint8_t* data, std::size_t size, Config::RootConfig& out) + bool Deserialize(const uint8_t* data, std::size_t size, Full& out) { - if (size > Config::MaxConfigSize) { - return false; // the Verifier constructor would assert + auto json = Config::HubConfigToJSON(std::span(data, size)); + if (!json) { + return false; } - flatbuffers::Verifier::Options options { - .max_size = Config::MaxConfigSize + 1, - }; - flatbuffers::Verifier verifier(data, size, options); - if (!verifier.VerifyBuffer()) { + JSON::JsonDocument doc; + if (!doc.parse(*json)) { return false; } - return out.FromFlatbuffers(flatbuffers::GetRoot(data)); + JSON::JsonView root = doc.root(); + return out.rf.FromJSON(root["rf"]) && out.wifi.FromJSON(root["wifi"]) && out.captivePortal.FromJSON(root["captivePortal"]) && out.backend.FromJSON(root["backend"]) && out.serialInput.FromJSON(root["serialInput"]) + && out.otaUpdate.FromJSON(root["otaUpdate"]) && out.estop.FromJSON(root["estop"]); + } + + static void wipePartition(const char* label) + { + nvs_flash_deinit_partition(label); // ESP_ERR_NVS_NOT_INITIALIZED when it is not up, which is fine + TEST_ASSERT_EQUAL(ESP_OK, nvs_flash_erase_partition(label)); } - bool InitFrom(const uint8_t* data, std::size_t size) + void WipeStore() { - HostConfigFs::reset(); + wipePartition(Keys::kPartitionCfg); + wipePartition(Keys::kPartitionShared); + // Back up right away, so a test can write straight into either store; Config::Init's own init is then a no-op + TEST_ASSERT_EQUAL(ESP_OK, nvs_flash_init_partition(Keys::kPartitionCfg)); + TEST_ASSERT_EQUAL(ESP_OK, nvs_flash_init_partition(Keys::kPartitionShared)); + HostLfs::reset(); + } + + void InitDefault() + { + WipeStore(); + Config::Init(); + } + + bool InitFromLegacy(const uint8_t* data, std::size_t size) + { + WipeStore(); + Full expected; + const bool parses = data != nullptr && Deserialize(data, size, expected); if (data != nullptr) { - HostConfigFs::files()[kConfigPath].assign(data, data + size); + HostLfs::files()[kLegacyPath].assign(data, data + size); } Config::Init(); - return HostConfigFs::writeCount == 0; + TEST_ASSERT_EQUAL_INT_MESSAGE(0, HostLfs::writeCount, "the 1.x file must be left untouched"); + if (!parses) { + return false; + } + // Imported when what the hub runs with is what the file held (as this firmware decodes it) + return expected.ToJSON() == Snapshot().ToJSON(); } - void InitDefault() + void Reload() + { + Config::Init(); + } + + void InitWith(const Full& config) { - InitFrom(nullptr, 0); - HostConfigFs::writeCount = 0; + WipeStore(); + TEST_ASSERT_TRUE(config.SaveNvs(Keys::kPartitionCfg)); + Reload(); } - std::vector StoredFile() + std::size_t UsedEntries() { - auto it = HostConfigFs::files().find(kConfigPath); - return it == HostConfigFs::files().end() ? std::vector() : it->second; + nvs_stats_t stats {}; + TEST_ASSERT_EQUAL(ESP_OK, nvs_get_stats(Keys::kPartitionCfg, &stats)); + return stats.used_entries; } - Config::RootConfig Snapshot() + Full Snapshot() { // Every getter must succeed; unwraps the returned optional. auto get = [](auto section) { @@ -85,7 +172,7 @@ namespace ConfigTest { return *std::move(section); }; - Config::RootConfig config; + Full config; config.rf = get(Config::GetRFConfig()); config.wifi = get(Config::GetWiFiConfig()); config.captivePortal = get(Config::GetCaptivePortalConfig()); @@ -105,7 +192,7 @@ namespace ConfigTest { TEST_ASSERT_EQUAL_UINT8_ARRAY_MESSAGE(expected.bssid.data(), actual.bssid.data(), 6, "wifi credentials bssid"); } - void AssertConfigEqual(const Config::RootConfig& expected, const Config::RootConfig& actual, bool withSensitiveData) + void AssertConfigEqual(const Full& expected, const Full& actual, bool withSensitiveData) { TEST_ASSERT_EQUAL_INT_MESSAGE(expected.rf.txPin, actual.rf.txPin, "rf.txPin"); TEST_ASSERT_EQUAL_MESSAGE(expected.rf.keepAliveEnabled, actual.rf.keepAliveEnabled, "rf.keepAliveEnabled"); diff --git a/components/config/host_test/main/config_test_helpers.h b/components/config/host_test/main/config_test_helpers.h index 24a86152d..cd295a898 100644 --- a/components/config/host_test/main/config_test_helpers.h +++ b/components/config/host_test/main/config_test_helpers.h @@ -1,51 +1,130 @@ #pragma once -// Shared helpers for the config host tests: a sample config with every field moved -// off its default, field-by-field comparison, and driving the real Config:: store -// (Config.cpp) through the in-memory ConfigFs stub. -#include "config/Config.h" -#include "config/RootConfig.h" +// Shared helpers for the config host tests. The suite runs the config framework (components/config) together with +// every module's section (device_control's rf and estop, core's wifi, backend and otaUpdate, captive_portal's +// captivePortal, serial_console's serialInput), so it checks the store as the firmware assembles it: over ESP-IDF's +// real NVS on the linux flash emulation, with the 1.x littlefs file faked by stubs/fs/LfsPartition.h. +#include "config/Nvs.h" +#include "config/Store.h" +#include "config/Wire.h" -#include "fs/ConfigFs.h" +#include "BackendConfig.h" +#include "captiveportal/CaptivePortalConfig.h" +#include "estop/EStopConfig.h" +#include "OtaUpdateConfig.h" +#include "radio/RFConfig.h" +#include "serial_console/SerialInputConfig.h" +#include "wifi/WiFiConfig.h" + +#include "fs/LfsPartition.h" #include #include +#include #include namespace ConfigTest { - // Path Config.cpp reads/writes inside the config partition. The Arduino firmware - // used the same file ("/config" on the "config" littlefs partition). - constexpr const char* kConfigPath = "/config"; + // Path the store's 1.x import reads inside the 1.x config partition (the Arduino firmware's "/config"). + constexpr const char* kLegacyPath = "/config"; + + // The storage format the tests check against: the store's own names and each module's. Written out here on purpose, + // so a renamed key or namespace (which would orphan what devices hold) fails a test. + namespace Keys { + inline constexpr const char* kPartitionCfg = "cfg"; + inline constexpr const char* kPartitionShared = "nvs"; + namespace Ns { + inline constexpr const char* kRoot = "root"; + inline constexpr const char* kBackend = "backend"; + inline constexpr const char* kWifi = "wifi"; + inline constexpr const char* kWifiCfg = "wifi.cfg"; + inline constexpr const char* kRf = "rf"; + inline constexpr const char* kEstop = "estop"; + inline constexpr const char* kPortal = "portal"; + inline constexpr const char* kSerial = "serial"; + inline constexpr const char* kOta = "ota"; + inline constexpr const char* kAll[] = {kRoot, kBackend, kWifi, kWifiCfg, kRf, kEstop, kPortal, kSerial, kOta}; + } // namespace Ns + namespace Root { + inline constexpr const char* kSchema = "schema"; + } + inline constexpr uint8_t kSchemaVersion = 1; + namespace Backend { + inline constexpr const char* kDomain = "domain"; + inline constexpr const char* kToken = "token"; + } // namespace Backend + namespace WifiCfg { + inline constexpr const char* kApSsid = "ap_ssid"; + inline constexpr const char* kHostname = "hostname"; + } // namespace WifiCfg + namespace Rf { + inline constexpr const char* kTxPin = "tx_pin"; + inline constexpr const char* kKeepAlive = "keepalive"; + } // namespace Rf + namespace Estop { + inline constexpr const char* kEnabled = "enabled"; + inline constexpr const char* kGpio = "gpio"; + inline constexpr const char* kLatched = "latched"; + } // namespace Estop + namespace Ota { + inline constexpr const char* kUpdateId = "update_id"; + } + } // namespace Keys + + // Every section's value at once: what a hub's whole config is (the role RootConfig had before the sections moved + // into their modules). Default-constructed, every member holds its defaults. + struct Full { + OpenShock::Config::RFConfig rf; + OpenShock::Config::WiFiConfig wifi; + OpenShock::Config::CaptivePortalConfig captivePortal; + OpenShock::Config::BackendConfig backend; + OpenShock::Config::SerialInputConfig serialInput; + OpenShock::Config::OtaUpdateConfig otaUpdate; + OpenShock::Config::EStopConfig estop; + + void ToDefault(); + // Every section's keys, plus the store's schema marker, so the store loads rather than fills it + bool SaveNvs(const char* partition) const; + // As the jsonconfig document renders it + std::string ToJSON(bool withSensitiveData = true) const; + }; + + // Every field differs from the defaults. + Full MakeSampleConfig(); - // Every field differs from RootConfig's defaults. - OpenShock::Config::RootConfig MakeSampleConfig(); + // Serializes as the 1.x firmware stored it (each section's FlatBuffers form in a HubConfig): a 1.x config file. + std::vector Serialize(const Full& config, bool withSensitiveData = true); - // Serializes like Config.cpp's trySaveConfig (ToFlatbuffers + FinishHubConfigBuffer). - std::vector Serialize(const OpenShock::Config::RootConfig& config, bool withSensitiveData = true); + // Decodes a HubConfig the way the store imports one (HubConfig -> jsonconfig document -> each section's FromJSON). + bool Deserialize(const uint8_t* data, std::size_t size, Full& out); - // Verifies (same options as Config.cpp) and deserializes a stored buffer. - bool Deserialize(const uint8_t* data, std::size_t size, OpenShock::Config::RootConfig& out); + // Erases the `cfg` and shared `nvs` partitions and the fake 1.x file: a hub with nothing stored. + void WipeStore(); - // Seeds the stored config file (nullptr = no file), runs Config::Init, and returns - // true when Init loaded that file as-is (it rewrites the file only when it fell - // back to defaults). - bool InitFrom(const uint8_t* data, std::size_t size); - inline bool InitFrom(const std::vector& data) + // WipeStore + Config::Init: a hub booting on the defaults, which Init writes to the store. + void InitDefault(); + + // Boots as a 1.x hub would after installing this firmware: nothing in NVS, `data` as its config file + // (nullptr = no file). Returns whether Init imported the file (as opposed to falling back to defaults). + bool InitFromLegacy(const uint8_t* data, std::size_t size); + inline bool InitFromLegacy(const std::vector& data) { - return InitFrom(data.data(), data.size()); + return InitFromLegacy(data.data(), data.size()); } - // Resets the store to a freshly written default config. - void InitDefault(); + // A reboot: Config::Init again on whatever the store holds. + void Reload(); + + // Writes `config` to the store as the firmware leaves it, then Reload()s: a hub running it. + void InitWith(const Full& config); - // The bytes currently stored in the config file (empty if there is none). - std::vector StoredFile(); + // The number of entries the `cfg` partition has in use: unchanged across a save that changes nothing. + std::size_t UsedEntries(); - // Reads back every section through the public Config:: getters. - OpenShock::Config::RootConfig Snapshot(); + // Reads back every section through the modules' public accessors. + Full Snapshot(); // TEST_ASSERTs that every field of every section matches. When withSensitiveData // is false, passwords and the backend auth token are expected to be empty. - void AssertConfigEqual(const OpenShock::Config::RootConfig& expected, const OpenShock::Config::RootConfig& actual, bool withSensitiveData = true); + void AssertConfigEqual(const Full& expected, const Full& actual, bool withSensitiveData = true); void AssertCredentialsEqual(const OpenShock::Config::WiFiCredentials& expected, const OpenShock::Config::WiFiCredentials& actual, bool withSensitiveData = true); } // namespace ConfigTest diff --git a/components/config/host_test/main/stubs/fs/ConfigFs.h b/components/config/host_test/main/stubs/fs/ConfigFs.h deleted file mode 100644 index 3c81bfcee..000000000 --- a/components/config/host_test/main/stubs/fs/ConfigFs.h +++ /dev/null @@ -1,66 +0,0 @@ -#pragma once - -// Host-test stub for fs/ConfigFs.h: an in-memory file store instead of littlefs on -// a flash partition. Every ConfigFs instance shares one store, so tests can seed -// the file Config::Init loads and inspect what Config writes (see HostConfigFs). -#include "OpenShock.h" - -#include - -#include -#include -#include -#include -#include - -namespace OpenShock { - struct HostConfigFs { - static std::map>& files() - { - static std::map> s_files; - return s_files; - } - static inline bool failWrites = false; - static inline int writeCount = 0; - - static void reset() - { - files().clear(); - failWrites = false; - writeCount = 0; - } - }; - - class ConfigFs { - DISABLE_COPY(ConfigFs); - DISABLE_MOVE(ConfigFs); - - public: - ConfigFs() = default; - - bool mount(const esp_partition_t* partition) { return partition != nullptr; } - void unmount() { } - bool isMounted() const { return true; } - - bool exists(const char* path) { return HostConfigFs::files().contains(path); } - bool read(const char* path, std::vector& out) - { - auto it = HostConfigFs::files().find(path); - if (it == HostConfigFs::files().end()) { - return false; - } - out = it->second; - return true; - } - bool write(const char* path, std::span data) - { - if (HostConfigFs::failWrites) { - return false; - } - ++HostConfigFs::writeCount; - HostConfigFs::files()[path].assign(data.begin(), data.end()); - return true; - } - bool remove(const char* path) { return HostConfigFs::files().erase(path) != 0; } - }; -} // namespace OpenShock diff --git a/components/config/host_test/main/stubs/fs/LfsPartition.h b/components/config/host_test/main/stubs/fs/LfsPartition.h new file mode 100644 index 000000000..10ce14234 --- /dev/null +++ b/components/config/host_test/main/stubs/fs/LfsPartition.h @@ -0,0 +1,83 @@ +#pragma once + +// Host-test stub for fs/LfsPartition.h: an in-memory file store instead of littlefs on a flash partition, so the +// tests can stand in the config file a 1.x hub left behind (HostLfs::files()) and see that the import never writes +// to it. Every LfsPartition instance shares one store. +#include "OpenShock.h" +#include "util/FunctionRef.h" + +#include + +#include +#include +#include +#include +#include + +namespace OpenShock { + struct HostLfs { + static std::map>& files() + { + static std::map> s_files; + return s_files; + } + static inline bool mountable = true; // false: the partition holds no mountable image + static inline int writeCount = 0; // writes through the stub: the legacy import must never make one + + static void reset() + { + files().clear(); + mountable = true; + writeCount = 0; + } + }; + + class LfsPartition { + DISABLE_COPY(LfsPartition); + DISABLE_MOVE(LfsPartition); + + public: + LfsPartition() = default; + + bool mount(const esp_partition_t* partition, bool writable, bool formatOnFail = false) + { + (void)writable; + (void)formatOnFail; + m_mounted = partition != nullptr && HostLfs::mountable; + return m_mounted; + } + void unmount() { m_mounted = false; } + bool isMounted() const { return m_mounted; } + + bool exists(const char* path) { return m_mounted && HostLfs::files().contains(path); } + bool readStream(const char* path, Util::FunctionRef)> sink) + { + std::vector data; + return readAll(path, data) && sink(data); + } + bool readAll(const char* path, std::vector& out) + { + auto it = HostLfs::files().find(path); + if (!m_mounted || it == HostLfs::files().end()) { + out.clear(); + return false; + } + out = it->second; + return true; + } + bool writeAll(const char* path, std::span data) + { + ++HostLfs::writeCount; + HostLfs::files()[path].assign(data.begin(), data.end()); + return true; + } + bool remove(const char* path) + { + ++HostLfs::writeCount; + return HostLfs::files().erase(path) != 0; + } + + private: + bool m_mounted = false; + }; +} // namespace OpenShock diff --git a/components/config/host_test/main/test_corrupt.cpp b/components/config/host_test/main/test_corrupt.cpp index e62a5aad3..25855dd72 100644 --- a/components/config/host_test/main/test_corrupt.cpp +++ b/components/config/host_test/main/test_corrupt.cpp @@ -1,6 +1,7 @@ -// Corrupt input: truncated, garbage or tampered config buffers (from flash or from -// rawconfig/jsonconfig over serial) must be rejected without crashing, and must not -// replace the stored config. +// Corrupt input: a truncated, garbage or tampered 1.x config file, a malformed credential blob, or bad JSON over +// serial must be rejected without crashing, and must not replace the stored config. +#include + #include "unity.h" #include "config_test_helpers.h" @@ -26,57 +27,50 @@ namespace { return state; } - // Loads a buffer through every path a corrupt config can arrive by. Returns - // whether SetRaw accepted it; never crashes (that is the point of the callers). - bool TryAllLoadPaths(const std::vector& data) + // Runs a 1.x config blob through the import: the parser must reject it, and booting with it as the file must + // fall back to the defaults. Returns whether the parser accepted it; never crashes (that is the point). + bool TryImport(const std::vector& data) { - InitDefault(); - const bool accepted = Config::SetRaw(data.data(), data.size()); - if (!accepted) { - TEST_ASSERT_EQUAL_INT_MESSAGE(0, HostConfigFs::writeCount, "rejected SetRaw must not write"); - } - - // Booting with it stored: Init either loads it or falls back to defaults. - InitFrom(data); + Full parsed; + const bool accepted = Deserialize(data.data(), data.size(), parsed); + const bool imported = InitFromLegacy(data); + TEST_ASSERT_EQUAL_MESSAGE(accepted, imported, "the import and the parser must agree"); Snapshot(); return accepted; } } // namespace -TEST_CASE("SetRaw rejects null and too-short buffers", "[config][corrupt]") -{ - InitDefault(); - const std::vector before = StoredFile(); - - const uint8_t tiny[3] = {0x04, 0x00, 0x00}; - TEST_ASSERT_FALSE(Config::SetRaw(nullptr, 0)); - TEST_ASSERT_FALSE(Config::SetRaw(nullptr, 128)); - TEST_ASSERT_FALSE(Config::SetRaw(tiny, 0)); - TEST_ASSERT_FALSE(Config::SetRaw(tiny, sizeof(tiny))); - - TEST_ASSERT_EQUAL_INT(0, HostConfigFs::writeCount); - TEST_ASSERT_EQUAL_size_t(before.size(), StoredFile().size()); -} - TEST_CASE("Every truncation of a legacy config is rejected", "[config][corrupt]") { for (std::size_t len = 0; len < sizeof(Legacy::kDevelopConfig); ++len) { std::vector data(Legacy::kDevelopConfig, Legacy::kDevelopConfig + len); + Full parsed; char msg[48]; snprintf(msg, sizeof(msg), "truncated to %zu bytes", len); - TEST_ASSERT_FALSE_MESSAGE(TryAllLoadPaths(data), msg); + TEST_ASSERT_FALSE_MESSAGE(Deserialize(data.data(), data.size(), parsed), msg); } } -TEST_CASE("Truncated config on flash boots with defaults and rewrites the file", "[config][corrupt]") +TEST_CASE("A truncated 1.x file boots with defaults", "[config][corrupt]") { const std::vector truncated(Legacy::kDevelopConfig, Legacy::kDevelopConfig + sizeof(Legacy::kDevelopConfig) / 2); - TEST_ASSERT_FALSE(InitFrom(truncated)); // fell back - AssertConfigEqual(Config::RootConfig(), Snapshot()); + TEST_ASSERT_FALSE(InitFromLegacy(truncated)); // fell back + AssertConfigEqual(Full(), Snapshot()); + + // The defaults were written to NVS: the next boot loads them and leaves the bad file alone. + Reload(); + AssertConfigEqual(Full(), Snapshot()); + TEST_ASSERT_EQUAL_INT(0, HostLfs::writeCount); +} - // The rewritten file is a valid default config. - TEST_ASSERT_TRUE(InitFrom(StoredFile())); - AssertConfigEqual(Config::RootConfig(), Snapshot()); +TEST_CASE("An oversized 1.x file is rejected, not aborted on", "[config][corrupt]") +{ + // A well-formed flatbuffer, just larger than the 1.x partition could ever hold: the Verifier's constructor + // asserts (size < max_size), so the size is checked first. + Full sample = MakeSampleConfig(); + sample.wifi.hostname.assign(5000, 'h'); + TEST_ASSERT_FALSE(InitFromLegacy(Serialize(sample))); + AssertConfigEqual(Full(), Snapshot()); } TEST_CASE("Random garbage is rejected", "[config][corrupt]") @@ -85,10 +79,12 @@ TEST_CASE("Random garbage is rejected", "[config][corrupt]") for (int round = 0; round < 300; ++round) { std::vector data(4 + NextRandom(state) % 600); for (auto& b : data) b = static_cast(NextRandom(state)); + Full parsed; char msg[48]; snprintf(msg, sizeof(msg), "garbage round %d", round); - TEST_ASSERT_FALSE_MESSAGE(TryAllLoadPaths(data), msg); + TEST_ASSERT_FALSE_MESSAGE(Deserialize(data.data(), data.size(), parsed), msg); } + TryImport(std::vector(64, 0xFF)); } TEST_CASE("Bit flips in a legacy config never crash the loader", "[config][corrupt]") @@ -99,7 +95,8 @@ TEST_CASE("Bit flips in a legacy config never crash the loader", "[config][corru for (uint8_t mask : {0x01, 0x80, 0xFF}) { std::vector data(Legacy::kDevelopConfig, Legacy::kDevelopConfig + sizeof(Legacy::kDevelopConfig)); data[i] ^= mask; - TryAllLoadPaths(data); + Full parsed; + Deserialize(data.data(), data.size(), parsed); } } } @@ -111,13 +108,13 @@ TEST_CASE("Out-of-range offsets in the root table are rejected", "[config][corru // Root offset pointing past the end of the buffer. data[0] = 0xF0; data[1] = 0xFF; - TEST_ASSERT_FALSE(TryAllLoadPaths(data)); + TEST_ASSERT_FALSE(TryImport(data)); // Root offset pointing at the last byte. const uint32_t last = static_cast(data.size() - 1); data[0] = static_cast(last); data[1] = static_cast(last >> 8); - TEST_ASSERT_FALSE(TryAllLoadPaths(data)); + TEST_ASSERT_FALSE(TryImport(data)); } TEST_CASE("Unterminated and oversized strings are rejected", "[config][corrupt]") @@ -131,12 +128,79 @@ TEST_CASE("Unterminated and oversized strings are rejected", "[config][corrupt]" TEST_ASSERT_EQUAL_UINT8(needle.size(), data[lenPos]); data[lenPos + 1] = 0x7F; // ~32 KiB long - TEST_ASSERT_FALSE(TryAllLoadPaths(data)); + TEST_ASSERT_FALSE(TryImport(data)); // Correct length, but the terminating NUL overwritten. data[lenPos + 1] = 0x00; data[lenPos + 4 + needle.size()] = 'X'; - TEST_ASSERT_FALSE(TryAllLoadPaths(data)); + TEST_ASSERT_FALSE(TryImport(data)); +} + +TEST_CASE("Every truncation and corruption of a credential blob is rejected without crashing", "[config][corrupt]") +{ + Config::WiFiCredentials sample(7, "Office", "s3cret!", WIFI_AUTH_WPA2_PSK); + sample.bssid = {1, 2, 3, 4, 5, 6}; + const std::vector blob = sample.ToBlob(); + + Config::WiFiCredentials decoded; + TEST_ASSERT_TRUE(decoded.FromBlob(blob)); + AssertCredentialsEqual(sample, decoded); + + for (std::size_t len = 0; len < blob.size(); ++len) { + Config::WiFiCredentials out; + std::vector cut(blob.begin(), blob.begin() + len); + char msg[48]; + snprintf(msg, sizeof(msg), "blob truncated to %zu bytes", len); + TEST_ASSERT_FALSE_MESSAGE(out.FromBlob(cut), msg); + } + + // Length prefixes past the end, an id of 0, an empty ssid + std::vector bad = blob; + bad[9] = 200; + TEST_ASSERT_FALSE(decoded.FromBlob(bad)); + bad = blob; + bad[1] = 0; + TEST_ASSERT_FALSE(decoded.FromBlob(bad)); + bad = blob; + bad[9] = 0; + TEST_ASSERT_FALSE(decoded.FromBlob(bad)); + + uint32_t state = 0x9E3779B9; + for (int round = 0; round < 200; ++round) { + std::vector data(NextRandom(state) % 300); + for (auto& b : data) b = static_cast(NextRandom(state)); + Config::WiFiCredentials out; + out.FromBlob(data); // must not crash; the result does not matter + } +} + +TEST_CASE("The credential blob's auth mode codes never change", "[config][corrupt]") +{ + // What hubs hold on flash: owned by the blob format, not by ESP-IDF or the FlatBuffers schema + const std::pair codes[] = { + {WIFI_AUTH_OPEN, 0}, {WIFI_AUTH_WEP, 1}, {WIFI_AUTH_WPA_PSK, 2}, {WIFI_AUTH_WPA2_PSK, 3}, {WIFI_AUTH_WPA_WPA2_PSK, 4}, + {WIFI_AUTH_WPA2_ENTERPRISE, 5}, {WIFI_AUTH_WPA3_PSK, 6}, {WIFI_AUTH_WPA2_WPA3_PSK, 7}, {WIFI_AUTH_WAPI_PSK, 8}, {WIFI_AUTH_MAX, 9}, + }; + for (const auto& [mode, code] : codes) { + const auto blob = Config::WiFiCredentials(1, "Net", "pw", mode).ToBlob(); + TEST_ASSERT_EQUAL_UINT8(code, blob[2]); + Config::WiFiCredentials decoded; + TEST_ASSERT_TRUE(decoded.FromBlob(blob)); + TEST_ASSERT_EQUAL(mode, decoded.authMode); + } + // An auth mode the format has no code for is stored as unknown + TEST_ASSERT_EQUAL_UINT8(9, Config::WiFiCredentials(1, "Net", "pw", WIFI_AUTH_OWE).ToBlob()[2]); +} + +TEST_CASE("A version 1 blob with fields appended after the known ones still reads", "[config][corrupt]") +{ + // How the format grows: what a later firmware appends, an older one (after a rollback) skips + Config::WiFiCredentials sample(7, "Office", "s3cret!", WIFI_AUTH_WPA2_PSK); + std::vector blob = sample.ToBlob(); + blob.insert(blob.end(), {0x01, 0x02, 0x03}); + Config::WiFiCredentials decoded; + TEST_ASSERT_TRUE(decoded.FromBlob(blob)); + AssertCredentialsEqual(sample, decoded); } TEST_CASE("jsonconfig set rejects malformed JSON without touching the store", "[config][corrupt][json]") @@ -158,113 +222,67 @@ TEST_CASE("jsonconfig set rejects malformed JSON without touching the store", "[ "{\"captivePortal\":[]}", }; + InitWith(MakeSampleConfig()); + const std::size_t before = UsedEntries(); for (const char* input : inputs) { - TEST_ASSERT_TRUE(InitFrom(Serialize(MakeSampleConfig()))); - const std::vector before = StoredFile(); - TEST_ASSERT_FALSE_MESSAGE(Config::SaveFromJSON(input), input); - TEST_ASSERT_EQUAL_INT_MESSAGE(0, HostConfigFs::writeCount, input); - TEST_ASSERT_EQUAL_size_t(before.size(), StoredFile().size()); - TEST_ASSERT_EQUAL_UINT8_ARRAY(before.data(), StoredFile().data(), before.size()); + TEST_ASSERT_EQUAL_size_t_MESSAGE(before, UsedEntries(), input); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); } + Reload(); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); } TEST_CASE("Rejected jsonconfig set leaves the running config unchanged", "[config][corrupt][json]") { - const Config::RootConfig sample = MakeSampleConfig(); - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + const Full sample = MakeSampleConfig(); + InitWith(sample); + const std::size_t before = UsedEntries(); // rf parses, then estop fails: the call must be all-or-nothing. TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"rf":{"txPin":13,"keepAliveEnabled":true},"estop":{"enabled":"yes"}})")); - TEST_ASSERT_EQUAL_INT(0, HostConfigFs::writeCount); + TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); // Sections parsed before the failing one (rf) must not be applied either. AssertConfigEqual(sample, Snapshot()); } -TEST_CASE("jsonconfig set that cannot be stored leaves the running config unchanged", "[config][corrupt][json]") -{ - const Config::RootConfig sample = MakeSampleConfig(); - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); - const std::vector before = StoredFile(); - - HostConfigFs::failWrites = true; - TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"rf":{"txPin":13,"keepAliveEnabled":true}})")); - HostConfigFs::failWrites = false; - - AssertConfigEqual(sample, Snapshot()); - TEST_ASSERT_TRUE(before == StoredFile()); -} - -TEST_CASE("Adding WiFi credentials that cannot be stored changes nothing", "[config][corrupt]") -{ - const Config::RootConfig sample = MakeSampleConfig(); - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); - - HostConfigFs::failWrites = true; - TEST_ASSERT_EQUAL_UINT8(0, Config::AddWiFiCredentials("NewNet", "password1", WIFI_AUTH_WPA2_PSK)); // new SSID - TEST_ASSERT_EQUAL_UINT8(0, Config::AddWiFiCredentials("HomeNet", "changed!", WIFI_AUTH_WPA3_PSK)); // existing SSID - HostConfigFs::failWrites = false; - - AssertConfigEqual(sample, Snapshot()); -} - -TEST_CASE("Setters that cannot be stored leave the running config unchanged", "[config][corrupt]") -{ - const Config::RootConfig sample = MakeSampleConfig(); - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); - TEST_ASSERT_FALSE(sample.wifi.credentialsList.empty()); - - HostConfigFs::failWrites = true; - TEST_ASSERT_FALSE(Config::SetBackendAuthToken("replaced-token")); - TEST_ASSERT_FALSE(Config::ClearBackendAuthToken()); - TEST_ASSERT_FALSE(Config::SetWiFiHostname("other-host")); - TEST_ASSERT_FALSE(Config::SetRFConfigKeepAliveEnabled(!sample.rf.keepAliveEnabled)); - TEST_ASSERT_FALSE(Config::SetEStopEnabled(!sample.estop.enabled)); - TEST_ASSERT_FALSE(Config::ClearWiFiCredentials()); - TEST_ASSERT_FALSE(Config::RemoveWiFiCredentials(sample.wifi.credentialsList.front().id)); - HostConfigFs::failWrites = false; - - AssertConfigEqual(sample, Snapshot()); -} - TEST_CASE("A malformed JSON bssid does not pin a partial address", "[config][corrupt][json]") { InitDefault(); TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"wifi":{"credentials":[{"id":1,"ssid":"net","bssid":"123456789aZZ"}]}})")); - const Config::RootConfig loaded = Snapshot(); + const Full loaded = Snapshot(); TEST_ASSERT_EQUAL_size_t(1, loaded.wifi.credentialsList.size()); TEST_ASSERT_FALSE(loaded.wifi.credentialsList[0].HasPinnedBSSID()); } TEST_CASE("An unknown OTA channel in JSON falls back to the default, not the running value", "[config][corrupt][json]") { - Config::RootConfig sample = MakeSampleConfig(); + Full sample = MakeSampleConfig(); sample.otaUpdate.updateChannel = OtaUpdateChannel::Develop; - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + InitWith(sample); TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"otaUpdate":{"updateChannel":"nightly","updateStep":42}})")); - const Config::RootConfig loaded = Snapshot(); + const Full loaded = Snapshot(); TEST_ASSERT_EQUAL_UINT8(std::to_underlying(OtaUpdateChannel::Stable), std::to_underlying(loaded.otaUpdate.updateChannel)); TEST_ASSERT_EQUAL_UINT8(std::to_underlying(OtaUpdateStep::None), std::to_underlying(loaded.otaUpdate.updateStep)); } -TEST_CASE("Schema fields the firmware does not model are dropped on save", "[config][corrupt]") +TEST_CASE("Schema fields the firmware does not model are dropped on import", "[config][corrupt]") { - // estop.active / estop.latching (and wifi AP/LAN fields) exist in the schema but nothing in the firmware reads - // them, so they are intentionally not round-tripped. This pins that behaviour. + // estop.active / estop.latching (and wifi AP/LAN fields) exist in the 1.x schema but nothing in the firmware + // reads them, so they are not imported and never stored. This pins that behaviour. namespace Fbs = Serialization::Configuration; flatbuffers::FlatBufferBuilder builder; auto estop = Fbs::CreateEStopConfig(builder, false, -1, /*active=*/true, /*latching=*/true); Fbs::FinishHubConfigBuffer(builder, Fbs::CreateHubConfig(builder, 0, 0, 0, 0, 0, 0, estop)); - TEST_ASSERT_TRUE(InitFrom(builder.GetBufferPointer(), builder.GetSize())); - - TEST_ASSERT_TRUE(Config::SetSerialInputConfigEchoEnabled(false)); // any save rewrites the file + TEST_ASSERT_TRUE(InitFromLegacy(builder.GetBufferPointer(), builder.GetSize())); - const std::vector stored = StoredFile(); - const auto* root = Fbs::GetHubConfig(stored.data()); + flatbuffers::FlatBufferBuilder out; + Fbs::FinishHubConfigBuffer(out, Config::GetAsFlatBuffer(out, true)); + const auto* root = Fbs::GetHubConfig(out.GetBufferPointer()); TEST_ASSERT_NOT_NULL(root->estop()); TEST_ASSERT_FALSE(root->estop()->active()); TEST_ASSERT_FALSE(root->estop()->latching()); @@ -276,9 +294,9 @@ TEST_CASE("RemoveWiFiCredentials reports unknown IDs", "[config]") TEST_ASSERT_FALSE(Config::RemoveWiFiCredentials(42)); } -TEST_CASE("Out-of-range E-Stop GPIO in a stored config falls back to the default", "[config][corrupt]") +TEST_CASE("Out-of-range E-Stop GPIO in a 1.x config falls back to the default", "[config][corrupt]") { - // gpio_pin is an int8 on flash; values outside gpio_num_t must not be cast to it + // gpio_pin is an int8 in the file; values outside gpio_num_t must not be cast to it // (undefined behaviour, flagged by UBSan) and mean a corrupt section. namespace Fbs = Serialization::Configuration; for (int8_t pin : {static_cast(-128), static_cast(-2), static_cast(GPIO_NUM_MAX), static_cast(127)}) { @@ -289,7 +307,7 @@ TEST_CASE("Out-of-range E-Stop GPIO in a stored config falls back to the default char msg[32]; snprintf(msg, sizeof(msg), "gpio_pin %d", pin); - TEST_ASSERT_TRUE_MESSAGE(InitFrom(builder.GetBufferPointer(), builder.GetSize()), msg); + TEST_ASSERT_TRUE_MESSAGE(InitFromLegacy(builder.GetBufferPointer(), builder.GetSize()), msg); auto estopConfig = Config::GetEStopConfig(); TEST_ASSERT_TRUE(estopConfig.has_value()); @@ -323,7 +341,7 @@ TEST_CASE("Out-of-range JSON values are not stored", "[config][corrupt][json]") // txPin outside uint8 / not a real GPIO -> board default; id outside uint8 -> credentials rejected. TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"rf":{"txPin":300},"wifi":{"credentials":[{"id":1,"ssid":"ok"},{"id":"x","ssid":""}]},"otaUpdate":{"checkInterval":70000,"updateChannel":"nightly"}})")); - const Config::RootConfig loaded = Snapshot(); + const Full loaded = Snapshot(); TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, loaded.rf.txPin); TEST_ASSERT_EQUAL_size_t(1, loaded.wifi.credentialsList.size()); TEST_ASSERT_EQUAL_STRING("ok", loaded.wifi.credentialsList[0].ssid.c_str()); diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index 13f5c5b28..88f46eee6 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -1,23 +1,38 @@ -// Defaults: no stored config, an empty one, or one missing sections/fields must give -// usable defaults rather than a failed load (which would wipe the whole file). +// Defaults: no stored config, a store missing namespaces or keys, must give usable defaults rather than a failed +// load, and a key this firmware does not know must be ignored. +#include + #include "unity.h" #include "config_test_helpers.h" #include "BoardDescriptor.h" +#include + #include #include using namespace OpenShock; using namespace ConfigTest; -namespace Fbs = OpenShock::Serialization::Configuration; +namespace Keys = ConfigTest::Keys; +namespace Nvs = OpenShock::Config::Nvs; namespace { - std::vector Finish(flatbuffers::FlatBufferBuilder& builder, flatbuffers::Offset root) + // Writes a few keys straight into the store, as a different firmware version might have, then marks the schema + // so Init loads rather than imports. + Nvs::Handle Open(const char* ns) + { + auto handle = Nvs::Handle::Open(Keys::kPartitionCfg, ns, true); + TEST_ASSERT_TRUE(handle.has_value()); + return std::move(*handle); + } + + void MarkSchema() { - Fbs::FinishHubConfigBuffer(builder, root); - return std::vector(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + auto root = Open(Keys::Ns::kRoot); + TEST_ASSERT_TRUE(root.SetU8(Keys::Root::kSchema, Keys::kSchemaVersion)); + TEST_ASSERT_TRUE(root.Commit()); } } // namespace @@ -28,7 +43,7 @@ TEST_CASE("Default values come from Kconfig and the board descriptor", "[config] TEST_ASSERT_EQUAL_STRING(OPENSHOCK_FW_BOARD, OpenShock::Board::Get().board); TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, OpenShock::Board::Get().rfTxGpio); - const Config::RootConfig config; + const Full config; TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, config.rf.txPin); TEST_ASSERT_TRUE(config.rf.keepAliveEnabled); @@ -53,72 +68,69 @@ TEST_CASE("Default values come from Kconfig and the board descriptor", "[config] TEST_CASE("ToDefault resets every section to the constructed defaults", "[config][defaults]") { - Config::RootConfig config = MakeSampleConfig(); + Full config = MakeSampleConfig(); config.ToDefault(); - AssertConfigEqual(Config::RootConfig(), config); + AssertConfigEqual(Full(), config); } -TEST_CASE("Missing config file: Init writes a default config that reloads", "[config][defaults]") +TEST_CASE("An empty store: Init writes a default config that reloads", "[config][defaults]") { - TEST_ASSERT_FALSE(InitFrom(nullptr, 0)); // falls back to defaults and writes them - TEST_ASSERT_EQUAL_INT(1, HostConfigFs::writeCount); - AssertConfigEqual(Config::RootConfig(), Snapshot()); - - const std::vector written = StoredFile(); - TEST_ASSERT_FALSE(written.empty()); - TEST_ASSERT_TRUE(InitFrom(written)); - AssertConfigEqual(Config::RootConfig(), Snapshot()); -} + InitDefault(); + TEST_ASSERT_EQUAL_STRING("cfg", Config::StorageLabel()); + AssertConfigEqual(Full(), Snapshot()); -TEST_CASE("HubConfig with no sections loads as defaults", "[config][defaults]") -{ - flatbuffers::FlatBufferBuilder builder; - const std::vector bytes = Finish(builder, Fbs::CreateHubConfig(builder)); + // Every section namespace and the schema marker are now on flash + for (const char* ns : Keys::Ns::kAll) { + TEST_ASSERT_TRUE_MESSAGE(Nvs::NamespaceExists(Keys::kPartitionCfg, ns), ns); + } + TEST_ASSERT_TRUE(UsedEntries() > 0); - TEST_ASSERT_TRUE(InitFrom(bytes)); - AssertConfigEqual(Config::RootConfig(), Snapshot()); + Reload(); + AssertConfigEqual(Full(), Snapshot()); } TEST_CASE("Missing sections default while present ones are kept", "[config][defaults]") { - flatbuffers::FlatBufferBuilder builder; - auto backend = Fbs::CreateBackendConfigDirect(builder, "api.example.org", "tok"); - auto estop = Fbs::CreateEStopConfig(builder, true, 13); - const std::vector bytes = Finish(builder, Fbs::CreateHubConfig(builder, 0, 0, 0, backend, 0, 0, estop)); - - TEST_ASSERT_TRUE(InitFrom(bytes)); + WipeStore(); + { + auto backend = Open(Keys::Ns::kBackend); + TEST_ASSERT_TRUE(backend.SetStr(Keys::Backend::kDomain, "api.example.org")); + TEST_ASSERT_TRUE(backend.SetStr(Keys::Backend::kToken, "tok")); + TEST_ASSERT_TRUE(backend.Commit()); + auto estop = Open(Keys::Ns::kEstop); + TEST_ASSERT_TRUE(estop.SetBool(Keys::Estop::kEnabled, true)); + TEST_ASSERT_TRUE(estop.SetI8(Keys::Estop::kGpio, 13)); + TEST_ASSERT_TRUE(estop.Commit()); + } + MarkSchema(); + Reload(); - Config::RootConfig expected; + Full expected; expected.backend = Config::BackendConfig("api.example.org", "tok"); expected.estop = Config::EStopConfig(true, GPIO_NUM_13); AssertConfigEqual(expected, Snapshot()); } -TEST_CASE("Missing fields inside a section take their defaults", "[config][defaults]") +TEST_CASE("Missing keys inside a section take their defaults", "[config][defaults]") { - flatbuffers::FlatBufferBuilder builder; - - Fbs::RFConfigBuilder rf(builder); - rf.add_tx_pin(13); - auto rfOffset = rf.Finish(); // keepalive_enabled absent - - auto hostname = builder.CreateString("my-hub"); - Fbs::WiFiConfigBuilder wifi(builder); - wifi.add_hostname(hostname); - auto wifiOffset = wifi.Finish(); // no AP SSID, no credentials - - auto domain = builder.CreateString("api.example.org"); - Fbs::BackendConfigBuilder backend(builder); - backend.add_domain(domain); - auto backendOffset = backend.Finish(); // no auth token - - Fbs::OtaUpdateConfigBuilder ota(builder); - ota.add_update_id(77); - auto otaOffset = ota.Finish(); // no CDN domain, rest at schema defaults - - const std::vector bytes = Finish(builder, Fbs::CreateHubConfig(builder, rfOffset, wifiOffset, 0, backendOffset, 0, otaOffset, 0)); - TEST_ASSERT_TRUE(InitFrom(bytes)); - const Config::RootConfig loaded = Snapshot(); + WipeStore(); + { + auto rf = Open(Keys::Ns::kRf); + TEST_ASSERT_TRUE(rf.SetI8(Keys::Rf::kTxPin, 13)); // keepalive absent + TEST_ASSERT_TRUE(rf.Commit()); + auto wifi = Open(Keys::Ns::kWifiCfg); + TEST_ASSERT_TRUE(wifi.SetStr(Keys::WifiCfg::kHostname, "my-hub")); // no AP SSID, no credentials namespace at all + TEST_ASSERT_TRUE(wifi.Commit()); + auto backend = Open(Keys::Ns::kBackend); + TEST_ASSERT_TRUE(backend.SetStr(Keys::Backend::kDomain, "api.example.org")); // no auth token + TEST_ASSERT_TRUE(backend.Commit()); + auto ota = Open(Keys::Ns::kOta); + TEST_ASSERT_TRUE(ota.SetI32(Keys::Ota::kUpdateId, 77)); // the rest at their defaults + TEST_ASSERT_TRUE(ota.Commit()); + } + MarkSchema(); + Reload(); + const Full loaded = Snapshot(); TEST_ASSERT_EQUAL_INT(GPIO_NUM_13, loaded.rf.txPin); TEST_ASSERT_TRUE(loaded.rf.keepAliveEnabled); @@ -133,17 +145,38 @@ TEST_CASE("Missing fields inside a section take their defaults", "[config][defau TEST_ASSERT_EQUAL_INT32(77, loaded.otaUpdate.updateId); } -TEST_CASE("Credentials without an SSID are dropped, the rest kept", "[config][defaults]") +TEST_CASE("Keys this firmware does not know are ignored and kept", "[config][defaults]") { - flatbuffers::FlatBufferBuilder builder; - std::vector> creds { - Fbs::CreateWiFiCredentialsDirect(builder, 1, "HomeNet", "hunter22"), - Fbs::CreateWiFiCredentialsDirect(builder, 2, nullptr, "orphan"), - Fbs::CreateWiFiCredentialsDirect(builder, 3, "", "empty"), - Fbs::CreateWiFiCredentialsDirect(builder, 4, "Office", nullptr), - }; - auto wifi = Fbs::CreateWiFiConfig(builder, 0, 0, builder.CreateVector(creds)); - TEST_ASSERT_TRUE(InitFrom(Finish(builder, Fbs::CreateHubConfig(builder, 0, wifi)))); + InitWith(MakeSampleConfig()); + { + auto rf = Open(Keys::Ns::kRf); + TEST_ASSERT_TRUE(rf.SetU16("future_fld", 1234)); // from a newer firmware + TEST_ASSERT_TRUE(rf.Commit()); + } + Reload(); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); + TEST_ASSERT_TRUE(Config::SetRFConfigKeepAliveEnabled(false)); // a save of the section leaves it alone + + auto rf = Open(Keys::Ns::kRf); + uint16_t value = 0; + TEST_ASSERT_EQUAL(Nvs::Result::Found, rf.GetU16("future_fld", value)); + TEST_ASSERT_EQUAL_UINT16(1234, value); +} + +TEST_CASE("Malformed credential blobs are dropped, the rest kept", "[config][defaults]") +{ + Full config; + config.wifi.credentialsList = {Config::WiFiCredentials(1, "HomeNet", "hunter22"), Config::WiFiCredentials(4, "Office", "")}; + InitWith(config); + { + auto wifi = Open(Keys::Ns::kWifi); + const uint8_t junk[] = {0x09, 0x02, 0x00}; + TEST_ASSERT_TRUE(wifi.SetBlob("2", junk)); // unknown blob version + TEST_ASSERT_TRUE(wifi.SetBlob("3", std::span())); // empty + TEST_ASSERT_TRUE(wifi.SetStr("notanid", "x")); // not a credential at all + TEST_ASSERT_TRUE(wifi.Commit()); + } + Reload(); auto loaded = Config::GetWiFiCredentials(); TEST_ASSERT_TRUE(loaded.has_value()); @@ -154,10 +187,18 @@ TEST_CASE("Credentials without an SSID are dropped, the rest kept", "[config][de TEST_CASE("Invalid stored GPIO pins fall back safely", "[config][defaults]") { - flatbuffers::FlatBufferBuilder builder; - auto rf = Fbs::CreateRFConfig(builder, 24, true); // GPIO24 does not exist on ESP32 - auto estop = Fbs::CreateEStopConfig(builder, true, 0); // strapping pin: not a valid E-Stop input - TEST_ASSERT_TRUE(InitFrom(Finish(builder, Fbs::CreateHubConfig(builder, rf, 0, 0, 0, 0, 0, estop)))); + WipeStore(); + { + auto rf = Open(Keys::Ns::kRf); + TEST_ASSERT_TRUE(rf.SetI8(Keys::Rf::kTxPin, 24)); // GPIO24 does not exist on ESP32 + TEST_ASSERT_TRUE(rf.Commit()); + auto estop = Open(Keys::Ns::kEstop); + TEST_ASSERT_TRUE(estop.SetBool(Keys::Estop::kEnabled, true)); + TEST_ASSERT_TRUE(estop.SetI8(Keys::Estop::kGpio, 0)); // strapping pin: not a valid E-Stop input + TEST_ASSERT_TRUE(estop.Commit()); + } + MarkSchema(); + Reload(); auto txPin = Config::GetRFConfigTxPin(); TEST_ASSERT_TRUE(txPin.has_value()); @@ -168,9 +209,14 @@ TEST_CASE("Invalid stored GPIO pins fall back safely", "[config][defaults]") TEST_ASSERT_FALSE(*estopEnabled); // Negative pin (the old "invalid" marker) also falls back. - flatbuffers::FlatBufferBuilder builder2; - auto rf2 = Fbs::CreateRFConfig(builder2, -1, true); - TEST_ASSERT_TRUE(InitFrom(Finish(builder2, Fbs::CreateHubConfig(builder2, rf2)))); + WipeStore(); + { + auto rf = Open(Keys::Ns::kRf); + TEST_ASSERT_TRUE(rf.SetI8(Keys::Rf::kTxPin, -1)); + TEST_ASSERT_TRUE(rf.Commit()); + } + MarkSchema(); + Reload(); txPin = Config::GetRFConfigTxPin(); TEST_ASSERT_TRUE(txPin.has_value()); TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, *txPin); @@ -178,12 +224,12 @@ TEST_CASE("Invalid stored GPIO pins fall back safely", "[config][defaults]") TEST_CASE("jsonconfig set with an empty object resets to defaults", "[config][defaults][json]") { - TEST_ASSERT_TRUE(InitFrom(Serialize(MakeSampleConfig()))); + InitWith(MakeSampleConfig()); TEST_ASSERT_TRUE(Config::SaveFromJSON("{}")); - AssertConfigEqual(Config::RootConfig(), Snapshot()); + AssertConfigEqual(Full(), Snapshot()); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); - AssertConfigEqual(Config::RootConfig(), Snapshot()); + Reload(); + AssertConfigEqual(Full(), Snapshot()); } TEST_CASE("jsonconfig set with partial sections fills in defaults", "[config][defaults][json]") @@ -191,7 +237,7 @@ TEST_CASE("jsonconfig set with partial sections fills in defaults", "[config][de InitDefault(); TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"rf":{"txPin":13},"wifi":{"hostname":"my-hub","credentials":[]},"backend":{"domain":"api.example.org"},"otaUpdate":{"updateChannel":"develop"}})")); - Config::RootConfig expected; + Full expected; expected.rf.txPin = GPIO_NUM_13; expected.wifi.hostname = "my-hub"; expected.backend.domain = "api.example.org"; @@ -203,7 +249,8 @@ TEST_CASE("jsonconfig set requires the wifi credentials array when wifi is given { // Same rule as the develop firmware: a wifi object without "credentials" is rejected. InitDefault(); + const std::size_t before = UsedEntries(); TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"wifi":{"hostname":"my-hub"}})")); TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"wifi":{"hostname":"my-hub","credentials":{}}})")); - TEST_ASSERT_EQUAL_INT(0, HostConfigFs::writeCount); + TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); } diff --git a/components/config/host_test/main/test_estop_latch.cpp b/components/config/host_test/main/test_estop_latch.cpp new file mode 100644 index 000000000..b45fff1a9 --- /dev/null +++ b/components/config/host_test/main/test_estop_latch.cpp @@ -0,0 +1,104 @@ +// The E-Stop latch: state the estop section's module keeps in the section's namespace, outside the section's value. +// A fresh hub starts usable, a stored latch survives a reboot and the move into cfg, and only a factory reset (which +// erases the section) drops it. +#include + +#include "unity.h" + +#include "config_test_helpers.h" + +#include "config/Section.h" + +#include + +#include + +using namespace OpenShock; +using namespace ConfigTest; +namespace Keys = ConfigTest::Keys; +namespace Nvs = OpenShock::Config::Nvs; + +TEST_CASE("A fresh hub is not latched", "[config][estop]") +{ + InitDefault(); + TEST_ASSERT_FALSE(Config::LoadEStopLatched()); +} + +TEST_CASE("The E-Stop latch survives a reboot until it is released", "[config][estop]") +{ + InitWith(MakeSampleConfig()); + TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Saved); + Reload(); + TEST_ASSERT_TRUE(Config::LoadEStopLatched()); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); // The latch is not part of the section's value + + TEST_ASSERT_TRUE(Config::SaveEStopLatched(false) == Config::LatchSave::Saved); + Reload(); + TEST_ASSERT_FALSE(Config::LoadEStopLatched()); +} + +TEST_CASE("Saving the estop section keeps the latch, and jsonconfig never shows it", "[config][estop]") +{ + InitWith(MakeSampleConfig()); + TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Saved); + TEST_ASSERT_TRUE(Config::SetEStopEnabled(false)); + TEST_ASSERT_TRUE(Config::SetEStopEnabled(true)); + TEST_ASSERT_TRUE(Config::SaveFromJSON(Config::GetAsJSON(true))); + Reload(); + TEST_ASSERT_TRUE(Config::LoadEStopLatched()); + TEST_ASSERT_TRUE(Config::GetAsJSON(true).find("latch") == std::string::npos); +} + +TEST_CASE("Factory reset releases the E-Stop latch", "[config][estop]") +{ + InitWith(MakeSampleConfig()); + TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Saved); + Config::FactoryReset(); + TEST_ASSERT_FALSE(Config::LoadEStopLatched()); + Reload(); + TEST_ASSERT_FALSE(Config::LoadEStopLatched()); +} + +TEST_CASE("Saving the latch never waits for another config writer", "[config][estop]") +{ + InitWith(MakeSampleConfig()); + { + // The write lock is a plain mutex, so taking it again from the holder fails like it would from the E-Stop task + ScopedLock lock__(&Config::Internal::WriteLock()); + TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Busy); + } + TEST_ASSERT_FALSE(Config::LoadEStopLatched()); // Nothing was written + TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Saved); + TEST_ASSERT_TRUE(Config::LoadEStopLatched()); +} + +TEST_CASE("A latch stored in the shared nvs moves into cfg with the estop section", "[config][estop][storage]") +{ + WipeStore(); + TEST_ASSERT_EQUAL(ESP_OK, nvs_flash_init_partition(Keys::kPartitionShared)); + TEST_ASSERT_TRUE(MakeSampleConfig().SaveNvs(Keys::kPartitionShared)); + { + auto estop = Nvs::Handle::Open(Keys::kPartitionShared, Keys::Ns::kEstop, true); + TEST_ASSERT_TRUE(estop.has_value()); + TEST_ASSERT_TRUE(estop->SetU8(Keys::Estop::kLatched, 1)); + TEST_ASSERT_TRUE(estop->Commit()); + } + + Config::Init(); + TEST_ASSERT_EQUAL_STRING(Keys::kPartitionCfg, Config::StorageLabel()); + TEST_ASSERT_TRUE(Config::LoadEStopLatched()); +} + +TEST_CASE("A latch stored with another type reads as not latched", "[config][estop]") +{ + InitWith(MakeSampleConfig()); + { + // NVS looks keys up by name and type, so this is a missing key, not a read error + auto estop = Nvs::Handle::Open(Keys::kPartitionCfg, Keys::Ns::kEstop, true); + TEST_ASSERT_TRUE(estop.has_value()); + TEST_ASSERT_TRUE(estop->SetStr(Keys::Estop::kLatched, "yes")); + TEST_ASSERT_TRUE(estop->Commit()); + } + Reload(); + TEST_ASSERT_FALSE(Config::LoadEStopLatched()); +} diff --git a/components/config/host_test/main/test_legacy.cpp b/components/config/host_test/main/test_legacy.cpp index fa61f1ac1..ccc5935cc 100644 --- a/components/config/host_test/main/test_legacy.cpp +++ b/components/config/host_test/main/test_legacy.cpp @@ -1,6 +1,8 @@ -// Upgrade safety: hubs flashed from the Arduino firmware (1.5.x releases and the -// develop branch) must keep their config. The fixtures are the exact bytes those -// firmwares stored in the "config" partition (see legacy_fixtures.h). +// Upgrade safety: hubs flashed from the Arduino firmware (1.5.x releases and the develop branch) must keep their +// config. The fixtures are the exact bytes those firmwares stored in the "config" partition (see +// legacy_fixtures.h); the 1.9.9 pivot imports that file into NVS on the first boot that finds no NVS config. +#include + #include "unity.h" #include "config_test_helpers.h" @@ -15,9 +17,9 @@ using namespace ConfigTest; namespace { // The settings every legacy fixture holds (see legacy_fixtures.h). - Config::RootConfig ExpectedLegacyConfig(bool hasAuthModeAndBssid, bool keepAlive) + Full ExpectedLegacyConfig(bool hasAuthModeAndBssid, bool keepAlive) { - Config::RootConfig config; + Full config; config.rf = Config::RFConfig(GPIO_NUM_15, keepAlive); Config::WiFiCredentials home(1, "HomeNet", "hunter22"); @@ -66,19 +68,22 @@ namespace { R"("estop":{"enabled":true,"gpioPin":13}})"; } // namespace -TEST_CASE("Config stored by develop (Arduino) firmware loads unchanged", "[config][legacy]") +TEST_CASE("Config stored by develop (Arduino) firmware is imported unchanged", "[config][legacy]") { - TEST_ASSERT_TRUE_MESSAGE(InitFrom(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig)), "Init fell back to defaults"); + TEST_ASSERT_TRUE_MESSAGE(InitFromLegacy(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig)), "Init fell back to defaults"); AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); - // Loading alone must not rewrite the file. - TEST_ASSERT_EQUAL_size_t(sizeof(Legacy::kDevelopConfig), StoredFile().size()); - TEST_ASSERT_EQUAL_UINT8_ARRAY(Legacy::kDevelopConfig, StoredFile().data(), sizeof(Legacy::kDevelopConfig)); + // Imported into NVS: a reboot loads it from there, with or without the file. + Reload(); + AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); + HostLfs::files().clear(); + Reload(); + AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); } -TEST_CASE("Config stored by 1.5.2 release firmware loads unchanged", "[config][legacy]") +TEST_CASE("Config stored by 1.5.2 release firmware is imported unchanged", "[config][legacy]") { - TEST_ASSERT_TRUE_MESSAGE(InitFrom(Legacy::kV152Config, sizeof(Legacy::kV152Config)), "Init fell back to defaults"); + TEST_ASSERT_TRUE_MESSAGE(InitFromLegacy(Legacy::kV152Config, sizeof(Legacy::kV152Config)), "Init fell back to defaults"); // 1.5.2 stored no auth mode / BSSID: they read back as unknown / not pinned. // OTA updates and backend management are off in this file, which 1.5.2 encoded @@ -87,17 +92,39 @@ TEST_CASE("Config stored by 1.5.2 release firmware loads unchanged", "[config][l AssertConfigEqual(ExpectedLegacyConfig(false, true), Snapshot()); } +TEST_CASE("The 1.x file is read once: a store that has a config ignores it", "[config][legacy]") +{ + InitWith(MakeSampleConfig()); + HostLfs::files()[kLegacyPath].assign(Legacy::kDevelopConfig, Legacy::kDevelopConfig + sizeof(Legacy::kDevelopConfig)); + Reload(); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); + TEST_ASSERT_EQUAL_INT(0, HostLfs::writeCount); +} + +TEST_CASE("A 1.x partition that does not mount, or has no file, gives the defaults", "[config][legacy]") +{ + TEST_ASSERT_FALSE(InitFromLegacy(nullptr, 0)); + AssertConfigEqual(Full(), Snapshot()); + + WipeStore(); + HostLfs::mountable = false; + HostLfs::files()[kLegacyPath].assign(Legacy::kDevelopConfig, Legacy::kDevelopConfig + sizeof(Legacy::kDevelopConfig)); + Config::Init(); + AssertConfigEqual(Full(), Snapshot()); + HostLfs::mountable = true; +} + TEST_CASE("1.5.2 booleans stored as false keep their value", "[config][legacy]") { // kV152ConfigKeepAliveOff has rf.keepalive_enabled, ota.is_enabled and // ota.allow_backend_management all false. - TEST_ASSERT_TRUE(InitFrom(Legacy::kV152ConfigKeepAliveOff, sizeof(Legacy::kV152ConfigKeepAliveOff))); + TEST_ASSERT_TRUE(InitFromLegacy(Legacy::kV152ConfigKeepAliveOff, sizeof(Legacy::kV152ConfigKeepAliveOff))); bool allOn; { // Scoped: TEST_IGNORE longjmps out, skipping destructors. - const Config::RootConfig loaded = Snapshot(); - allOn = loaded.rf.keepAliveEnabled && loaded.otaUpdate.isEnabled && loaded.otaUpdate.allowBackendManagement; + const Full loaded = Snapshot(); + allOn = loaded.rf.keepAliveEnabled && loaded.otaUpdate.isEnabled && loaded.otaUpdate.allowBackendManagement; } if (allOn) { // 1.5.2's schema gave these three fields default false, so flatbuffers never @@ -140,8 +167,8 @@ namespace { void AssertFlippedDefaults(const std::vector& file, bool keepAlive, bool otaEnabled, bool backendManagement) { - TEST_ASSERT_TRUE(InitFrom(file)); - const Config::RootConfig loaded = Snapshot(); + TEST_ASSERT_TRUE(InitFromLegacy(file)); + const Full loaded = Snapshot(); TEST_ASSERT_EQUAL_MESSAGE(keepAlive, loaded.rf.keepAliveEnabled, "rf.keepAliveEnabled"); TEST_ASSERT_EQUAL_MESSAGE(otaEnabled, loaded.otaUpdate.isEnabled, "otaUpdate.isEnabled"); TEST_ASSERT_EQUAL_MESSAGE(backendManagement, loaded.otaUpdate.allowBackendManagement, "otaUpdate.allowBackendManagement"); @@ -168,51 +195,17 @@ TEST_CASE("Omitted flipped-default booleans follow the convention the file shows AssertFlippedDefaults(FlippedDefaultsConfig(std::nullopt, std::nullopt, std::nullopt), true, true, true); } -TEST_CASE("This firmware stores the flipped-default booleans explicitly", "[config][legacy]") +TEST_CASE("The imported config survives the first save by the new firmware", "[config][legacy]") { - // So its files read back the same under any schema (a downgrade to 1.5.x reads - // an omitted field as false) and are never ambiguous. - for (bool value : {true, false}) { - Config::RootConfig config; - config.rf.keepAliveEnabled = value; - config.otaUpdate.isEnabled = value; - config.otaUpdate.allowBackendManagement = value; - const std::vector bytes = Serialize(config); - const Fbs::HubConfig* fbs = Fbs::GetHubConfig(bytes.data()); - // Generated tables derive privately from flatbuffers::Table (a view of the same bytes). - const auto* rf = static_cast(static_cast(fbs->rf())); - const auto* ota = static_cast(static_cast(fbs->ota_update())); - TEST_ASSERT_TRUE(rf->CheckField(Fbs::RFConfig::VT_KEEPALIVE_ENABLED)); - TEST_ASSERT_TRUE(ota->CheckField(Fbs::OtaUpdateConfig::VT_IS_ENABLED)); - TEST_ASSERT_TRUE(ota->CheckField(Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT)); - - Config::RootConfig loaded; - TEST_ASSERT_TRUE(Deserialize(bytes.data(), bytes.size(), loaded)); - AssertConfigEqual(config, loaded); - } -} - -TEST_CASE("Legacy config survives the first save by the new firmware", "[config][legacy]") -{ - TEST_ASSERT_TRUE(InitFrom(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig))); + TEST_ASSERT_TRUE(InitFromLegacy(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig))); - // Any setter rewrites the whole file in the new firmware's encoding. TEST_ASSERT_TRUE(Config::SetOtaUpdateStep(OtaUpdateStep::Validated)); - Config::RootConfig expected = ExpectedLegacyConfig(true, false); + Full expected = ExpectedLegacyConfig(true, false); expected.otaUpdate.updateStep = OtaUpdateStep::Validated; - TEST_ASSERT_TRUE(InitFrom(StoredFile())); + Reload(); AssertConfigEqual(expected, Snapshot()); -} - -TEST_CASE("rawconfig set accepts a config backed up from legacy firmware", "[config][legacy]") -{ - InitDefault(); - TEST_ASSERT_TRUE(Config::SetRaw(Legacy::kV152Config, sizeof(Legacy::kV152Config))); - TEST_ASSERT_TRUE(Config::SetRaw(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig))); - - TEST_ASSERT_TRUE(InitFrom(StoredFile())); - AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); + TEST_ASSERT_EQUAL_INT(0, HostLfs::writeCount); // the 1.x file is never written to, so a rollback still finds it } TEST_CASE("jsonconfig set accepts JSON exported by develop firmware", "[config][legacy][json]") @@ -221,7 +214,7 @@ TEST_CASE("jsonconfig set accepts JSON exported by develop firmware", "[config][ TEST_ASSERT_TRUE(Config::SaveFromJSON(kDevelopJson)); AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); + Reload(); AssertConfigEqual(ExpectedLegacyConfig(true, false), Snapshot()); } @@ -234,14 +227,14 @@ TEST_CASE("jsonconfig set accepts JSON exported by 1.5.2 firmware", "[config][le TEST_CASE("jsonconfig get emits the same document develop did", "[config][legacy][json]") { - TEST_ASSERT_TRUE(InitFrom(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig))); + TEST_ASSERT_TRUE(InitFromLegacy(Legacy::kDevelopConfig, sizeof(Legacy::kDevelopConfig))); TEST_ASSERT_EQUAL_STRING(kDevelopJson, Config::GetAsJSON(true).c_str()); } -TEST_CASE("Fields from a newer schema are tolerated on load", "[config][legacy]") +TEST_CASE("Fields from a newer 1.x schema are tolerated on import", "[config][legacy]") { - // The schema has grown fields this firmware does not read yet (wifi AP password / - // disable flags, LAN config). A file carrying them must still load. + // The schema has grown fields this firmware does not read (wifi AP password / disable flags, LAN config). A + // file carrying them must still import. namespace Fbs = Serialization::Configuration; flatbuffers::FlatBufferBuilder builder; auto creds = builder.CreateVector(std::vector> {Fbs::CreateWiFiCredentialsDirect(builder, 1, "HomeNet", "hunter22")}); @@ -251,7 +244,7 @@ TEST_CASE("Fields from a newer schema are tolerated on load", "[config][legacy]" auto root = Fbs::CreateHubConfig(builder, 0, wifi, 0, backend, 0, 0, 0, lan); Fbs::FinishHubConfigBuffer(builder, root); - TEST_ASSERT_TRUE(InitFrom(builder.GetBufferPointer(), builder.GetSize())); + TEST_ASSERT_TRUE(InitFromLegacy(builder.GetBufferPointer(), builder.GetSize())); auto wifiConfig = Config::GetWiFiConfig(); TEST_ASSERT_TRUE(wifiConfig.has_value()); diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 5794487b8..35ff98e44 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -1,5 +1,7 @@ -// Round trips: a config written by this firmware must read back unchanged, both as -// the stored flatbuffer and through the jsonconfig (GetAsJSON / SaveFromJSON) path. +// Round trips: a config written by this firmware must read back unchanged after a reboot, through the NVS store +// and through the jsonconfig (GetAsJSON / SaveFromJSON) path. +#include + #include "unity.h" #include "config_test_helpers.h" @@ -9,34 +11,13 @@ using namespace OpenShock; using namespace ConfigTest; -TEST_CASE("RootConfig flatbuffer round trip keeps every section", "[config][roundtrip]") -{ - const Config::RootConfig sample = MakeSampleConfig(); - const std::vector bytes = Serialize(sample); - - Config::RootConfig loaded; - TEST_ASSERT_TRUE(Deserialize(bytes.data(), bytes.size(), loaded)); - AssertConfigEqual(sample, loaded); -} - -TEST_CASE("Stored config is loaded unchanged by Config::Init", "[config][roundtrip]") +TEST_CASE("A stored config is loaded unchanged by Config::Init", "[config][roundtrip]") { - const Config::RootConfig sample = MakeSampleConfig(); - - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + const Full sample = MakeSampleConfig(); + InitWith(sample); AssertConfigEqual(sample, Snapshot()); } -TEST_CASE("Serializing without sensitive data drops only the secrets", "[config][roundtrip]") -{ - const Config::RootConfig sample = MakeSampleConfig(); - const std::vector bytes = Serialize(sample, false); - - Config::RootConfig loaded; - TEST_ASSERT_TRUE(Deserialize(bytes.data(), bytes.size(), loaded)); - AssertConfigEqual(sample, loaded, false); -} - TEST_CASE("Values set through the Config API survive a reload", "[config][roundtrip]") { InitDefault(); @@ -57,11 +38,10 @@ TEST_CASE("Values set through the Config API survive a reload", "[config][roundt TEST_ASSERT_TRUE(Config::SetEStopGpioPin(GPIO_NUM_13)); TEST_ASSERT_TRUE(Config::SetEStopEnabled(true)); - const Config::RootConfig before = Snapshot(); + const Full before = Snapshot(); TEST_ASSERT_EQUAL_UINT8_ARRAY(bssid, before.wifi.credentialsList[0].bssid.data(), 6); - // Reboot: load the file the setters wrote. - TEST_ASSERT_TRUE(InitFrom(StoredFile())); + Reload(); AssertConfigEqual(before, Snapshot()); TEST_ASSERT_TRUE(Config::HasBackendAuthToken()); @@ -77,7 +57,7 @@ TEST_CASE("Values set through the Config API survive a reload", "[config][roundt TEST_CASE("Whole-section setters round trip through the store", "[config][roundtrip]") { - const Config::RootConfig sample = MakeSampleConfig(); + const Full sample = MakeSampleConfig(); InitDefault(); TEST_ASSERT_TRUE(Config::SetRFConfig(sample.rf)); @@ -89,76 +69,87 @@ TEST_CASE("Whole-section setters round trip through the store", "[config][roundt TEST_ASSERT_TRUE(Config::SetEStopConfig(sample.estop)); AssertConfigEqual(sample, Snapshot()); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); + Reload(); AssertConfigEqual(sample, Snapshot()); } -TEST_CASE("GetAsFlatBuffer and SaveFromFlatBuffer round trip", "[config][roundtrip]") +TEST_CASE("Removed credentials are gone after a reload and their ids can be reused", "[config][roundtrip]") { - const Config::RootConfig sample = MakeSampleConfig(); - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); - - flatbuffers::FlatBufferBuilder builder; - Serialization::Configuration::FinishHubConfigBuffer(builder, Config::GetAsFlatBuffer(builder, true)); - std::vector exported(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); - InitDefault(); - TEST_ASSERT_TRUE(Config::SaveFromFlatBuffer(Serialization::Configuration::GetHubConfig(exported.data()))); - AssertConfigEqual(sample, Snapshot()); + TEST_ASSERT_EQUAL_UINT8(1, Config::AddWiFiCredentials("A", "pa", WIFI_AUTH_WPA2_PSK)); + TEST_ASSERT_EQUAL_UINT8(2, Config::AddWiFiCredentials("B", "pb", WIFI_AUTH_WPA2_PSK)); + TEST_ASSERT_EQUAL_UINT8(3, Config::AddWiFiCredentials("C", "pc", WIFI_AUTH_WPA2_PSK)); + TEST_ASSERT_TRUE(Config::RemoveWiFiCredentials(2)); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); - AssertConfigEqual(sample, Snapshot()); + Reload(); + auto creds = Config::GetWiFiCredentials(); + TEST_ASSERT_TRUE(creds.has_value()); + TEST_ASSERT_EQUAL_size_t(2, creds->size()); + TEST_ASSERT_EQUAL_UINT8(1, (*creds)[0].id); + TEST_ASSERT_EQUAL_UINT8(3, (*creds)[1].id); + TEST_ASSERT_FALSE(Config::TryGetWiFiCredentialsByID(2).has_value()); + + // The freed id is handed out again, and the list order is by id, not by insertion + TEST_ASSERT_EQUAL_UINT8(2, Config::AddWiFiCredentials("D", "pd", WIFI_AUTH_WPA2_PSK)); + Reload(); + creds = Config::GetWiFiCredentials(); + TEST_ASSERT_EQUAL_size_t(3, creds->size()); + TEST_ASSERT_EQUAL_STRING("D", (*creds)[1].ssid.c_str()); + + TEST_ASSERT_TRUE(Config::ClearWiFiCredentials()); + Reload(); + TEST_ASSERT_TRUE(Config::GetWiFiCredentials()->empty()); } -TEST_CASE("SetRaw stores a valid buffer verbatim and GetRaw returns it", "[config][roundtrip]") +TEST_CASE("Credentials are enumerated from their keys, never counted", "[config][roundtrip]") { - const std::vector bytes = Serialize(MakeSampleConfig()); - InitDefault(); + // Ids with gaps and out of order, as a list that has been edited over time looks: everything comes back. + Full config; + config.wifi.credentialsList = {Config::WiFiCredentials(200, "High", "p1"), Config::WiFiCredentials(3, "Low", "p2"), Config::WiFiCredentials(255, "Max", "p3")}; + InitWith(config); - TEST_ASSERT_TRUE(Config::SetRaw(bytes.data(), bytes.size())); - TEST_ASSERT_EQUAL_size_t(bytes.size(), StoredFile().size()); - TEST_ASSERT_EQUAL_UINT8_ARRAY(bytes.data(), StoredFile().data(), bytes.size()); - - TinyVec raw; - TEST_ASSERT_TRUE(Config::GetRaw(raw)); - TEST_ASSERT_EQUAL_size_t(bytes.size(), raw.size()); - TEST_ASSERT_EQUAL_UINT8_ARRAY(bytes.data(), raw.data(), bytes.size()); - - // SetRaw takes effect immediately and survives a restart. - AssertConfigEqual(MakeSampleConfig(), Snapshot()); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); - AssertConfigEqual(MakeSampleConfig(), Snapshot()); + auto creds = Config::GetWiFiCredentials(); + TEST_ASSERT_TRUE(creds.has_value()); + TEST_ASSERT_EQUAL_size_t(3, creds->size()); + TEST_ASSERT_EQUAL_UINT8(3, (*creds)[0].id); + TEST_ASSERT_EQUAL_UINT8(200, (*creds)[1].id); + TEST_ASSERT_EQUAL_UINT8(255, (*creds)[2].id); + TEST_ASSERT_EQUAL_STRING("Max", (*creds)[2].ssid.c_str()); } -TEST_CASE("Saved configs write fields that equal their schema default", "[config][roundtrip]") +TEST_CASE("GetRaw writes fields that equal their schema default", "[config][roundtrip]") { - // 1.5.x reads a missing is_enabled/keepalive_enabled as false, so omitting them breaks downgrades. + // 1.5.x reads a missing is_enabled/keepalive_enabled as false, so omitting them breaks loading the dump there. InitDefault(); TEST_ASSERT_TRUE(Config::SetRFConfigKeepAliveEnabled(true)); - const std::vector stored = StoredFile(); - const auto* root = Serialization::Configuration::GetHubConfig(stored.data()); - TEST_ASSERT_TRUE(root->rf()->CheckField(Serialization::Configuration::RFConfig::VT_KEEPALIVE_ENABLED)); - TEST_ASSERT_TRUE(root->ota_update()->CheckField(Serialization::Configuration::OtaUpdateConfig::VT_IS_ENABLED)); - TinyVec raw; TEST_ASSERT_TRUE(Config::GetRaw(raw)); - const auto* rawRoot = Serialization::Configuration::GetHubConfig(raw.data()); - TEST_ASSERT_TRUE(rawRoot->ota_update()->CheckField(Serialization::Configuration::OtaUpdateConfig::VT_IS_ENABLED)); + const auto* root = Serialization::Configuration::GetHubConfig(raw.data()); + // The generated tables derive from flatbuffers::Table privately; every table is one at the same address + auto hasField = [](const void* table, flatbuffers::voffset_t field) { return static_cast(table)->CheckField(field); }; + TEST_ASSERT_TRUE(hasField(root->rf(), Serialization::Configuration::RFConfig::VT_KEEPALIVE_ENABLED)); + TEST_ASSERT_TRUE(hasField(root->ota_update(), Serialization::Configuration::OtaUpdateConfig::VT_IS_ENABLED)); } -TEST_CASE("A setter after SetRaw keeps the imported config", "[config][roundtrip]") +TEST_CASE("A save writes only what changed", "[config][roundtrip]") { - const std::vector bytes = Serialize(MakeSampleConfig()); - InitDefault(); + const Full sample = MakeSampleConfig(); + InitWith(sample); + const std::size_t before = UsedEntries(); - TEST_ASSERT_TRUE(Config::SetRaw(bytes.data(), bytes.size())); - TEST_ASSERT_TRUE(Config::SetSerialInputConfigEchoEnabled(!MakeSampleConfig().serialInput.echoEnabled)); - - Config::RootConfig expected = MakeSampleConfig(); - expected.serialInput.echoEnabled = !expected.serialInput.echoEnabled; - TEST_ASSERT_TRUE(InitFrom(StoredFile())); - AssertConfigEqual(expected, Snapshot()); + // Setting what is already set changes nothing on flash + TEST_ASSERT_TRUE(Config::SetWiFiConfig(sample.wifi)); + TEST_ASSERT_TRUE(Config::SetBackendConfig(sample.backend)); + TEST_ASSERT_TRUE(Config::SetOtaUpdateConfig(sample.otaUpdate)); + TEST_ASSERT_TRUE(Config::SetWiFiCredentials(sample.wifi.credentialsList)); + TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); + + // One scalar: one entry replaced (NVS writes the new one, then frees the old: the count does not grow) + TEST_ASSERT_TRUE(Config::SetSerialInputConfigEchoEnabled(true)); + TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); + Reload(); + TEST_ASSERT_TRUE(*Config::GetSerialInputConfigEchoEnabled()); } TEST_CASE("Many max-length WiFi credentials survive a reload", "[config][roundtrip]") @@ -169,105 +160,34 @@ TEST_CASE("Many max-length WiFi credentials survive a reload", "[config][roundtr std::string password(63, static_cast('a' + i)); // WPA2 passphrase maximum TEST_ASSERT_EQUAL_UINT8(i + 1, Config::AddWiFiCredentials(ssid, password, WIFI_AUTH_WPA2_PSK)); } - const Config::RootConfig before = Snapshot(); + const Full before = Snapshot(); - TEST_ASSERT_TRUE_MESSAGE(InitFrom(StoredFile()), "Init fell back to defaults"); + Reload(); AssertConfigEqual(before, Snapshot()); } -TEST_CASE("A stored config is never too large to load again", "[config][roundtrip]") +TEST_CASE("The store holds more than the 1.x config file could", "[config][roundtrip]") { - // AddWiFiCredentials hands out up to 255 IDs, but the config partition holds at - // most Config::MaxConfigSize bytes: once another network would not fit, adding - // is refused instead of storing a file that cannot be loaded again. + // The 1.x firmware capped its config file at 4 KiB, some 30 credentials like these. cfg is that file's 12 KiB + // partition retyped as NVS (three pages, one of them always kept free for compaction): 32 credentials with long + // names and passphrases fit with room for the rest of the config. The store fills up at about 36. InitDefault(); - int added = 0; - for (int i = 0; i < 40; ++i) { - std::string ssid = "Network-" + std::to_string(i) + std::string(20, 'x'); - const Config::RootConfig beforeAdd = Snapshot(); - const std::vector fileBefore = StoredFile(); - const int writesBefore = HostConfigFs::writeCount; - - if (Config::AddWiFiCredentials(ssid, std::string(63, 'p'), WIFI_AUTH_WPA2_PSK) == 0) { - // Refused: nothing changed, in memory or on flash. - AssertConfigEqual(beforeAdd, Snapshot()); - TEST_ASSERT_EQUAL_INT(writesBefore, HostConfigFs::writeCount); - TEST_ASSERT_TRUE(fileBefore == StoredFile()); - break; - } - ++added; + for (int i = 0; i < 32; ++i) { + std::string ssid = "Network-" + std::to_string(i) + std::string(20, 'x'); + TEST_ASSERT_NOT_EQUAL_MESSAGE(0, Config::AddWiFiCredentials(ssid, std::string(63, 'p'), WIFI_AUTH_WPA2_PSK), ssid.c_str()); } - TEST_ASSERT_TRUE_MESSAGE(added >= 16, "far fewer credentials fit than expected"); - TEST_ASSERT_TRUE_MESSAGE(added < 40, "40 such credentials cannot fit in MaxConfigSize"); - TEST_ASSERT_LESS_OR_EQUAL_size_t(Config::MaxConfigSize, StoredFile().size()); - - const Config::RootConfig before = Snapshot(); - TEST_ASSERT_EQUAL_size_t(added, before.wifi.credentialsList.size()); - TEST_ASSERT_TRUE_MESSAGE(InitFrom(StoredFile()), "Init fell back to defaults"); + const Full before = Snapshot(); + TEST_ASSERT_EQUAL_size_t(32, before.wifi.credentialsList.size()); + Reload(); AssertConfigEqual(before, Snapshot()); } -namespace { - // A valid config whose serialized form is exactly `size` bytes (padded through the hostname). - std::vector SerializedOfSize(std::size_t size, Config::RootConfig& config) - { - config = MakeSampleConfig(); - for (std::size_t len = 0; len < size; ++len) { - config.wifi.hostname.assign(len, 'h'); - std::vector bytes = Serialize(config); - if (bytes.size() == size) { - return bytes; - } - } - TEST_FAIL_MESSAGE("no hostname length gives that size"); - return {}; - } -} // namespace - -TEST_CASE("A config of exactly MaxConfigSize bytes loads", "[config][roundtrip]") -{ - Config::RootConfig sample; - const std::vector bytes = SerializedOfSize(Config::MaxConfigSize, sample); - - TEST_ASSERT_TRUE_MESSAGE(InitFrom(bytes), "Init fell back to defaults"); - AssertConfigEqual(sample, Snapshot()); - - InitDefault(); - TEST_ASSERT_TRUE(Config::SetRaw(bytes.data(), bytes.size())); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); - AssertConfigEqual(sample, Snapshot()); -} - -TEST_CASE("A config larger than MaxConfigSize is rejected, not aborted on", "[config][roundtrip]") -{ - // A well-formed flatbuffer, just too large: before the fix the Verifier - // constructor asserted (size < max_size) and aborted the hub on every boot. - Config::RootConfig sample; - const std::vector bytes = SerializedOfSize(Config::MaxConfigSize + 4, sample); - - InitDefault(); - TEST_ASSERT_FALSE(Config::SetRaw(bytes.data(), bytes.size())); - TEST_ASSERT_EQUAL_INT(0, HostConfigFs::writeCount); - - TEST_ASSERT_FALSE(InitFrom(bytes)); // fell back to defaults and rewrote the file - AssertConfigEqual(Config::RootConfig(), Snapshot()); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); - - // Setters cannot store an oversized config either, and keep the old file. - InitDefault(); - const std::vector before = StoredFile(); - Config::WiFiConfig wifi = sample.wifi; - wifi.hostname.append(Config::MaxConfigSize, 'h'); - TEST_ASSERT_FALSE(Config::SetWiFiConfig(wifi)); - TEST_ASSERT_TRUE(before == StoredFile()); -} - TEST_CASE("jsonconfig get/set round trip keeps every section", "[config][roundtrip][json]") { - Config::RootConfig sample = MakeSampleConfig(); + Full sample = MakeSampleConfig(); // Strings that need JSON escaping are covered separately below. sample.wifi.credentialsList[2] = Config::WiFiCredentials(7, "Office", "s3cret!", WIFI_AUTH_WPA3_PSK); - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + InitWith(sample); const std::string json = Config::GetAsJSON(true); TEST_ASSERT_FALSE(json.empty()); @@ -275,8 +195,8 @@ TEST_CASE("jsonconfig get/set round trip keeps every section", "[config][roundtr TEST_ASSERT_TRUE(Config::SaveFromJSON(json)); AssertConfigEqual(sample, Snapshot()); - // SaveFromJSON persists: the stored file reloads to the same config. - TEST_ASSERT_TRUE(InitFrom(StoredFile())); + // SaveFromJSON persists: the store reloads to the same config. + Reload(); AssertConfigEqual(sample, Snapshot()); // And exporting again yields identical JSON. @@ -285,8 +205,8 @@ TEST_CASE("jsonconfig get/set round trip keeps every section", "[config][roundtr TEST_CASE("jsonconfig round trip keeps strings that need escaping", "[config][roundtrip][json]") { - Config::RootConfig sample = MakeSampleConfig(); - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + Full sample = MakeSampleConfig(); + InitWith(sample); const std::string json = Config::GetAsJSON(true); InitDefault(); @@ -309,8 +229,9 @@ TEST_CASE("jsonconfig round trip keeps strings that need escaping", "[config][ro TEST_CASE("jsonconfig set decodes \\u escapes", "[config][roundtrip][json]") { InitDefault(); - TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"wifi":{"hostname":"hub\u002d1","credentials":[{"id":1,"ssid":"Caf\u00e9 \ud83d\ude00","password":"a\tb"}]}})")); + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"wifi":{"hostname":"hub-1","credentials":[{"id":1,"ssid":"Café 😀","password":"a\tb"}]}})")); + Reload(); // UTF-8 and control characters survive the store too auto wifi = Config::GetWiFiConfig(); TEST_ASSERT_TRUE(wifi.has_value()); TEST_ASSERT_EQUAL_STRING("hub-1", wifi->hostname.c_str()); @@ -321,7 +242,7 @@ TEST_CASE("jsonconfig set decodes \\u escapes", "[config][roundtrip][json]") TEST_CASE("GetAsJSON without sensitive data omits secrets", "[config][roundtrip][json]") { - TEST_ASSERT_TRUE(InitFrom(Serialize(MakeSampleConfig()))); + InitWith(MakeSampleConfig()); const std::string full = Config::GetAsJSON(true); const std::string redacted = Config::GetAsJSON(false); @@ -335,14 +256,28 @@ TEST_CASE("GetAsJSON without sensitive data omits secrets", "[config][roundtrip] TEST_ASSERT_NOT_EQUAL(std::string::npos, redacted.find("\"api.example.org\"")); } +TEST_CASE("The wire form for the portal omits secrets and carries every section", "[config][roundtrip]") +{ + const Full sample = MakeSampleConfig(); + InitWith(sample); + + flatbuffers::FlatBufferBuilder builder; + Serialization::Configuration::FinishHubConfigBuffer(builder, Config::GetAsFlatBuffer(builder, false)); + std::vector exported(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + + Full decoded; + TEST_ASSERT_TRUE(Deserialize(exported.data(), exported.size(), decoded)); + AssertConfigEqual(sample, decoded, false); +} + TEST_CASE("UpdateOtaUpdateConfig edits only what the update touches and saves only on change", "[config][roundtrip]") { - const Config::RootConfig sample = MakeSampleConfig(); - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + const Full sample = MakeSampleConfig(); + InitWith(sample); - const std::vector storedBefore = StoredFile(); + const std::size_t before = UsedEntries(); TEST_ASSERT_TRUE(Config::UpdateOtaUpdateConfig([](Config::OtaUpdateConfig&) { return false; })); - TEST_ASSERT_TRUE(storedBefore == StoredFile()); + TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); const uint16_t interval = static_cast(sample.otaUpdate.checkInterval + 7); TEST_ASSERT_TRUE(Config::UpdateOtaUpdateConfig([&](Config::OtaUpdateConfig& cfg) { @@ -350,11 +285,11 @@ TEST_CASE("UpdateOtaUpdateConfig edits only what the update touches and saves on return true; })); - Config::RootConfig expected = sample; + Full expected = sample; expected.otaUpdate.checkInterval = interval; AssertConfigEqual(expected, Snapshot()); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); + Reload(); AssertConfigEqual(expected, Snapshot()); } @@ -369,6 +304,36 @@ TEST_CASE("CompareExchangeOtaUpdateStep only replaces the expected step", "[conf TEST_ASSERT_TRUE(Config::CompareExchangeOtaUpdateStep(OtaUpdateStep::Updated, OtaUpdateStep::None)); TEST_ASSERT_TRUE(Config::GetOtaUpdateStep() == OtaUpdateStep::None); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); + Reload(); TEST_ASSERT_TRUE(Config::GetOtaUpdateStep() == OtaUpdateStep::None); } + +TEST_CASE("Factory reset wipes every setting and survives a reload", "[config][roundtrip]") +{ + InitWith(MakeSampleConfig()); + Config::FactoryReset(); + AssertConfigEqual(Full(), Snapshot()); + Reload(); + AssertConfigEqual(Full(), Snapshot()); +} + +TEST_CASE("rawconfig get and set round trip the whole config", "[config][roundtrip]") +{ + InitWith(MakeSampleConfig()); + TinyVec raw; + TEST_ASSERT_TRUE(Config::GetRaw(raw)); + const std::vector exported(raw.data(), raw.data() + raw.size()); + + InitDefault(); + TEST_ASSERT_TRUE(Config::SetRaw(exported.data(), exported.size())); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); + Reload(); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); + + // A buffer that does not verify changes nothing + const std::size_t before = UsedEntries(); + TEST_ASSERT_FALSE(Config::SetRaw(exported.data(), exported.size() / 2)); + TEST_ASSERT_FALSE(Config::SetRaw(nullptr, 0)); + TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); +} diff --git a/components/config/host_test/main/test_storage.cpp b/components/config/host_test/main/test_storage.cpp new file mode 100644 index 000000000..7ec8311ef --- /dev/null +++ b/components/config/host_test/main/test_storage.cpp @@ -0,0 +1,160 @@ +// Where the config lives: the `cfg` partition, the shared `nvs` only until the layout migration creates `cfg`, +// and the one-time move between them on the first boot of the new layout. +#include + +#include "unity.h" + +#include "config_test_helpers.h" + +#include +#include + +#include +#include + +using namespace OpenShock; +using namespace ConfigTest; +namespace Keys = ConfigTest::Keys; +namespace Nvs = OpenShock::Config::Nvs; + +namespace { + bool HasSchema(const char* partition) + { + auto root = Nvs::Handle::Open(partition, Keys::Ns::kRoot, false); + if (!root) return false; + uint8_t schema = 0; + return root->GetU8(Keys::Root::kSchema, schema) == Nvs::Result::Found && schema == Keys::kSchemaVersion; + } + + // A hub on the 1.x layout kept its config in the shared nvs; this stands that in on a table that has cfg too. + void StoreInShared(const Full& config) + { + TEST_ASSERT_EQUAL(ESP_OK, nvs_flash_init_partition(Keys::kPartitionShared)); + TEST_ASSERT_TRUE(config.SaveNvs(Keys::kPartitionShared)); + TEST_ASSERT_TRUE(HasSchema(Keys::kPartitionShared)); + } +} // namespace + +TEST_CASE("The store is the cfg partition", "[config][storage]") +{ + InitDefault(); + TEST_ASSERT_EQUAL_STRING(Keys::kPartitionCfg, Config::StorageLabel()); + TEST_ASSERT_TRUE(HasSchema(Keys::kPartitionCfg)); + TEST_ASSERT_FALSE(HasSchema(Keys::kPartitionShared)); // nothing of ours goes in the shared partition +} + +TEST_CASE("A config kept in the shared nvs moves into cfg on the first boot that has cfg", "[config][storage]") +{ + WipeStore(); + const Full sample = MakeSampleConfig(); + StoreInShared(sample); + + Config::Init(); + TEST_ASSERT_EQUAL_STRING(Keys::kPartitionCfg, Config::StorageLabel()); + AssertConfigEqual(sample, Snapshot()); + TEST_ASSERT_TRUE(HasSchema(Keys::kPartitionCfg)); + + // The shared copy is gone: every key of every namespace of ours (NVS keeps an emptied namespace's name) + TEST_ASSERT_FALSE(HasSchema(Keys::kPartitionShared)); + for (const char* ns : Keys::Ns::kAll) { + TEST_ASSERT_EQUAL_size_t_MESSAGE(0, Nvs::KeyCount(Keys::kPartitionShared, ns), ns); + } + + // And a later boot is a plain load from cfg + Reload(); + AssertConfigEqual(sample, Snapshot()); +} + +namespace { + // cfg as the table rewrite leaves it: the 1.x littlefs `config` partition, retyped. A littlefs metadata block + // (revision count, superblock tag, "littlefs", then more of the block) in `block`, the rest left as littlefs left it. + void FillCfgWithLittlefs(uint32_t block) + { + TEST_ASSERT_EQUAL(ESP_OK, nvs_flash_deinit_partition(Keys::kPartitionCfg)); + const esp_partition_t* cfg = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, Keys::kPartitionCfg); + TEST_ASSERT_NOT_NULL(cfg); + TEST_ASSERT_EQUAL(ESP_OK, esp_partition_erase_range(cfg, 0, cfg->size)); + std::vector metadata(0x1000); + for (std::size_t i = 0; i < metadata.size(); ++i) metadata[i] = static_cast(i * 7 + 3); + const uint8_t head[] = {0x01, 0x00, 0x00, 0x00, 0x0F, 0xF0, 0x00, 0x08, 'l', 'i', 't', 't', 'l', 'e', 'f', 's'}; + memcpy(metadata.data(), head, sizeof(head)); + TEST_ASSERT_EQUAL(ESP_OK, esp_partition_write(cfg, block * 0x1000, metadata.data(), metadata.size())); + } +} // namespace + +TEST_CASE("The 1.x littlefs left in cfg by the table rewrite is erased before NVS opens it", "[config][storage]") +{ + for (uint32_t block = 0; block < 2; ++block) { + WipeStore(); + const Full sample = MakeSampleConfig(); + StoreInShared(sample); + FillCfgWithLittlefs(block); + + Config::Init(); + TEST_ASSERT_EQUAL_STRING(Keys::kPartitionCfg, Config::StorageLabel()); + AssertConfigEqual(sample, Snapshot()); + TEST_ASSERT_TRUE(HasSchema(Keys::kPartitionCfg)); + // Nothing of the littlefs is left + const esp_partition_t* cfg = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, Keys::kPartitionCfg); + for (uint32_t b = 0; b < cfg->size / 0x1000; ++b) { + char magic[8]; + TEST_ASSERT_EQUAL(ESP_OK, esp_partition_read(cfg, b * 0x1000 + 8, magic, sizeof(magic))); + TEST_ASSERT_NOT_EQUAL(0, memcmp(magic, "littlefs", sizeof(magic))); + } + + Reload(); + AssertConfigEqual(sample, Snapshot()); + } +} + +TEST_CASE("A config in cfg wins over a stale copy in the shared nvs, which is cleaned up", "[config][storage]") +{ + InitWith(MakeSampleConfig()); + Full stale = MakeSampleConfig(); + stale.wifi.hostname = "stale-hub"; + StoreInShared(stale); + + Reload(); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); + TEST_ASSERT_FALSE(HasSchema(Keys::kPartitionShared)); +} + +TEST_CASE("The move is idempotent and verified", "[config][storage]") +{ + WipeStore(); + StoreInShared(MakeSampleConfig()); + Config::Init(); + Reload(); + Reload(); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); + TEST_ASSERT_TRUE(HasSchema(Keys::kPartitionCfg)); +} + +TEST_CASE("Sections link themselves into the registry in document order", "[config][storage]") +{ + const char* expected[] = {"rf", "wifi", "captivePortal", "backend", "serialInput", "otaUpdate", "estop"}; + std::size_t i = 0; + for (auto* section = Config::SectionBase::First(); section != nullptr; section = section->Next(), ++i) { + TEST_ASSERT_LESS_THAN_size_t(std::size(expected), i); + TEST_ASSERT_EQUAL_STRING(expected[i], section->Info().name); + } + TEST_ASSERT_EQUAL_size_t(std::size(expected), i); +} + +TEST_CASE("A key stored with another type reads as missing, so as its default", "[config][storage]") +{ + InitWith(MakeSampleConfig()); + { + // NVS looks keys up by name and type: a string where the rf section reads an i8 is not its pin + auto rf = Nvs::Handle::Open(Keys::kPartitionCfg, Keys::Ns::kRf, true); + TEST_ASSERT_TRUE(rf.has_value()); + TEST_ASSERT_TRUE(rf->Erase(Keys::Rf::kTxPin)); + TEST_ASSERT_TRUE(rf->SetStr(Keys::Rf::kTxPin, "not a number")); + TEST_ASSERT_TRUE(rf->Commit()); + } + Reload(); + + Full expected = MakeSampleConfig(); + expected.rf.txPin = Config::RFConfig().txPin; + AssertConfigEqual(expected, Snapshot()); +} diff --git a/components/config/host_test/partitions_test.csv b/components/config/host_test/partitions_test.csv new file mode 100644 index 000000000..7ea4c63b6 --- /dev/null +++ b/components/config/host_test/partitions_test.csv @@ -0,0 +1,10 @@ +# The partition table the config host test runs on (ESP-IDF's linux esp_partition emulates the flash in a file). +# Both a 2.x `cfg` NVS partition and the 1.x littlefs `config` partition, so Config::Init can be tested picking the +# store (cfg), moving a config out of the shared `nvs`, and importing the 1.x file (through the in-memory +# LfsPartition stub; the `config` partition only has to exist for the import to be attempted). +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x009000, 0x005000, +otadata, data, ota, 0x00E000, 0x002000, +app0, app, ota_0, 0x010000, 0x100000, +cfg, data, nvs, 0x110000, 0x003000, +config, data, spiffs, 0x113000, 0x003000, diff --git a/components/config/host_test/sdkconfig.defaults b/components/config/host_test/sdkconfig.defaults index 60da2c642..2d2c265b6 100644 --- a/components/config/host_test/sdkconfig.defaults +++ b/components/config/host_test/sdkconfig.defaults @@ -6,12 +6,11 @@ CONFIG_UNITY_ENABLE_IDF_TEST_RUNNER=y # Same tick rate as the firmware (sdkconfig.defaults at the repo root). CONFIG_FREERTOS_HZ=1000 -# The firmware's own partition table (every board uses it), so Config::Init's -# esp_partition_find_first() looks up the real "config" partition. ESP-IDF's linux -# esp_partition emulates the flash in a file. -CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +# A table with both the 2.x `cfg` store and the 1.x `config` partition (partitions_test.csv), so Config::Init's +# esp_partition_find_first() finds what each test needs. ESP-IDF's linux esp_partition emulates the flash in a +# file, and its nvs_flash runs on that, so the store under test is the real NVS. CONFIG_PARTITION_TABLE_CUSTOM=y -CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="../../../partitions/ota_4mb.csv" +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_test.csv" # app_main runs the whole suite on the main task. The 3584-byte default is sized for # the target; x86-64 frames (flatbuffers builders, std::string, Unity) need more. diff --git a/components/config/include/config/BackendConfig.h b/components/config/include/config/BackendConfig.h deleted file mode 100644 index 0b3129858..000000000 --- a/components/config/include/config/BackendConfig.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "config/ConfigBase.h" - -#include -#include - -namespace OpenShock::Config { - struct BackendConfig : public ConfigBase { - BackendConfig(); - BackendConfig(std::string_view domain, std::string_view authToken); - - std::string domain; - std::string authToken; - - void ToDefault() override; - - bool FromFlatbuffers(const Serialization::Configuration::BackendConfig* config) override; - [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - - bool FromJSON(JSON::JsonView json) override; - void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; - }; -} // namespace OpenShock::Config diff --git a/components/config/include/config/CaptivePortalConfig.h b/components/config/include/config/CaptivePortalConfig.h deleted file mode 100644 index 75105c56c..000000000 --- a/components/config/include/config/CaptivePortalConfig.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "config/ConfigBase.h" - -namespace OpenShock::Config { - struct CaptivePortalConfig : public ConfigBase { - CaptivePortalConfig(); - CaptivePortalConfig(bool alwaysEnabled); - - bool alwaysEnabled; - - void ToDefault() override; - - bool FromFlatbuffers(const Serialization::Configuration::CaptivePortalConfig* config) override; - [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - - bool FromJSON(JSON::JsonView json) override; - void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; - }; -} // namespace OpenShock::Config diff --git a/components/config/include/config/Config.h b/components/config/include/config/Config.h deleted file mode 100644 index a09786884..000000000 --- a/components/config/include/config/Config.h +++ /dev/null @@ -1,113 +0,0 @@ -#pragma once - -#include "config/BackendConfig.h" -#include "config/CaptivePortalConfig.h" -#include "config/EStopConfig.h" -#include "config/OtaUpdateConfig.h" -#include "config/RFConfig.h" -#include "config/SerialInputConfig.h" -#include "config/WiFiConfig.h" -#include "config/WiFiCredentials.h" -#include "TinyVec.h" -#include "util/FunctionRef.h" - -#include - -#include -#include -#include -#include -#include - -namespace OpenShock::Config { - /* Largest config file that can be stored and loaded back. The "config" partition (partitions/ota_4mb.csv) is 12 KiB of littlefs with - * 4 KiB blocks; two blocks hold the metadata pair, leaving a single block for file data. Saves of a larger config are refused, and a - * larger file or rawconfig buffer is rejected without being loaded. */ - inline constexpr std::size_t MaxConfigSize = 4096; - - void Init(); - - /* GetAsJSON and SaveFromJSON are used for Reading/Writing the config file in its human-readable form. */ - std::string GetAsJSON(bool withSensitiveData); - bool SaveFromJSON(std::string_view json); - - /* GetAsFlatBuffer and SaveFromFlatBuffer are used for Reading/Writing the config file in its binary form. */ - [[nodiscard]] flatbuffers::Offset GetAsFlatBuffer(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData); - bool SaveFromFlatBuffer(const Serialization::Configuration::HubConfig* config); - - /* GetRaw and SetRaw are used for Reading/Writing the config file in its binary form. */ - bool GetRaw(TinyVec& buffer); - bool SetRaw(const uint8_t* buffer, std::size_t size); - - /** - * @brief Resets the config file to the factory default values. - * - * @note A restart after calling this function is HIGHLY recommended. - */ - void FactoryReset(); - - [[nodiscard]] std::optional GetRFConfig(); - [[nodiscard]] std::optional GetWiFiConfig(); - [[nodiscard]] std::optional GetCaptivePortalConfig(); - [[nodiscard]] std::optional GetBackendConfig(); - [[nodiscard]] std::optional GetSerialInputConfig(); - [[nodiscard]] std::optional GetOtaUpdateConfig(); - [[nodiscard]] std::optional GetEStopConfig(); - - bool SetRFConfig(const RFConfig& config); - bool SetWiFiConfig(const WiFiConfig& config); - bool SetCaptivePortalConfig(const CaptivePortalConfig& config); - bool SetBackendConfig(const BackendConfig& config); - bool SetSerialInputConfig(const SerialInputConfig& config); - bool SetOtaUpdateConfig(const OtaUpdateConfig& config); - /// @brief Read-modify-write that no other writer can interleave with. `update` edits the current config in place and - /// returns whether it changed anything (nothing is saved otherwise). It runs under the config write lock: keep - /// it to plain field updates. - bool UpdateOtaUpdateConfig(Util::FunctionRef update); - bool SetEStopConfig(const EStopConfig& config); - - [[nodiscard]] std::optional> GetWiFiCredentials(); - // Emits each stored credential as an object into an already-open JSON array - // (the caller opens/closes it with json_gen_{start,end}_array). - bool GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData); - bool SetWiFiCredentials(const std::vector& credentials); - - [[nodiscard]] std::optional GetRFConfigTxPin(); - bool SetRFConfigTxPin(gpio_num_t txPin); - [[nodiscard]] std::optional GetRFConfigKeepAliveEnabled(); - bool SetRFConfigKeepAliveEnabled(bool enabled); - - bool AnyWiFiCredentials(Util::FunctionRef predicate); - uint8_t AddWiFiCredentials(std::string_view ssid, std::string_view password, wifi_auth_mode_t authMode = WIFI_AUTH_MAX); - [[nodiscard]] std::optional TryGetWiFiCredentialsByID(uint8_t id); - [[nodiscard]] std::optional TryGetWiFiCredentialsBySSID(const char* ssid); - uint8_t GetWiFiCredentialsIDbySSID(const char* ssid); - bool PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]); - bool RemoveWiFiCredentials(uint8_t id); - bool ClearWiFiCredentials(); - [[nodiscard]] std::optional GetWiFiHostname(); - bool SetWiFiHostname(std::string hostname); - - [[nodiscard]] std::optional GetBackendDomain(); - bool SetBackendDomain(std::string domain); - bool HasBackendAuthToken(); - [[nodiscard]] std::optional GetBackendAuthToken(); - bool SetBackendAuthToken(std::string token); - bool ClearBackendAuthToken(); - - [[nodiscard]] std::optional GetSerialInputConfigEchoEnabled(); - bool SetSerialInputConfigEchoEnabled(bool enabled); - - [[nodiscard]] std::optional GetOtaUpdateId(); - bool SetOtaUpdateId(int32_t updateId); - [[nodiscard]] std::optional GetOtaUpdateStep(); - bool SetOtaUpdateStep(OtaUpdateStep updateStep); - /// @brief Sets the step only if it is still `expected`, so a stale writer can't clobber an update started meanwhile. - /// @return True if the step was `expected` and is now `desired` - bool CompareExchangeOtaUpdateStep(OtaUpdateStep expected, OtaUpdateStep desired); - - [[nodiscard]] std::optional GetEStopEnabled(); - bool SetEStopEnabled(bool enabled); - [[nodiscard]] std::optional GetEStopGpioPin(); - bool SetEStopGpioPin(gpio_num_t gpioPin); -} // namespace OpenShock::Config diff --git a/components/config/include/config/ConfigBase.h b/components/config/include/config/ConfigBase.h deleted file mode 100644 index 3b43e0f54..000000000 --- a/components/config/include/config/ConfigBase.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include "serialization/_fbs/HubConfig_generated.h" - -#include "json/Json.h" - -#include - -namespace OpenShock::Config { - template - struct ConfigBase { - virtual void ToDefault() = 0; - - virtual bool FromFlatbuffers(const T* config) = 0; - [[nodiscard]] virtual flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const = 0; - - virtual bool FromJSON(JSON::JsonView json) = 0; - // Emits this config as its own JSON object. When `name` is non-null the object - // is added as a named member of the enclosing object; when null it is anonymous - // (document root or array element). See JSON::objBegin/objEnd. - virtual void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const = 0; - }; - -} // namespace OpenShock::Config diff --git a/components/config/include/config/EStopConfig.h b/components/config/include/config/EStopConfig.h deleted file mode 100644 index 7bec4f1c4..000000000 --- a/components/config/include/config/EStopConfig.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include "config/ConfigBase.h" - -#include - -namespace OpenShock::Config { - // The schema's `active` and `latching` fields are not modelled: nothing reads them, so they are dropped on save. - struct EStopConfig : public ConfigBase { - EStopConfig(); - EStopConfig(bool enabled, gpio_num_t gpioPin); - - bool enabled; - gpio_num_t gpioPin; - - void ToDefault() override; - - /// @brief Disables the E-Stop when its pin can't be used as an input, so every input path stores the same thing. - void Normalize(); - - bool FromFlatbuffers(const Serialization::Configuration::EStopConfig* config) override; - flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - - bool FromJSON(JSON::JsonView json) override; - void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; - }; -} // namespace OpenShock::Config diff --git a/components/config/include/config/Migration.h b/components/config/include/config/Migration.h new file mode 100644 index 000000000..63d1583e1 --- /dev/null +++ b/components/config/include/config/Migration.h @@ -0,0 +1,24 @@ +#pragma once + +// The config half of the 1.x -> 2.x storage migration (docs/STORAGE.md): retype the 1.x littlefs `config` partition +// as the `cfg` NVS store, in place. Not called by anything yet; the release that pipelines the storage migrations +// decides when it runs. +// +// What happens around it is Store's: before it, a 1.x hub keeps its config (imported from the 1.x file) in the shared +// `nvs`; on the first boot after it, Store erases the littlefs left in `cfg` and moves the config over. + +namespace OpenShock::Config { + enum class MigrationResult { + NotNeeded, // the table already has `cfg` + Refused, // no 1.x `config` partition, the table could not be read, or the config is not in the shared nvs + // (retyping would lose it) + }; + + /// @brief Retypes the 1.x `config` partition as `cfg` and restarts, whether the table write went through or not + /// (PartitionTable::Replace). Returns only when it touched nothing. + /// + /// Requires everything that uses NVS or the partition API outside the config store to be stopped: it closes NVS + /// for the table write. A power cut between the erase and the write of the table sector bricks the hub until the + /// table is reflashed over USB (docs/STORAGE.md, Recovery). + MigrationResult MigrateToCfgPartition(); +} // namespace OpenShock::Config diff --git a/components/config/include/config/Nvs.h b/components/config/include/config/Nvs.h new file mode 100644 index 000000000..54605dc41 --- /dev/null +++ b/components/config/include/config/Nvs.h @@ -0,0 +1,114 @@ +#pragma once + +// Typed access to one NVS namespace: what every module's config section uses for its LoadNvs / SaveNvs. The +// partition is the one the store picked (Config::StorageLabel(), handed to LoadNvs/SaveNvs), never hardcoded. +// +// Reads are tri-state (Found / Missing / Error): a missing key is a default, an error fails the load. Writes +// take the previous value so a save touches only keys whose value changed: NVS is append-only with +// copy-on-write pages, so every write costs an entry until the page is compacted, and a field that did not +// change costs nothing. NVS guarantees atomicity per key, not across keys: anything that must change together +// belongs in one blob. +// +// Namespace and key names are at most 15 characters and are effectively permanent once devices hold them. + +#include + +#include +#include +#include +#include +#include +#include + +namespace OpenShock::Config::Nvs { + enum class Result : uint8_t { + Found, + Missing, + Error, + }; + + class Handle { + public: + /// @brief Opens `ns` in `partition`. Read-only opens of a namespace that does not exist yet fail with nullopt + /// and no error logged: that is what a fresh store looks like, and every read then yields Missing. + [[nodiscard]] static std::optional Open(const char* partition, const char* ns, bool write); + + Handle(Handle&& other) noexcept; + Handle& operator=(Handle&& other) noexcept; + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + ~Handle(); + + [[nodiscard]] nvs_handle_t get() const { return m_handle; } + + [[nodiscard]] Result GetU8(const char* key, uint8_t& out) const; + [[nodiscard]] Result GetI8(const char* key, int8_t& out) const; + [[nodiscard]] Result GetU16(const char* key, uint16_t& out) const; + [[nodiscard]] Result GetI32(const char* key, int32_t& out) const; + [[nodiscard]] Result GetStr(const char* key, std::string& out) const; + [[nodiscard]] Result GetBlob(const char* key, std::vector& out) const; + + // `previous` is the value on flash as the caller last saw it (null when unknown): the write is skipped when + // it equals `value`. + [[nodiscard]] bool SetU8(const char* key, uint8_t value, const uint8_t* previous = nullptr); + [[nodiscard]] bool SetBool(const char* key, bool value, const bool* previous = nullptr); + [[nodiscard]] bool SetI8(const char* key, int8_t value, const int8_t* previous = nullptr); + [[nodiscard]] bool SetU16(const char* key, uint16_t value, const uint16_t* previous = nullptr); + [[nodiscard]] bool SetI32(const char* key, int32_t value, const int32_t* previous = nullptr); + [[nodiscard]] bool SetStr(const char* key, std::string_view value, const std::string* previous = nullptr); + [[nodiscard]] bool SetBlob(const char* key, std::span value); + + /// @brief Removes `key`; a key that is not there counts as removed. + [[nodiscard]] bool Erase(const char* key); + [[nodiscard]] bool EraseAll(); + [[nodiscard]] bool Commit(); + + /// @brief Calls `fn(key)` for every key of `type` (NVS_TYPE_ANY for all) in the namespace. + template + bool ForEachKey(nvs_type_t type, Fn&& fn) const + { + nvs_iterator_t it = nullptr; + esp_err_t err = nvs_entry_find_in_handle(m_handle, type, &it); + while (err == ESP_OK) { + nvs_entry_info_t info; + nvs_entry_info(it, &info); + fn(info.key); + err = nvs_entry_next(&it); + } + nvs_release_iterator(it); + return err == ESP_ERR_NVS_NOT_FOUND; // the iterator ran off the end, which is the only clean exit + } + + private: + explicit Handle(nvs_handle_t handle) + : m_handle(handle) + { + } + + nvs_handle_t m_handle; + }; + + // Loads a bool / integer / string with its default: Missing takes `defaultValue`, Error returns false. + bool Load(const Handle& handle, const char* key, bool& out, bool defaultValue); + bool Load(const Handle& handle, const char* key, uint8_t& out, uint8_t defaultValue); + bool Load(const Handle& handle, const char* key, int8_t& out, int8_t defaultValue); + bool Load(const Handle& handle, const char* key, uint16_t& out, uint16_t defaultValue); + bool Load(const Handle& handle, const char* key, int32_t& out, int32_t defaultValue); + bool Load(const Handle& handle, const char* key, std::string& out, std::string_view defaultValue); + + /// @brief Whether `ns` exists in `partition`: it was created by a write once, and stays even when every key of it + /// has since been erased. + [[nodiscard]] bool NamespaceExists(const char* partition, const char* ns); + + /// @brief The number of keys in `ns` of `partition`; 0 for a namespace that does not exist. + [[nodiscard]] std::size_t KeyCount(const char* partition, const char* ns); + + /// @brief Erases every key of `ns` in `partition`; a namespace that does not exist counts as erased. + [[nodiscard]] bool EraseNamespace(const char* partition, const char* ns); + + /// @brief Copies every key of `ns` from partition `from` into partition `to`, whatever its type, and commits. + [[nodiscard]] bool CopyNamespace(const char* from, const char* to, const char* ns); + + /// @brief Whether `ns` holds the same keys with the same types and values in both partitions. + [[nodiscard]] bool NamespacesEqual(const char* a, const char* b, const char* ns); +} // namespace OpenShock::Config::Nvs diff --git a/components/config/include/config/OtaUpdateConfig.h b/components/config/include/config/OtaUpdateConfig.h deleted file mode 100644 index d81241b60..000000000 --- a/components/config/include/config/OtaUpdateConfig.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include "config/ConfigBase.h" -#include "enums/FirmwareBootType.h" -#include "enums/OtaUpdateChannel.h" -#include "enums/OtaUpdateStep.h" - -#include - -namespace OpenShock::Config { - struct OtaUpdateConfig : public ConfigBase { - OtaUpdateConfig(); - OtaUpdateConfig( - bool isEnabled, std::string cdnDomain, OtaUpdateChannel updateChannel, bool checkOnStartup, bool checkPeriodically, uint16_t checkInterval, bool allowBackendManagement, bool requireManualApproval, int32_t updateId, OtaUpdateStep updateStep - ); - - bool isEnabled; - std::string cdnDomain; - OtaUpdateChannel updateChannel; - bool checkOnStartup; - bool checkPeriodically; - uint16_t checkInterval; - bool allowBackendManagement; - bool requireManualApproval; - int32_t updateId; - OtaUpdateStep updateStep; - - void ToDefault() override; - - bool FromFlatbuffers(const Serialization::Configuration::OtaUpdateConfig* config) override; - [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - - bool FromJSON(JSON::JsonView json) override; - void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; - }; -} // namespace OpenShock::Config diff --git a/components/config/include/config/internal/utils.h b/components/config/include/config/Parse.h similarity index 50% rename from components/config/include/config/internal/utils.h rename to components/config/include/config/Parse.h index f46917c13..3064be696 100644 --- a/components/config/include/config/internal/utils.h +++ b/components/config/include/config/Parse.h @@ -1,6 +1,7 @@ #pragma once -#include "config/ConfigBase.h" +// Field parsing helpers shared by every module's config section (its FromJSON): GPIO numbers, enum strings and +// arrays, each falling back to the default the way the rest of a section does. #include "util/FunctionRef.h" #include "json/Json.h" @@ -13,32 +14,14 @@ #include #include -namespace OpenShock::Config::Internal::Utils { +namespace OpenShock::Config::Parse { // Accepts GPIO_NUM_NC (-1, "no pin") or any GPIO number valid on this chip; used for every stored/imported pin. - [[nodiscard]] std::optional FromIntGpioNum(int32_t intVal); + [[nodiscard]] std::optional GpioNum(int32_t intVal); - void FromFbsStr(std::string& str, const flatbuffers::String* fbsStr, const char* defaultStr); + [[nodiscard]] std::optional JsonGpioNum(JSON::JsonView json, std::string_view name); - [[nodiscard]] std::optional FromJsonGpioNum(JSON::JsonView json, std::string_view name); - - template // T inherits from ConfigBase - void FromFbsVec(std::vector& vec, const flatbuffers::Vector>* fbsVec) - { - vec.clear(); - - if (fbsVec == nullptr) { - return; - } - - for (auto fbsItem : *fbsVec) { - T item; - if (item.FromFlatbuffers(fbsItem)) { - vec.push_back(std::move(item)); - } - } - } - template // T inherits from ConfigBase - bool FromJsonStrParsed(T& val, JSON::JsonView json, std::string_view name, std::type_identity_t(std::string_view)>> StringParser, T defaultVal) + template + bool JsonStrParsed(T& val, JSON::JsonView json, std::string_view name, std::type_identity_t(std::string_view)>> StringParser, T defaultVal) { JSON::JsonView jsonVal = json[name]; if (!jsonVal.valid()) { @@ -63,8 +46,10 @@ namespace OpenShock::Config::Internal::Utils { val = *parsed; return true; } - template // T inherits from ConfigBase - bool FromJsonArray(std::vector& vec, JSON::JsonView jsonArray) + + /// @brief Fills `vec` with every element of `jsonArray` whose T::FromJSON accepts it; the others are dropped. + template + bool JsonArray(std::vector& vec, JSON::JsonView jsonArray) { vec.clear(); if (!jsonArray.isArray()) { @@ -81,4 +66,4 @@ namespace OpenShock::Config::Internal::Utils { return true; } -} // namespace OpenShock::Config::Internal::Utils +} // namespace OpenShock::Config::Parse diff --git a/components/config/include/config/RFConfig.h b/components/config/include/config/RFConfig.h deleted file mode 100644 index 49cf370da..000000000 --- a/components/config/include/config/RFConfig.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include - -#include "config/ConfigBase.h" - -namespace OpenShock::Config { - struct RFConfig : public ConfigBase { - RFConfig(); - RFConfig(gpio_num_t txPin, bool keepAliveEnabled); - - gpio_num_t txPin; - bool keepAliveEnabled; - - void ToDefault() override; - - bool FromFlatbuffers(const Serialization::Configuration::RFConfig* config) override; - [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - - bool FromJSON(JSON::JsonView json) override; - void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; - }; -} // namespace OpenShock::Config diff --git a/components/config/include/config/RootConfig.h b/components/config/include/config/RootConfig.h deleted file mode 100644 index 946f3078e..000000000 --- a/components/config/include/config/RootConfig.h +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include "config/BackendConfig.h" -#include "config/CaptivePortalConfig.h" -#include "config/ConfigBase.h" -#include "config/EStopConfig.h" -#include "config/OtaUpdateConfig.h" -#include "config/RFConfig.h" -#include "config/SerialInputConfig.h" -#include "config/WiFiConfig.h" - -namespace OpenShock::Config { - struct RootConfig : public ConfigBase { - RootConfig(); - - OpenShock::Config::RFConfig rf; - OpenShock::Config::WiFiConfig wifi; - OpenShock::Config::CaptivePortalConfig captivePortal; - OpenShock::Config::BackendConfig backend; - OpenShock::Config::SerialInputConfig serialInput; - OpenShock::Config::OtaUpdateConfig otaUpdate; - OpenShock::Config::EStopConfig estop; - - void ToDefault() override; - - bool FromFlatbuffers(const Serialization::Configuration::HubConfig* config) override; - [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - - bool FromJSON(JSON::JsonView json) override; - void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; - }; -} // namespace OpenShock::Config diff --git a/components/config/include/config/Section.h b/components/config/include/config/Section.h new file mode 100644 index 000000000..5fc57222f --- /dev/null +++ b/components/config/include/config/Section.h @@ -0,0 +1,251 @@ +#pragma once + +// A config section: one module's settings, owned and defined by that module, kept in NVS through this framework. +// +// The module defines a value type T and one Section object for it (a namespace-scope static in its own .cpp): +// +// struct RFConfig { +// void ToDefault(); +// bool LoadNvs(const char* partition); // missing keys are defaults +// bool SaveNvs(const char* partition, const RFConfig* previous) const; // writes only what differs +// bool FromJSON(JSON::JsonView json); // invalid view: defaults +// void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; +// flatbuffers::Offset<...> ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; +// }; +// static constexpr const char* kNamespaces[] = {"rf"}; +// static Config::Section s_rf({.name = "rf", .order = 0, .hubConfigField = VT_RF, .namespaces = kNamespaces}); +// +// and wraps it in whatever accessors it wants (Config::GetRFConfigTxPin() and so on). Nothing outside the module +// needs to know T's fields or keys; this framework only sees the section through SectionBase. +// +// The section caches its value in RAM: readers copy it out under a short lock and never wait on flash. Writers are +// serialized across every section (one write lock), save what changed, and only then publish the new value, so a +// failed write leaves the running value identical to what is on flash. +// +// Every section links itself into one registry when it is constructed (static initialization), which is how the +// framework loads all of them at boot, renders the whole config as JSON (jsonconfig), imports one, and resets them +// (factory reset). Sections never die; the registry is a constant-initialized intrusive list. + +#include "OpenShock.h" +#include "SimpleMutex.h" +#include "util/FunctionRef.h" + +#include + +#include +#include +#include +#include + +namespace OpenShock::JSON { + class JsonView; +} +namespace flatbuffers { + template + class FlatBufferBuilderImpl; + using FlatBufferBuilder = FlatBufferBuilderImpl; +} // namespace flatbuffers + +namespace OpenShock::Config { + struct SectionInfo { + const char* name; // member name of the section in the jsonconfig document, e.g. "rf" + uint8_t order; // position in that document and in HubConfig; unique across sections + uint16_t hubConfigField; // the HubConfig field (VT_*) its wire form goes into for the local protocol; 0 = none + std::span namespaces; // the NVS namespaces it owns (erased by a reset, moved with the store) + }; + + class SectionBase { + DISABLE_COPY(SectionBase); + DISABLE_MOVE(SectionBase); + + public: + [[nodiscard]] const SectionInfo& Info() const { return m_info; } + + /// @brief The registered sections, in `order`. + [[nodiscard]] static SectionBase* First(); + [[nodiscard]] SectionBase* Next() const { return m_next; } + + // The interface the store (Store.cpp) drives. All of these run under the write lock. + virtual bool Load(const char* partition) = 0; // from NVS into the cache; false on a read error + virtual bool Reset(const char* partition) = 0; // erase its namespaces, save and publish the defaults + virtual bool VerifyStored(const char* partition) const = 0; // whether NVS reads back as the cache + virtual void ToJSON(json_gen_str_t* gen, bool withSensitiveData) const = 0; // as the member `name` + virtual bool Stage(const JSON::JsonView& json) = 0; // parse into a staging copy + virtual bool CommitStaged(const char* partition, bool full) = 0; // save it (every key when full) and publish + virtual void DiscardStaged() = 0; + virtual uint32_t ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const = 0; // the sub-table offset + + protected: + explicit SectionBase(const SectionInfo& info); + ~SectionBase() = default; // Sections are statics and are never destroyed + + private: + const SectionInfo m_info; // A copy: modules pass a temporary + SectionBase* m_next; + }; + + namespace Internal { + /// @brief Serializes every writer of every section, the store's own operations included. + SimpleMutex& WriteLock(); + /// @brief The partition the store uses ("cfg"; "nvs" on the 1.x layout), or null when the config only lives in RAM. + const char* Partition(); + } // namespace Internal + + template + class Section final : public SectionBase { + public: + explicit Section(const SectionInfo& info) + : SectionBase(info) + , m_dataMutex() + , m_value() + , m_staged() + { + } + + /// @brief A copy of the current value. + [[nodiscard]] T Get() const + { + ScopedLock lock__(&m_dataMutex); + return m_value; + } + + /// @brief Runs `fn(const T&)` under the read lock and returns what it returns: for reading one field of a large + /// section without copying it. Keep `fn` short, and never call into config from it. + template + decltype(auto) Read(Fn&& fn) const + { + ScopedLock lock__(&m_dataMutex); + return std::forward(fn)(std::as_const(m_value)); + } + + bool Set(const T& value) + { + return Update([&](T& current) { + current = value; + return true; + }); + } + + /// @brief Read-modify-write that no other writer can interleave with. `mutate` edits a copy of the current value + /// and returns whether it changed anything; a changed copy is saved (the keys that differ) and only then + /// published. It runs under the write lock: keep it to plain field updates. + /// @return False only when saving failed, in which case the value is unchanged. + bool Update(Util::FunctionRef mutate) + { + ScopedLock lock__(&Internal::WriteLock()); + T updated = m_value; // Writers are serialized, so the value can't change under this read + if (!mutate(updated)) { + return true; + } + if (!save(updated, &m_value)) { + return false; + } + publish(std::move(updated)); + return true; + } + + bool Load(const char* partition) override + { + T loaded; + bool ok = loaded.LoadNvs(partition); + if (!ok) { + loaded.ToDefault(); + } + publish(std::move(loaded)); + return ok; + } + + bool Reset(const char* partition) override + { + T defaults; + defaults.ToDefault(); + bool ok = true; + if (partition != nullptr) { + ok = eraseNamespaces(partition) && defaults.SaveNvs(partition, nullptr); + } + publish(std::move(defaults)); // What the hub runs with either way; a failed save only means it isn't kept + return ok; + } + + bool VerifyStored(const char* partition) const override + { + T stored; + return stored.LoadNvs(partition) && sameValue(stored, Get()); + } + + void ToJSON(json_gen_str_t* gen, bool withSensitiveData) const override + { + ScopedLock lock__(&m_dataMutex); + m_value.ToJSON(gen, Info().name, withSensitiveData); + } + + bool Stage(const JSON::JsonView& json) override + { + T staged = m_value; // FromJSON resets every field it is given no value for + if (!staged.FromJSON(json)) { + return false; + } + m_staged.emplace(std::move(staged)); + return true; + } + + bool CommitStaged(const char* partition, bool full) override + { + if (!m_staged) { + return true; + } + bool ok = partition == nullptr || m_staged->SaveNvs(partition, full ? nullptr : &m_value); + if (ok) { + publish(std::move(*m_staged)); + } + m_staged.reset(); + return ok; + } + + void DiscardStaged() override { m_staged.reset(); } + + uint32_t ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override + { + ScopedLock lock__(&m_dataMutex); + return m_value.ToFlatbuffers(builder, withSensitiveData).o; + } + + private: + bool save(const T& value, const T* previous) const + { + const char* partition = Internal::Partition(); + return partition == nullptr || value.SaveNvs(partition, previous); + } + + void publish(T&& value) + { + ScopedLock lock__(&m_dataMutex); + m_value = std::move(value); + } + + bool eraseNamespaces(const char* partition) const; + static bool sameValue(const T& a, const T& b); + + mutable SimpleMutex m_dataMutex; + T m_value; + std::optional m_staged; // Only touched under the write lock + }; + + namespace Internal { + bool EraseNamespaces(const char* partition, std::span namespaces); + /// @brief Whether two values render the same JSON, secrets included: the complete, canonical comparison. + bool SameJson(Util::FunctionRef renderA, Util::FunctionRef renderB); + } // namespace Internal + + template + bool Section::eraseNamespaces(const char* partition) const + { + return Internal::EraseNamespaces(partition, Info().namespaces); + } + + template + bool Section::sameValue(const T& a, const T& b) + { + return Internal::SameJson([&](json_gen_str_t* gen) { a.ToJSON(gen, nullptr, true); }, [&](json_gen_str_t* gen) { b.ToJSON(gen, nullptr, true); }); + } +} // namespace OpenShock::Config diff --git a/components/config/include/config/SerialInputConfig.h b/components/config/include/config/SerialInputConfig.h deleted file mode 100644 index 8742aa8d7..000000000 --- a/components/config/include/config/SerialInputConfig.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include "config/ConfigBase.h" - -namespace OpenShock::Config { - struct SerialInputConfig : public ConfigBase { - SerialInputConfig(); - SerialInputConfig(bool echoEnabled); - - bool echoEnabled; - - void ToDefault() override; - - bool FromFlatbuffers(const Serialization::Configuration::SerialInputConfig* config) override; - [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - - bool FromJSON(JSON::JsonView json) override; - void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; - }; -} // namespace OpenShock::Config diff --git a/components/config/include/config/Store.h b/components/config/include/config/Store.h new file mode 100644 index 000000000..72dff90c5 --- /dev/null +++ b/components/config/include/config/Store.h @@ -0,0 +1,38 @@ +#pragma once + +// The config store: where every module's section (config/Section.h) lives, and the operations over all of them. +// +// The store is the NVS partition labelled `cfg` (2.x partition layouts). On the 1.x layout, which has no `cfg`, +// the same namespaces live in the shared `nvs` partition until the layout migration creates `cfg`; the first boot +// that finds both moves them over, verifies the copy and deletes the shared one. A hub without any usable NVS runs +// on defaults in RAM and says so. +// +// The `root` namespace is the store's own: `schema` (u8) marks a store that has been filled. An unmarked store is +// filled once, from the 1.x littlefs config file when the hub has one, else from every section's defaults. + +#include +#include + +namespace OpenShock::Config { + /// @brief Picks and opens the store, moves or imports what needs it, then loads every section. Call once, after + /// Board::Init (section defaults read the board) and before anything reads a section. + void Init(); + + /// @brief The partition the config is stored in ("cfg", "nvs" on the 1.x layout), or null when it only lives in RAM. + [[nodiscard]] const char* StorageLabel(); + + /** + * @brief Resets every section to its defaults: erases its namespaces and writes the defaults. + * + * @note A restart after calling this function is HIGHLY recommended. + */ + void FactoryReset(); + + /// @brief The whole config as one JSON object, one member per section (the jsonconfig serial command, backups). + std::string GetAsJSON(bool withSensitiveData); + + /// @brief Applies a whole-config JSON object. Every section parses first and nothing is saved unless all of them + /// accept their part; a section given no member resets to its defaults. The saves themselves are per key + /// (NVS has no transactions): if one fails, the sections saved before it keep their new values. + bool SaveFromJSON(std::string_view json); +} // namespace OpenShock::Config diff --git a/components/config/include/config/WiFiConfig.h b/components/config/include/config/WiFiConfig.h deleted file mode 100644 index ca16b5b75..000000000 --- a/components/config/include/config/WiFiConfig.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include "config/ConfigBase.h" -#include "config/WiFiCredentials.h" - -#include -#include -#include - -namespace OpenShock::Config { - struct WiFiConfig : public ConfigBase { - WiFiConfig(); - WiFiConfig(std::string_view accessPointSSID, std::string_view hostname, const std::vector& credentialsList); - - std::string accessPointSSID; - std::string hostname; - std::vector credentialsList; - - void ToDefault() override; - - bool FromFlatbuffers(const Serialization::Configuration::WiFiConfig* config) override; - [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; - - bool FromJSON(JSON::JsonView json) override; - void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; - }; -} // namespace OpenShock::Config diff --git a/components/config/include/config/Wire.h b/components/config/include/config/Wire.h new file mode 100644 index 000000000..0a085c164 --- /dev/null +++ b/components/config/include/config/Wire.h @@ -0,0 +1,34 @@ +#pragma once + +// The whole config in the FlatBuffers HubConfig form. It is never stored: it is the shape the local (captive +// portal) protocol's Ready message carries, and the binary form of the rawconfig serial command. Assembled from +// every section's own ToFlatbuffers; parsed back through the JSON importer (HubConfig -> jsonconfig document). + +#include "serialization/_fbs/HubConfig_generated.h" +#include "TinyVec.h" + +#include +#include +#include +#include +#include + +namespace OpenShock::Config { + /// @brief Builds a HubConfig table from every section that has a HubConfig field. + [[nodiscard]] flatbuffers::Offset GetAsFlatBuffer(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData); + + /// @brief The finished HubConfig buffer, secrets included (rawconfig get). + bool GetRaw(TinyVec& buffer); + + /// @brief Applies a HubConfig buffer as SaveFromJSON applies a document (rawconfig set). False when it does not + /// verify or a section refuses its part. + bool SetRaw(const uint8_t* buffer, std::size_t size); + + /// @brief The jsonconfig document a HubConfig buffer stands for: what SetRaw applies and how the 1.x config file + /// (the same HubConfig, written by the Arduino firmware) is imported. Nullopt when the buffer does not verify. + [[nodiscard]] std::optional HubConfigToJSON(std::span buffer); + + /// @brief The largest HubConfig accepted: what the 1.x config partition could hold, which is also far more than + /// any real config renders to. + inline constexpr std::size_t kMaxHubConfigSize = 4096; +} // namespace OpenShock::Config diff --git a/components/config/src/BackendConfig.cpp b/components/config/src/BackendConfig.cpp deleted file mode 100644 index 908f433a2..000000000 --- a/components/config/src/BackendConfig.cpp +++ /dev/null @@ -1,85 +0,0 @@ -#include "config/BackendConfig.h" - -const char* const TAG = "Config::BackendConfig"; - -#include "config/internal/utils.h" -#include "Logging.h" - -using namespace OpenShock::Config; - -BackendConfig::BackendConfig() - : domain(CONFIG_OPENSHOCK_API_DOMAIN) - , authToken() -{ -} - -BackendConfig::BackendConfig(std::string_view domain, std::string_view authToken) - : domain(domain) - , authToken(authToken) -{ -} - -void BackendConfig::ToDefault() -{ - domain = CONFIG_OPENSHOCK_API_DOMAIN; - authToken.clear(); -} - -bool BackendConfig::FromFlatbuffers(const Serialization::Configuration::BackendConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - Internal::Utils::FromFbsStr(domain, config->domain(), CONFIG_OPENSHOCK_API_DOMAIN); - Internal::Utils::FromFbsStr(authToken, config->auth_token(), ""); - - return true; -} - -flatbuffers::Offset BackendConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - auto domainOffset = builder.CreateString(domain); - - flatbuffers::Offset authTokenOffset; - if (withSensitiveData) { - authTokenOffset = builder.CreateString(authToken); - } else { - authTokenOffset = 0; - } - - return Serialization::Configuration::CreateBackendConfig(builder, domainOffset, authTokenOffset); -} - -bool BackendConfig::FromJSON(JSON::JsonView json) -{ - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - domain = json["domain"].tryGetStr().value_or(CONFIG_OPENSHOCK_API_DOMAIN); - - authToken = json["authToken"].tryGetStr().value_or(std::string()); - - return true; -} - -void BackendConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const -{ - JSON::objBegin(gen, name); - JSON::objSetString(gen, "domain", domain); - - if (withSensitiveData) { - JSON::objSetString(gen, "authToken", authToken); - } - JSON::objEnd(gen, name); -} diff --git a/components/config/src/CaptivePortalConfig.cpp b/components/config/src/CaptivePortalConfig.cpp deleted file mode 100644 index 33993daa8..000000000 --- a/components/config/src/CaptivePortalConfig.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#include "config/CaptivePortalConfig.h" - -const char* const TAG = "Config::CaptivePortalConfig"; - -#include "config/internal/utils.h" -#include "Logging.h" - -using namespace OpenShock::Config; - -CaptivePortalConfig::CaptivePortalConfig() - : alwaysEnabled(false) -{ -} - -CaptivePortalConfig::CaptivePortalConfig(bool alwaysEnabled) - : alwaysEnabled(alwaysEnabled) -{ -} - -void CaptivePortalConfig::ToDefault() -{ - alwaysEnabled = false; -} - -bool CaptivePortalConfig::FromFlatbuffers(const Serialization::Configuration::CaptivePortalConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - alwaysEnabled = config->always_enabled(); - - return true; -} - -flatbuffers::Offset CaptivePortalConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - return Serialization::Configuration::CreateCaptivePortalConfig(builder, alwaysEnabled); -} - -bool CaptivePortalConfig::FromJSON(JSON::JsonView json) -{ - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - alwaysEnabled = json["alwaysEnabled"].tryGetBool().value_or(false); - - return true; -} - -void CaptivePortalConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const -{ - JSON::objBegin(gen, name); - json_gen_obj_set_bool(gen, "alwaysEnabled", alwaysEnabled); - JSON::objEnd(gen, name); -} diff --git a/components/config/src/Config.cpp b/components/config/src/Config.cpp deleted file mode 100644 index 2b080e9b1..000000000 --- a/components/config/src/Config.cpp +++ /dev/null @@ -1,868 +0,0 @@ -#include - -#include "config/Config.h" - -const char* const TAG = "Config"; - -#include "Chipset.h" -#include "config/RootConfig.h" -#include "Logging.h" -#include "OpenShock.h" -#include "serialization/VerifiedRoot.h" -#include "SimpleMutex.h" - -#include "json/Json.h" - -#include "fs/ConfigFs.h" - -#include -#include -#include -#include -#include -#include -#include - -using namespace OpenShock; - -// littlefs partition holding the persisted config blob. The Arduino LittleFSFS -// stored it as the file "config" at the root of the partition labelled "config" -// (VFS "/config/config" resolved to lfs path "/config"); the raw-lfs ConfigFs opens -// that same path so existing devices keep loading their config after the migration. -static constexpr const char* CONFIG_PARTITION_LABEL = "config"; -static constexpr const char* CONFIG_FILE_PATH = "/config"; - -static OpenShock::ConfigFs _configFs; -// Constructed at static-init time, before app_main has run Board::Init(), so the RF and E-Stop constructors see -// the no-pin fallback descriptor here. That is harmless only because Init() always re-seeds it: a stored config -// goes through FromFlatbuffers, which falls back to the board default per field, and a missing or corrupt one -// through ToDefault(). Nothing may read _configData before Config::Init(). -static Config::RootConfig _configData; - -// Two priority-inheriting mutexes, so no reader ever waits on flash: -// - _configDataMutex guards _configData and is held only to copy it out (readers) or to commit a new one (writers). -// - _configWriteMutex serializes writers across copy, mutate, save and commit. Writers are the only ones that modify -// _configData, so while holding it they may read _configData without _configDataMutex. -// Lock order: _configWriteMutex before _configDataMutex. -static SimpleMutex _configDataMutex; -static SimpleMutex _configWriteMutex; - -// Both wait as long as it takes, so they can't fail; `retval` is kept so the call sites read the same. -#define CONFIG_LOCK_READ(retval) ScopedLock lock__(&_configDataMutex) -#define CONFIG_LOCK_WRITE(retval) ScopedLock lock__(&_configWriteMutex) - -// Requires the write lock. Publishes `config` as the running config. -static void commitConfig(Config::RootConfig&& config) -{ - ScopedLock lock__(&_configDataMutex); - _configData = std::move(config); -} - -static bool tryDeserializeConfig(const uint8_t* buffer, std::size_t bufferLen, OpenShock::Config::RootConfig& config) -{ - if (buffer == nullptr) { - OS_LOGE(TAG, "Buffer is null"); - return false; - } - - auto fbsConfig = Serialization::GetVerifiedRoot(std::span(buffer, bufferLen), Config::MaxConfigSize); - if (fbsConfig == nullptr) { - OS_LOGE(TAG, "Config failed verification (%zu bytes, max %zu)", bufferLen, Config::MaxConfigSize); - return false; - } - - // Read config - if (!config.FromFlatbuffers(fbsConfig)) { - OS_LOGE(TAG, "Failed to read config file"); - return false; - } - - return true; -} -// Requires the write lock. -static bool tryLoadConfig() -{ - std::vector data; - if (!_configFs.read(CONFIG_FILE_PATH, data)) { - OS_LOGE(TAG, "Failed to read config file"); - return false; - } - - OpenShock::Config::RootConfig loaded; - if (!tryDeserializeConfig(data.data(), data.size(), loaded)) { - return false; - } - - commitConfig(std::move(loaded)); - return true; -} -static bool trySaveConfig(const uint8_t* data, std::size_t dataLen) -{ - if (dataLen > Config::MaxConfigSize) { - OS_LOGE(TAG, "Config is too large to store (%zu bytes, max %zu)", dataLen, Config::MaxConfigSize); - return false; - } - - if (!_configFs.write(CONFIG_FILE_PATH, std::span(data, dataLen))) { - OS_LOGE(TAG, "Failed to write config file (%zu bytes)", dataLen); - return false; - } - - return true; -} -static bool trySaveConfig(const OpenShock::Config::RootConfig& config) -{ - flatbuffers::FlatBufferBuilder builder; - - // Write every field, even ones equal to the schema default. Older firmware may use different defaults - // (1.5.x reads a missing is_enabled/keepalive_enabled as false), so an omitted field changes meaning on downgrade. - builder.ForceDefaults(true); - - auto fbsConfig = config.ToFlatbuffers(builder, true); - - Serialization::Configuration::FinishHubConfigBuffer(builder, fbsConfig); - - return trySaveConfig(builder.GetBufferPointer(), builder.GetSize()); -} -// Requires the write lock. Applies `mutate` to a copy, persists the copy and only then commits it, so a failed -// write leaves the running config identical to what is on flash. `mutate` returns false to abort without saving. -// Readers keep reading the old config during the flash write. -template -static bool mutateAndSave(Fn&& mutate) -{ - OpenShock::Config::RootConfig updated = _configData; - if (!mutate(updated)) { - return false; - } - - if (!trySaveConfig(updated)) { - return false; - } - - commitConfig(std::move(updated)); - return true; -} - -void Config::Init() -{ - CONFIG_LOCK_WRITE(); - - const esp_partition_t* partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, CONFIG_PARTITION_LABEL); - if (partition == nullptr) { - OS_PANIC(TAG, "Unable to find config partition!"); - } - - if (!_configFs.mount(partition)) { - OS_PANIC(TAG, "Unable to mount config LittleFS partition!"); - } - - if (tryLoadConfig()) { - return; - } - - OS_LOGW(TAG, "Failed to load config, writing default config"); - - OpenShock::Config::RootConfig defaults; - defaults.ToDefault(); - - if (!trySaveConfig(defaults)) { - OS_PANIC(TAG, "Failed to save default config. Recommend formatting microcontroller and re-flashing firmware"); - } - - commitConfig(std::move(defaults)); -} - -std::string Config::GetAsJSON(bool withSensitiveData) -{ - CONFIG_LOCK_READ({}); - - JSON::StringWriter writer; - json_gen_str_t* gen = writer.gen(); - - _configData.ToJSON(gen, nullptr, withSensitiveData); - - return writer.finish(); -} -bool Config::SaveFromJSON(std::string_view json) -{ - JSON::JsonDocument doc; - if (!doc.parse(json)) { - OS_LOGE(TAG, "Failed to parse JSON"); - return false; - } - - CONFIG_LOCK_WRITE(false); - - // Parse into a copy and commit only once every section parsed and the result is - // stored: RootConfig::FromJSON fills in section by section, so a failure part way - // through would otherwise leave the earlier sections applied. - OpenShock::Config::RootConfig config = _configData; - if (!config.FromJSON(doc.root())) { - OS_LOGE(TAG, "Failed to read JSON"); - return false; - } - - if (!trySaveConfig(config)) { - return false; - } - - commitConfig(std::move(config)); - return true; -} - -flatbuffers::Offset Config::GetAsFlatBuffer(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) -{ - CONFIG_LOCK_READ(0); - - return _configData.ToFlatbuffers(builder, withSensitiveData); -} - -bool Config::SaveFromFlatBuffer(const Serialization::Configuration::HubConfig* config) -{ - CONFIG_LOCK_WRITE(false); - - // Same all-or-nothing handling as SaveFromJSON. - OpenShock::Config::RootConfig parsed = _configData; - if (!parsed.FromFlatbuffers(config)) { - OS_LOGE(TAG, "Failed to read config file"); - return false; - } - - if (!trySaveConfig(parsed)) { - return false; - } - - commitConfig(std::move(parsed)); - return true; -} - -bool Config::GetRaw(TinyVec& buffer) -{ - CONFIG_LOCK_READ(false); - - // Serialize the live config rather than reading flash: no filesystem access under the read lock, and it is - // what the device is actually running with. - // ForceDefaults like trySaveConfig, so a raw dump stays valid when loaded into older firmware. - flatbuffers::FlatBufferBuilder builder; - builder.ForceDefaults(true); - auto fbsConfig = _configData.ToFlatbuffers(builder, true); - Serialization::Configuration::FinishHubConfigBuffer(builder, fbsConfig); - - buffer.resize(builder.GetSize()); - memcpy(buffer.data(), builder.GetBufferPointer(), builder.GetSize()); - - return true; -} - -bool Config::SetRaw(const uint8_t* buffer, std::size_t size) -{ - CONFIG_LOCK_WRITE(false); - - OpenShock::Config::RootConfig config; - if (!tryDeserializeConfig(buffer, size, config)) { - OS_LOGE(TAG, "Failed to deserialize config"); - return false; - } - - if (!trySaveConfig(buffer, size)) { - return false; - } - - // Keep memory in sync with flash, or the next setter would write the old config back over the import. - commitConfig(std::move(config)); - return true; -} - -void Config::FactoryReset() -{ - CONFIG_LOCK_WRITE(); - - OpenShock::Config::RootConfig defaults; - defaults.ToDefault(); - - if (!_configFs.remove(CONFIG_FILE_PATH) && _configFs.exists(CONFIG_FILE_PATH)) { - OS_PANIC(TAG, "Failed to remove existing config file for factory reset. Reccomend formatting microcontroller and re-flashing firmware"); - } - - if (!trySaveConfig(defaults)) { - OS_PANIC(TAG, "Failed to save default config. Recommend formatting microcontroller and re-flashing firmware"); - } - - // Published only once persisted, like every other write - commitConfig(std::move(defaults)); - - OS_LOGI(TAG, "Factory reset complete"); -} - -std::optional Config::GetRFConfig() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.rf; -} - -std::optional Config::GetWiFiConfig() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.wifi; -} - -std::optional Config::GetCaptivePortalConfig() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.captivePortal; -} - -std::optional Config::GetBackendConfig() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.backend; -} - -std::optional Config::GetSerialInputConfig() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.serialInput; -} - -std::optional Config::GetOtaUpdateConfig() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.otaUpdate; -} - -std::optional Config::GetEStopConfig() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.estop; -} - -bool Config::SetRFConfig(const Config::RFConfig& config) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.rf = config; - return true; - }); -} - -bool Config::SetWiFiConfig(const Config::WiFiConfig& config) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.wifi = config; - return true; - }); -} - -bool Config::SetCaptivePortalConfig(const Config::CaptivePortalConfig& config) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.captivePortal = config; - return true; - }); -} - -bool Config::SetBackendConfig(const Config::BackendConfig& config) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.backend = config; - return true; - }); -} - -bool Config::SetSerialInputConfig(const Config::SerialInputConfig& config) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.serialInput = config; - return true; - }); -} - -bool Config::SetOtaUpdateConfig(const Config::OtaUpdateConfig& config) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.otaUpdate = config; - return true; - }); -} - -bool Config::UpdateOtaUpdateConfig(Util::FunctionRef update) -{ - CONFIG_LOCK_WRITE(false); - - Config::OtaUpdateConfig config = _configData.otaUpdate; - if (!update(config)) { - return true; // Unchanged - } - - return mutateAndSave([&](Config::RootConfig& root) { - root.otaUpdate = std::move(config); - return true; - }); -} - -bool Config::SetEStopConfig(const Config::EStopConfig& config) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.estop = config; - root.estop.Normalize(); - return true; - }); -} - -std::optional> Config::GetWiFiCredentials() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.wifi.credentialsList; -} - -bool Config::GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData) -{ - CONFIG_LOCK_READ(false); - - for (auto& creds : _configData.wifi.credentialsList) { - creds.ToJSON(gen, nullptr, withSensitiveData); - } - - return true; -} - -// 802.11 SSIDs are 1-32 bytes; WPA passphrases are up to 63 characters, or 64 for a raw hex PSK. -static bool isValidWiFiCredentials(std::string_view ssid, std::string_view password) -{ - if (ssid.empty() || ssid.size() > 32) { - OS_LOGE(TAG, "Invalid SSID length %zu", ssid.size()); - return false; - } - if (password.size() > 64) { - OS_LOGE(TAG, "Invalid password length %zu", password.size()); - return false; - } - return true; -} - -bool Config::SetWiFiCredentials(const std::vector& credentials) -{ - std::bitset<256> seenIds; - for (const auto& creds : credentials) { - if (creds.id == 0) { - OS_LOGE(TAG, "Cannot set WiFi credentials: credential ID cannot be 0"); - return false; - } - if (seenIds[creds.id]) { - OS_LOGE(TAG, "Cannot set WiFi credentials: duplicate credential ID %u", creds.id); - return false; - } - seenIds[creds.id] = true; - - if (!isValidWiFiCredentials(creds.ssid, creds.password)) { - return false; - } - } - - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.wifi.credentialsList = credentials; - return true; - }); -} - -std::optional Config::GetRFConfigTxPin() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.rf.txPin; -} - -bool Config::SetRFConfigTxPin(gpio_num_t txPin) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.rf.txPin = txPin; - return true; - }); -} - -std::optional Config::GetRFConfigKeepAliveEnabled() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.rf.keepAliveEnabled; -} - -bool Config::SetRFConfigKeepAliveEnabled(bool enabled) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.rf.keepAliveEnabled = enabled; - return true; - }); -} - -bool Config::AnyWiFiCredentials(Util::FunctionRef predicate) -{ - // The predicate is caller code, so run it on a copy outside the lock - auto creds = GetWiFiCredentials(); - if (!creds) { - return false; - } - - return std::any_of(creds->begin(), creds->end(), predicate); -} - -uint8_t Config::AddWiFiCredentials(std::string_view ssid, std::string_view password, wifi_auth_mode_t authMode) -{ - if (!isValidWiFiCredentials(ssid, password)) { - return 0; - } - - CONFIG_LOCK_WRITE(0); - - // Built on a copy and committed only once on flash, like mutateAndSave - OpenShock::Config::RootConfig updated = _configData; - auto& list = updated.wifi.credentialsList; - - uint8_t id = 0; - - std::bitset<255> bits; - for (auto it = list.begin(); it != list.end();) { - auto& creds = *it; - - if (std::string_view(creds.ssid) == ssid) { - creds.password = password; - if (authMode != WIFI_AUTH_MAX) { - creds.authMode = authMode; - } - - if (!trySaveConfig(updated)) { - OS_LOGE(TAG, "Failed to persist updated WiFi credentials for SSID %.*s", static_cast(ssid.size()), ssid.data()); - return 0; - } - - id = creds.id; - commitConfig(std::move(updated)); - return id; - } - - if (creds.id == 0) { - OS_LOGW(TAG, "Found WiFi credentials with ID 0, removing"); - it = list.erase(it); - continue; - } - - // Mark ID as used - bits[creds.id - 1] = true; - ++it; - } - - // Get first available ID - for (std::size_t i = 0; i < bits.size(); ++i) { - if (!bits[i]) { - id = i + 1; - break; - } - } - - if (id == 0) { - OS_LOGE(TAG, "Failed to add WiFi credentials: no available IDs"); - return 0; - } - - list.emplace_back(id, ssid, password, authMode); - - // Only keep credentials that made it to flash. This is also what caps the list: - // once the config would outgrow MaxConfigSize, trySaveConfig refuses it. - if (!trySaveConfig(updated)) { - OS_LOGE(TAG, "Failed to persist new WiFi credentials for SSID %.*s", static_cast(ssid.size()), ssid.data()); - return 0; - } - - commitConfig(std::move(updated)); - return id; -} - -std::optional Config::TryGetWiFiCredentialsByID(uint8_t id) -{ - CONFIG_LOCK_READ(std::nullopt); - - for (const auto& creds : _configData.wifi.credentialsList) { - if (creds.id == id) { - return creds; - } - } - - return std::nullopt; -} - -std::optional Config::TryGetWiFiCredentialsBySSID(const char* ssid) -{ - CONFIG_LOCK_READ(std::nullopt); - - for (const auto& creds : _configData.wifi.credentialsList) { - if (creds.ssid == ssid) { - return creds; - } - } - - return std::nullopt; -} - -uint8_t Config::GetWiFiCredentialsIDbySSID(const char* ssid) -{ - CONFIG_LOCK_READ(0); - - for (const auto& creds : _configData.wifi.credentialsList) { - if (creds.ssid == ssid) { - return creds.id; - } - } - - return 0; -} - -bool Config::PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - for (auto& creds : root.wifi.credentialsList) { - if (creds.id == id) { - memcpy(creds.bssid.data(), bssid, 6); - return true; - } - } - return false; - }); -} - -bool Config::RemoveWiFiCredentials(uint8_t id) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([id](Config::RootConfig& root) { - auto& list = root.wifi.credentialsList; - auto it = std::find_if(list.begin(), list.end(), [id](const Config::WiFiCredentials& creds) { return creds.id == id; }); - if (it == list.end()) { - return false; - } - list.erase(it); - return true; - }); -} - -bool Config::ClearWiFiCredentials() -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([](Config::RootConfig& root) { - root.wifi.credentialsList.clear(); - return true; - }); -} - -std::optional Config::GetWiFiHostname() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.wifi.hostname; -} - -bool Config::SetWiFiHostname(std::string hostname) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.wifi.hostname = std::move(hostname); - return true; - }); -} - -std::optional Config::GetBackendDomain() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.backend.domain; -} - -bool Config::SetBackendDomain(std::string domain) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.backend.domain = std::move(domain); - return true; - }); -} - -bool Config::HasBackendAuthToken() -{ - CONFIG_LOCK_READ(false); - - return !_configData.backend.authToken.empty(); -} - -std::optional Config::GetBackendAuthToken() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.backend.authToken; -} - -bool Config::SetBackendAuthToken(std::string token) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.backend.authToken = std::move(token); - return true; - }); -} - -bool Config::ClearBackendAuthToken() -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([](Config::RootConfig& root) { - root.backend.authToken.clear(); - return true; - }); -} - -std::optional Config::GetSerialInputConfigEchoEnabled() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.serialInput.echoEnabled; -} - -bool Config::SetSerialInputConfigEchoEnabled(bool enabled) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.serialInput.echoEnabled = enabled; - return true; - }); -} - -std::optional Config::GetOtaUpdateId() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.otaUpdate.updateId; -} - -bool Config::SetOtaUpdateId(int32_t updateId) -{ - CONFIG_LOCK_WRITE(false); - - if (_configData.otaUpdate.updateId == updateId) { - return true; - } - - return mutateAndSave([&](Config::RootConfig& root) { - root.otaUpdate.updateId = updateId; - return true; - }); -} - -std::optional Config::GetOtaUpdateStep() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.otaUpdate.updateStep; -} - -bool Config::CompareExchangeOtaUpdateStep(OtaUpdateStep expected, OtaUpdateStep desired) -{ - CONFIG_LOCK_WRITE(false); - - // Writers are serialized by the write lock, so the step can't change between this check and the save - if (_configData.otaUpdate.updateStep != expected) { - return false; - } - if (expected == desired) { - return true; - } - - return mutateAndSave([&](Config::RootConfig& root) { - root.otaUpdate.updateStep = desired; - return true; - }); -} - -bool Config::SetOtaUpdateStep(OtaUpdateStep updateStep) -{ - CONFIG_LOCK_WRITE(false); - - if (_configData.otaUpdate.updateStep == updateStep) { - return true; - } - - return mutateAndSave([&](Config::RootConfig& root) { - root.otaUpdate.updateStep = updateStep; - return true; - }); -} - -std::optional Config::GetEStopEnabled() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.estop.enabled; -} - -bool Config::SetEStopEnabled(bool enabled) -{ - CONFIG_LOCK_WRITE(false); - - return mutateAndSave([&](Config::RootConfig& root) { - root.estop.enabled = enabled; - return true; - }); -} - -std::optional Config::GetEStopGpioPin() -{ - CONFIG_LOCK_READ(std::nullopt); - - return _configData.estop.gpioPin; -} - -bool Config::SetEStopGpioPin(gpio_num_t gpioPin) -{ - CONFIG_LOCK_WRITE(false); - - if (!OpenShock::IsValidInputPin(gpioPin)) { - OS_LOGE(TAG, "Invalid EStop GPIO Pin: %d", gpioPin); - return false; - } - - return mutateAndSave([&](Config::RootConfig& root) { - root.estop.gpioPin = gpioPin; - return true; - }); -} diff --git a/components/config/src/EStopConfig.cpp b/components/config/src/EStopConfig.cpp deleted file mode 100644 index 98ed9e736..000000000 --- a/components/config/src/EStopConfig.cpp +++ /dev/null @@ -1,102 +0,0 @@ -#include "config/EStopConfig.h" - -#include "BoardDescriptor.h" -#include "Chipset.h" -#include "config/internal/utils.h" -#include "Logging.h" - -const char* const TAG = "Config::EStopConfig"; - -using namespace OpenShock::Config; - -EStopConfig::EStopConfig() - : enabled(OpenShock::IsValidInputPin(Board::Get().estopGpio)) - , gpioPin(static_cast(Board::Get().estopGpio)) -{ -} - -EStopConfig::EStopConfig(bool enabled, gpio_num_t gpioPin) - : enabled(enabled) - , gpioPin(gpioPin) -{ -} - -void EStopConfig::ToDefault() -{ - enabled = OpenShock::IsValidInputPin(Board::Get().estopGpio); - gpioPin = static_cast(Board::Get().estopGpio); -} - -bool EStopConfig::FromFlatbuffers(const Serialization::Configuration::EStopConfig* config) -{ - if (config == nullptr) { - ToDefault(); // Set to default if config is null - return true; - } - - // gpio_pin is a raw int8 from flash or rawconfig; anything that isn't "no pin" or a real GPIO means a - // corrupt section, so fall back to the default. - auto pin = Internal::Utils::FromIntGpioNum(config->gpio_pin()); - if (!pin) { - OS_LOGW(TAG, "Invalid E-Stop GPIO pin %d, using default", config->gpio_pin()); - ToDefault(); - return true; - } - - gpioPin = *pin; - enabled = config->enabled(); - Normalize(); - - return true; -} - -flatbuffers::Offset EStopConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - return Serialization::Configuration::CreateEStopConfig(builder, enabled, gpioPin); -} - -bool EStopConfig::FromJSON(JSON::JsonView json) -{ - if (!json.valid()) { - ToDefault(); // Set to default if config is null - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - gpioPin = Internal::Utils::FromJsonGpioNum(json, "gpioPin").value_or(static_cast(Board::Get().estopGpio)); - - if (JSON::JsonView enabledJson = json["enabled"]; enabledJson.valid()) { - auto parsed = enabledJson.tryGetBool(); - if (!parsed) { - OS_LOGE(TAG, "Failed to parse enabled"); - return false; - } - enabled = *parsed; - } else { - enabled = OpenShock::IsValidInputPin(gpioPin); - } - Normalize(); - - return true; -} - -void EStopConfig::Normalize() -{ - // An E-Stop on a pin that can't be read as an input would never trigger; store it as disabled on every path. - if (enabled && !OpenShock::IsValidInputPin(gpioPin)) { - OS_LOGW(TAG, "E-Stop pin %d is not a valid input pin, disabling E-Stop", gpioPin); - enabled = false; - } -} - -void EStopConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const -{ - JSON::objBegin(gen, name); - json_gen_obj_set_bool(gen, "enabled", enabled); - json_gen_obj_set_int(gen, "gpioPin", gpioPin); - JSON::objEnd(gen, name); -} diff --git a/components/config/src/Migration.cpp b/components/config/src/Migration.cpp new file mode 100644 index 000000000..699040fcf --- /dev/null +++ b/components/config/src/Migration.cpp @@ -0,0 +1,99 @@ +#include + +#include "config/Migration.h" + +const char* const TAG = "ConfigMigration"; + +#include "config/Section.h" +#include "config/Store.h" +#include "Logging.h" +#include "partition_layout/LiveTable.h" +#include "partition_layout/PartitionTable.h" +#include "SimpleMutex.h" + +#include +#include +#include +#include + +#include +#include + +using namespace OpenShock; +namespace PT = OpenShock::PartitionTable; + +namespace { + // The WiFi driver's own store from the Arduino builds: one STA profile, with the password in plain text. Nothing + // reads it any more (CONFIG_ESP_WIFI_NVS_ENABLED=n). + constexpr const char* kWifiDriverNamespace = "nvs.net80211"; + + void dropWifiDriverStore() + { + nvs_handle_t handle = 0; + if (nvs_open_from_partition("nvs", kWifiDriverNamespace, NVS_READWRITE, &handle) != ESP_OK) { + return; // Not there: never written, or already dropped + } + if (nvs_erase_all(handle) == ESP_OK && nvs_commit(handle) == ESP_OK) { + OS_LOGI(TAG, "Dropped the WiFi driver's stale NVS store (%s)", kWifiDriverNamespace); + } + nvs_close(handle); + } +} // namespace + +Config::MigrationResult Config::MigrateToCfgPartition() +{ + // A raw table and a parsed one come to some 6.5 KiB: on the heap, not the caller's stack + struct Tables { + PT::Bytes live; + PT::Bytes next; + PT::Table parsed; + }; + auto tables = std::make_unique(); + + if (!PT::ReadLiveBytes(tables->live) || !PT::Parse(tables->live, tables->parsed)) { + OS_LOGE(TAG, "Cannot read the partition table; not migrating"); + return MigrationResult::Refused; + } + if (tables->parsed.Find("cfg") != nullptr) { + return MigrationResult::NotNeeded; + } + const PT::Entry* config = tables->parsed.Find("config"); + if (config == nullptr || config->type != PT::kTypeData || config->subtype != PT::kSubtypeDataSpiffs) { + OS_LOGE(TAG, "No 1.x littlefs config partition; not migrating"); + return MigrationResult::Refused; + } + // The retype hands the partition to NVS, and the next boot erases what littlefs left in it + const char* store = StorageLabel(); + if (store == nullptr || strcmp(store, "nvs") != 0) { + OS_LOGE(TAG, "The config is not in the shared nvs partition; retyping config would lose it"); + return MigrationResult::Refused; + } + + tables->next = tables->live; + if (auto retyped = PT::Retype(tables->next, "config", PT::kTypeData, PT::kSubtypeDataNvs, "cfg"); !retyped) { + OS_LOGE(TAG, "Cannot retype the config partition: %s", PT::RetypeErrorName(retyped.error())); + return MigrationResult::Refused; + } + + // Quiesce: no config write can start and NVS is closed + OS_LOGW(TAG, "Retyping the config partition as cfg. Do not unplug the hub."); + ScopedLock configLock(&Internal::WriteLock()); + dropWifiDriverStore(); + nvs_flash_deinit(); + vTaskDelay(pdMS_TO_TICKS(100)); + + switch (PT::Replace(tables->next, tables->live)) { + case PT::ReplaceResult::Written: + OS_LOGI(TAG, "Partition table rewritten; restarting into the cfg config store"); + break; + case PT::ReplaceResult::Restored: + OS_LOGE(TAG, "The partition table is the old one again; restarting on it"); + break; + case PT::ReplaceResult::Untouched: + OS_LOGE(TAG, "The partition table was not touched; restarting on it"); + break; + } + // NVS is closed: a restart is the one clean way back to a running hub + esp_partition_unload_all(); + esp_restart(); +} diff --git a/components/config/src/Nvs.cpp b/components/config/src/Nvs.cpp new file mode 100644 index 000000000..78f5f2615 --- /dev/null +++ b/components/config/src/Nvs.cpp @@ -0,0 +1,483 @@ +#include "config/Nvs.h" + +const char* const TAG = "Config::Nvs"; + +#include "Logging.h" + +#include +#include +#include +#include + +using namespace OpenShock::Config; + +std::optional Nvs::Handle::Open(const char* partition, const char* ns, bool write) +{ + nvs_handle_t handle = 0; + esp_err_t err = nvs_open_from_partition(partition, ns, write ? NVS_READWRITE : NVS_READONLY, &handle); + if (err == ESP_OK) { + return Handle(handle); + } + if (err == ESP_ERR_NVS_NOT_FOUND && !write) { + return std::nullopt; // The namespace was never written: every key is a default + } + OS_LOGE(TAG, "Failed to open %s/%s: %s", partition, ns, esp_err_to_name(err)); + return std::nullopt; +} + +Nvs::Handle::Handle(Handle&& other) noexcept + : m_handle(std::exchange(other.m_handle, 0)) +{ +} + +Nvs::Handle& Nvs::Handle::operator=(Handle&& other) noexcept +{ + if (this != &other) { + if (m_handle != 0) { + nvs_close(m_handle); + } + m_handle = std::exchange(other.m_handle, 0); + } + return *this; +} + +Nvs::Handle::~Handle() +{ + if (m_handle != 0) { + nvs_close(m_handle); + } +} + +namespace { + Nvs::Result toResult(esp_err_t err, const char* key) + { + if (err == ESP_OK) { + return Nvs::Result::Found; + } + if (err == ESP_ERR_NVS_NOT_FOUND) { + return Nvs::Result::Missing; + } + OS_LOGE(TAG, "Failed to read %s: %s", key, esp_err_to_name(err)); + return Nvs::Result::Error; + } + + bool checkWrite(esp_err_t err, const char* key) + { + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to write %s: %s", key, esp_err_to_name(err)); + return false; + } + return true; + } +} // namespace + +Nvs::Result Nvs::Handle::GetU8(const char* key, uint8_t& out) const +{ + return toResult(nvs_get_u8(m_handle, key, &out), key); +} +Nvs::Result Nvs::Handle::GetI8(const char* key, int8_t& out) const +{ + return toResult(nvs_get_i8(m_handle, key, &out), key); +} +Nvs::Result Nvs::Handle::GetU16(const char* key, uint16_t& out) const +{ + return toResult(nvs_get_u16(m_handle, key, &out), key); +} +Nvs::Result Nvs::Handle::GetI32(const char* key, int32_t& out) const +{ + return toResult(nvs_get_i32(m_handle, key, &out), key); +} + +Nvs::Result Nvs::Handle::GetStr(const char* key, std::string& out) const +{ + size_t length = 0; + Result result = toResult(nvs_get_str(m_handle, key, nullptr, &length), key); + if (result != Result::Found) { + return result; + } + std::string value(length, '\0'); // length includes the NUL + result = toResult(nvs_get_str(m_handle, key, value.data(), &length), key); + if (result != Result::Found) { + return result; + } + value.resize(length > 0 ? length - 1 : 0); + out = std::move(value); + return Result::Found; +} + +Nvs::Result Nvs::Handle::GetBlob(const char* key, std::vector& out) const +{ + size_t length = 0; + Result result = toResult(nvs_get_blob(m_handle, key, nullptr, &length), key); + if (result != Result::Found) { + return result; + } + std::vector value(length); + if (length > 0) { + result = toResult(nvs_get_blob(m_handle, key, value.data(), &length), key); + if (result != Result::Found) { + return result; + } + } + out = std::move(value); + return Result::Found; +} + +bool Nvs::Handle::SetU8(const char* key, uint8_t value, const uint8_t* previous) +{ + if (previous != nullptr && *previous == value) return true; + return checkWrite(nvs_set_u8(m_handle, key, value), key); +} +bool Nvs::Handle::SetBool(const char* key, bool value, const bool* previous) +{ + if (previous != nullptr && *previous == value) return true; + return checkWrite(nvs_set_u8(m_handle, key, value ? 1 : 0), key); +} +bool Nvs::Handle::SetI8(const char* key, int8_t value, const int8_t* previous) +{ + if (previous != nullptr && *previous == value) return true; + return checkWrite(nvs_set_i8(m_handle, key, value), key); +} +bool Nvs::Handle::SetU16(const char* key, uint16_t value, const uint16_t* previous) +{ + if (previous != nullptr && *previous == value) return true; + return checkWrite(nvs_set_u16(m_handle, key, value), key); +} +bool Nvs::Handle::SetI32(const char* key, int32_t value, const int32_t* previous) +{ + if (previous != nullptr && *previous == value) return true; + return checkWrite(nvs_set_i32(m_handle, key, value), key); +} +bool Nvs::Handle::SetStr(const char* key, std::string_view value, const std::string* previous) +{ + if (previous != nullptr && *previous == value) return true; + // nvs_set_str wants a C string: a value with an embedded NUL would be silently cut, so refuse it + if (value.find('\0') != std::string_view::npos) { + OS_LOGE(TAG, "Refusing to store %s: embedded NUL", key); + return false; + } + std::string copy(value); + return checkWrite(nvs_set_str(m_handle, key, copy.c_str()), key); +} +bool Nvs::Handle::SetBlob(const char* key, std::span value) +{ + return checkWrite(nvs_set_blob(m_handle, key, value.data(), value.size()), key); +} + +bool Nvs::Handle::Erase(const char* key) +{ + esp_err_t err = nvs_erase_key(m_handle, key); + if (err == ESP_ERR_NVS_NOT_FOUND) { + return true; + } + return checkWrite(err, key); +} +bool Nvs::Handle::EraseAll() +{ + return checkWrite(nvs_erase_all(m_handle), "*"); +} +bool Nvs::Handle::Commit() +{ + return checkWrite(nvs_commit(m_handle), "commit"); +} + +namespace { + template + bool loadWith(Getter getter, const char* key, T& out, T defaultValue) + { + T value {}; + switch (getter(key, value)) { + case Nvs::Result::Found: + out = value; + return true; + case Nvs::Result::Missing: + out = defaultValue; + return true; + case Nvs::Result::Error: + return false; + } + return false; + } +} // namespace + +bool Nvs::Load(const Handle& handle, const char* key, bool& out, bool defaultValue) +{ + uint8_t value = 0; + switch (handle.GetU8(key, value)) { + case Result::Found: + out = value != 0; + return true; + case Result::Missing: + out = defaultValue; + return true; + case Result::Error: + return false; + } + return false; +} +bool Nvs::Load(const Handle& handle, const char* key, uint8_t& out, uint8_t defaultValue) +{ + return loadWith([&](const char* k, uint8_t& v) { return handle.GetU8(k, v); }, key, out, defaultValue); +} +bool Nvs::Load(const Handle& handle, const char* key, int8_t& out, int8_t defaultValue) +{ + return loadWith([&](const char* k, int8_t& v) { return handle.GetI8(k, v); }, key, out, defaultValue); +} +bool Nvs::Load(const Handle& handle, const char* key, uint16_t& out, uint16_t defaultValue) +{ + return loadWith([&](const char* k, uint16_t& v) { return handle.GetU16(k, v); }, key, out, defaultValue); +} +bool Nvs::Load(const Handle& handle, const char* key, int32_t& out, int32_t defaultValue) +{ + return loadWith([&](const char* k, int32_t& v) { return handle.GetI32(k, v); }, key, out, defaultValue); +} +bool Nvs::Load(const Handle& handle, const char* key, std::string& out, std::string_view defaultValue) +{ + std::string value; + switch (handle.GetStr(key, value)) { + case Result::Found: + out = std::move(value); + return true; + case Result::Missing: + out = defaultValue; + return true; + case Result::Error: + return false; + } + return false; +} + +bool Nvs::NamespaceExists(const char* partition, const char* ns) +{ + nvs_handle_t handle = 0; + esp_err_t err = nvs_open_from_partition(partition, ns, NVS_READONLY, &handle); + if (err == ESP_OK) { + nvs_close(handle); + return true; + } + return false; +} + +std::size_t Nvs::KeyCount(const char* partition, const char* ns) +{ + auto handle = Handle::Open(partition, ns, false); + if (!handle) { + return 0; + } + std::size_t count = 0; + handle->ForEachKey(NVS_TYPE_ANY, [&](const char*) { ++count; }); + return count; +} + +bool Nvs::EraseNamespace(const char* partition, const char* ns) +{ + nvs_handle_t handle = 0; + esp_err_t err = nvs_open_from_partition(partition, ns, NVS_READWRITE, &handle); + if (err == ESP_ERR_NVS_NOT_FOUND) { + return true; + } + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to open %s/%s for erase: %s", partition, ns, esp_err_to_name(err)); + return false; + } + err = nvs_erase_all(handle); + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to erase %s/%s: %s", partition, ns, esp_err_to_name(err)); + return false; + } + return true; +} + +namespace { + // One entry of any type, read into a byte image so two can be compared and one can be written back. + struct RawEntry { + nvs_type_t type; + std::vector data; // integers little-endian, strings with their NUL, blobs as they are + }; + + bool readRaw(nvs_handle_t handle, const char* key, nvs_type_t type, RawEntry& out) + { + out.type = type; + esp_err_t err = ESP_OK; + switch (type) { + case NVS_TYPE_U8: + case NVS_TYPE_I8: + out.data.resize(1); + err = type == NVS_TYPE_U8 ? nvs_get_u8(handle, key, out.data.data()) : nvs_get_i8(handle, key, reinterpret_cast(out.data.data())); + break; + case NVS_TYPE_U16: + case NVS_TYPE_I16: + out.data.resize(2); + err = type == NVS_TYPE_U16 ? nvs_get_u16(handle, key, reinterpret_cast(out.data.data())) : nvs_get_i16(handle, key, reinterpret_cast(out.data.data())); + break; + case NVS_TYPE_U32: + case NVS_TYPE_I32: + out.data.resize(4); + err = type == NVS_TYPE_U32 ? nvs_get_u32(handle, key, reinterpret_cast(out.data.data())) : nvs_get_i32(handle, key, reinterpret_cast(out.data.data())); + break; + case NVS_TYPE_U64: + case NVS_TYPE_I64: + out.data.resize(8); + err = type == NVS_TYPE_U64 ? nvs_get_u64(handle, key, reinterpret_cast(out.data.data())) : nvs_get_i64(handle, key, reinterpret_cast(out.data.data())); + break; + case NVS_TYPE_STR: + { + size_t length = 0; + err = nvs_get_str(handle, key, nullptr, &length); + if (err == ESP_OK) { + out.data.resize(length); + err = nvs_get_str(handle, key, reinterpret_cast(out.data.data()), &length); + } + break; + } + case NVS_TYPE_BLOB: + { + size_t length = 0; + err = nvs_get_blob(handle, key, nullptr, &length); + if (err == ESP_OK) { + out.data.resize(length); + err = length == 0 ? ESP_OK : nvs_get_blob(handle, key, out.data.data(), &length); + } + break; + } + default: + OS_LOGE(TAG, "Key %s has an unknown type 0x%02x", key, type); + return false; + } + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to read %s: %s", key, esp_err_to_name(err)); + return false; + } + return true; + } + + bool writeRaw(nvs_handle_t handle, const char* key, const RawEntry& entry) + { + const void* p = entry.data.data(); + esp_err_t err = ESP_OK; + switch (entry.type) { + case NVS_TYPE_U8: + err = nvs_set_u8(handle, key, *static_cast(p)); + break; + case NVS_TYPE_I8: + err = nvs_set_i8(handle, key, *static_cast(p)); + break; + case NVS_TYPE_U16: + err = nvs_set_u16(handle, key, *static_cast(p)); + break; + case NVS_TYPE_I16: + err = nvs_set_i16(handle, key, *static_cast(p)); + break; + case NVS_TYPE_U32: + err = nvs_set_u32(handle, key, *static_cast(p)); + break; + case NVS_TYPE_I32: + err = nvs_set_i32(handle, key, *static_cast(p)); + break; + case NVS_TYPE_U64: + err = nvs_set_u64(handle, key, *static_cast(p)); + break; + case NVS_TYPE_I64: + err = nvs_set_i64(handle, key, *static_cast(p)); + break; + case NVS_TYPE_STR: + err = nvs_set_str(handle, key, static_cast(p)); + break; + case NVS_TYPE_BLOB: + err = nvs_set_blob(handle, key, p, entry.data.size()); + break; + default: + return false; + } + return checkWrite(err, key); + } + + // Every key of `ns` in `partition` with its type. False when the namespace cannot be enumerated. + bool listKeys(const char* partition, const char* ns, std::vector>& out) + { + out.clear(); + nvs_iterator_t it = nullptr; + esp_err_t err = nvs_entry_find(partition, ns, NVS_TYPE_ANY, &it); + while (err == ESP_OK) { + nvs_entry_info_t info; + nvs_entry_info(it, &info); + out.emplace_back(info.key, info.type); + err = nvs_entry_next(&it); + } + nvs_release_iterator(it); + return err == ESP_ERR_NVS_NOT_FOUND; + } +} // namespace + +bool Nvs::CopyNamespace(const char* from, const char* to, const char* ns) +{ + std::vector> keys; + if (!listKeys(from, ns, keys)) { + OS_LOGE(TAG, "Failed to enumerate %s/%s", from, ns); + return false; + } + if (keys.empty()) { + return true; + } + + nvs_handle_t src = 0, dst = 0; + if (nvs_open_from_partition(from, ns, NVS_READONLY, &src) != ESP_OK) { + return false; + } + if (nvs_open_from_partition(to, ns, NVS_READWRITE, &dst) != ESP_OK) { + nvs_close(src); + return false; + } + bool ok = true; + for (const auto& [key, type] : keys) { + RawEntry entry; + if (!readRaw(src, key.c_str(), type, entry) || !writeRaw(dst, key.c_str(), entry)) { + ok = false; + break; + } + } + ok = ok && checkWrite(nvs_commit(dst), "commit"); + nvs_close(dst); + nvs_close(src); + return ok; +} + +bool Nvs::NamespacesEqual(const char* a, const char* b, const char* ns) +{ + std::vector> keysA, keysB; + if (!listKeys(a, ns, keysA) || !listKeys(b, ns, keysB)) { + return false; + } + std::sort(keysA.begin(), keysA.end()); + std::sort(keysB.begin(), keysB.end()); + if (keysA != keysB) { + return false; + } + if (keysA.empty()) { + return true; + } + + nvs_handle_t ha = 0, hb = 0; + if (nvs_open_from_partition(a, ns, NVS_READONLY, &ha) != ESP_OK) { + return false; + } + if (nvs_open_from_partition(b, ns, NVS_READONLY, &hb) != ESP_OK) { + nvs_close(ha); + return false; + } + bool equal = true; + for (const auto& [key, type] : keysA) { + RawEntry ea, eb; + if (!readRaw(ha, key.c_str(), type, ea) || !readRaw(hb, key.c_str(), type, eb) || ea.data != eb.data) { + equal = false; + break; + } + } + nvs_close(hb); + nvs_close(ha); + return equal; +} diff --git a/components/config/src/OtaUpdateConfig.cpp b/components/config/src/OtaUpdateConfig.cpp deleted file mode 100644 index ee5b96f04..000000000 --- a/components/config/src/OtaUpdateConfig.cpp +++ /dev/null @@ -1,151 +0,0 @@ -#include "config/OtaUpdateConfig.h" - -const char* const TAG = "Config::OtaUpdateConfig"; - -#include "config/internal/utils.h" -#include "Logging.h" - -#include - -using namespace OpenShock::Config; -using namespace std::string_view_literals; - -OtaUpdateConfig::OtaUpdateConfig() - : isEnabled(true) - , cdnDomain(CONFIG_OPENSHOCK_FW_CDN_DOMAIN) - , updateChannel(OtaUpdateChannel::Stable) - , checkOnStartup(false) - , checkPeriodically(false) - , checkInterval(30) - , allowBackendManagement(true) - , requireManualApproval(false) - , updateId(0) - , updateStep(OtaUpdateStep::None) -{ -} - -OtaUpdateConfig::OtaUpdateConfig( - bool isEnabled, std::string cdnDomain, OtaUpdateChannel updateChannel, bool checkOnStartup, bool checkPeriodically, uint16_t checkInterval, bool allowBackendManagement, bool requireManualApproval, int32_t updateId, OtaUpdateStep updateStep -) - : isEnabled(isEnabled) - , cdnDomain(std::move(cdnDomain)) - , updateChannel(updateChannel) - , checkOnStartup(checkOnStartup) - , checkPeriodically(checkPeriodically) - , checkInterval(checkInterval) - , allowBackendManagement(allowBackendManagement) - , requireManualApproval(requireManualApproval) - , updateId(updateId) - , updateStep(updateStep) -{ -} - -void OtaUpdateConfig::ToDefault() -{ - isEnabled = true; - cdnDomain = CONFIG_OPENSHOCK_FW_CDN_DOMAIN; - updateChannel = OtaUpdateChannel::Stable; - checkOnStartup = false; - checkPeriodically = false; - checkInterval = 30; // 30 minutes - allowBackendManagement = true; - requireManualApproval = false; - updateId = 0; - updateStep = OtaUpdateStep::None; -} - -bool OtaUpdateConfig::FromFlatbuffers(const Serialization::Configuration::OtaUpdateConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - isEnabled = config->is_enabled(); - Internal::Utils::FromFbsStr(cdnDomain, config->cdn_domain(), CONFIG_OPENSHOCK_FW_CDN_DOMAIN); - // Out-of-range enum values (bit flips, configs from newer firmware) fall back to the defaults - auto fbsChannel = std::to_underlying(config->update_channel()); - updateChannel = fbsChannel <= std::to_underlying(OtaUpdateChannel::Develop) ? static_cast(fbsChannel) : OtaUpdateChannel::Stable; - checkOnStartup = config->check_on_startup(); - checkPeriodically = config->check_periodically(); - checkInterval = config->check_interval(); - allowBackendManagement = config->allow_backend_management(); - requireManualApproval = config->require_manual_approval(); - updateId = config->update_id(); - auto fbsStep = std::to_underlying(config->update_step()); - updateStep = fbsStep <= std::to_underlying(OtaUpdateStep::RollingBack) ? static_cast(fbsStep) : OtaUpdateStep::None; - - return true; -} - -flatbuffers::Offset OtaUpdateConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - namespace Fbs = Serialization::Configuration; - - auto cdnDomainOffset = builder.CreateString(cdnDomain); - - // Same field order as CreateOtaUpdateConfig, except that is_enabled and - // allow_backend_management are stored even when they equal the schema default, so - // they read back the same under every schema version (up to 1.5.x both defaulted - // to false) and RootConfig::FromFlatbuffers never has to infer them. - Fbs::OtaUpdateConfigBuilder otaBuilder(builder); - otaBuilder.add_update_id(updateId); - otaBuilder.add_cdn_domain(cdnDomainOffset); - otaBuilder.add_check_interval(checkInterval); - otaBuilder.add_update_step(static_cast(updateStep)); - otaBuilder.add_require_manual_approval(requireManualApproval); - builder.AddElement(Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT, static_cast(allowBackendManagement)); - otaBuilder.add_check_periodically(checkPeriodically); - otaBuilder.add_check_on_startup(checkOnStartup); - otaBuilder.add_update_channel(static_cast(updateChannel)); - builder.AddElement(Fbs::OtaUpdateConfig::VT_IS_ENABLED, static_cast(isEnabled)); - return otaBuilder.Finish(); -} - -bool OtaUpdateConfig::FromJSON(JSON::JsonView json) -{ - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - isEnabled = json["isEnabled"].tryGetBool().value_or(true); - - cdnDomain = json["cdnDomain"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_CDN_DOMAIN); - - Internal::Utils::FromJsonStrParsed(updateChannel, json, "updateChannel"sv, OpenShock::TryParseOtaUpdateChannel, OpenShock::OtaUpdateChannel::Stable); - - checkOnStartup = json["checkOnStartup"].tryGetBool().value_or(false); - checkPeriodically = json["checkPeriodically"].tryGetBool().value_or(false); - checkInterval = json["checkInterval"].tryGetU16().value_or(30); - allowBackendManagement = json["allowBackendManagement"].tryGetBool().value_or(true); - requireManualApproval = json["requireManualApproval"].tryGetBool().value_or(false); - updateId = json["updateId"].tryGetI32().value_or(0); - - Internal::Utils::FromJsonStrParsed(updateStep, json, "updateStep"sv, OpenShock::TryParseOtaUpdateStep, OpenShock::OtaUpdateStep::None); - - return true; -} - -void OtaUpdateConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const -{ - JSON::objBegin(gen, name); - json_gen_obj_set_bool(gen, "isEnabled", isEnabled); - JSON::objSetString(gen, "cdnDomain", cdnDomain); - JSON::objSetString(gen, "updateChannel", OpenShock::Serialization::Configuration::EnumNameOtaUpdateChannel(static_cast(updateChannel))); - json_gen_obj_set_bool(gen, "checkOnStartup", checkOnStartup); - json_gen_obj_set_bool(gen, "checkPeriodically", checkPeriodically); - json_gen_obj_set_int(gen, "checkInterval", checkInterval); - json_gen_obj_set_bool(gen, "allowBackendManagement", allowBackendManagement); - json_gen_obj_set_bool(gen, "requireManualApproval", requireManualApproval); - json_gen_obj_set_int(gen, "updateId", updateId); - JSON::objSetString(gen, "updateStep", OpenShock::Serialization::Configuration::EnumNameOtaUpdateStep(static_cast(updateStep))); - JSON::objEnd(gen, name); -} diff --git a/components/config/src/Parse.cpp b/components/config/src/Parse.cpp new file mode 100644 index 000000000..f86cc8f00 --- /dev/null +++ b/components/config/src/Parse.cpp @@ -0,0 +1,34 @@ +#include "config/Parse.h" + +const char* const TAG = "Config::Parse"; + +#include "Logging.h" + +#include +#include + +using namespace OpenShock; + +std::optional Config::Parse::GpioNum(int32_t intVal) +{ + if (intVal == GPIO_NUM_NC) { + return GPIO_NUM_NC; + } + + if (intVal < 0 || intVal >= GPIO_NUM_MAX || !GPIO_IS_VALID_GPIO(intVal)) { + OS_LOGE(TAG, "invalid GPIO number %" PRId32, intVal); + return std::nullopt; + } + + return static_cast(intVal); +} + +std::optional Config::Parse::JsonGpioNum(JSON::JsonView json, std::string_view name) +{ + auto intVal = json[name].tryGetI32(); + if (!intVal) { + return std::nullopt; + } + + return GpioNum(*intVal); +} diff --git a/components/config/src/RFConfig.cpp b/components/config/src/RFConfig.cpp deleted file mode 100644 index 3c6176127..000000000 --- a/components/config/src/RFConfig.cpp +++ /dev/null @@ -1,90 +0,0 @@ -#include "config/RFConfig.h" - -const char* const TAG = "Config::RFConfig"; - -#include "BoardDescriptor.h" -#include "config/internal/utils.h" -#include "Logging.h" - -using namespace OpenShock::Config; - -RFConfig::RFConfig() - : txPin(static_cast(Board::Get().rfTxGpio)) - , keepAliveEnabled(true) -{ -} - -RFConfig::RFConfig(gpio_num_t txPin, bool keepAliveEnabled) - : txPin(txPin) - , keepAliveEnabled(keepAliveEnabled) -{ -} - -void RFConfig::ToDefault() -{ - txPin = static_cast(Board::Get().rfTxGpio); - keepAliveEnabled = true; -} - -bool RFConfig::FromFlatbuffers(const Serialization::Configuration::RFConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - // "No pin" (-1) is not a usable TX pin either; both fall back to the board default. - if (auto pin = Internal::Utils::FromIntGpioNum(config->tx_pin()); pin && *pin != GPIO_NUM_NC) { - txPin = *pin; - } else { - txPin = static_cast(Board::Get().rfTxGpio); - } - keepAliveEnabled = config->keepalive_enabled(); - - return true; -} - -flatbuffers::Offset RFConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - namespace Fbs = Serialization::Configuration; - - // keepalive_enabled is stored even when it equals the schema default, so the value - // reads back the same under every schema version (up to 1.5.x it defaulted to - // false) and RootConfig::FromFlatbuffers never has to infer it. - Fbs::RFConfigBuilder rfBuilder(builder); - rfBuilder.add_tx_pin(txPin); - builder.AddElement(Fbs::RFConfig::VT_KEEPALIVE_ENABLED, static_cast(keepAliveEnabled)); - return rfBuilder.Finish(); -} - -bool RFConfig::FromJSON(JSON::JsonView json) -{ - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - if (auto pin = Internal::Utils::FromJsonGpioNum(json, "txPin"); pin && *pin != GPIO_NUM_NC) { - txPin = *pin; - } else { - txPin = static_cast(Board::Get().rfTxGpio); - } - keepAliveEnabled = json["keepAliveEnabled"].tryGetBool().value_or(true); - - return true; -} - -void RFConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const -{ - JSON::objBegin(gen, name); - json_gen_obj_set_int(gen, "txPin", static_cast(txPin)); - json_gen_obj_set_bool(gen, "keepAliveEnabled", keepAliveEnabled); - JSON::objEnd(gen, name); -} diff --git a/components/config/src/RootConfig.cpp b/components/config/src/RootConfig.cpp deleted file mode 100644 index 75a9b86b4..000000000 --- a/components/config/src/RootConfig.cpp +++ /dev/null @@ -1,201 +0,0 @@ -#include "config/RootConfig.h" - -const char* const TAG = "Config::RootConfig"; - -#include "Logging.h" - -using namespace OpenShock::Config; - -namespace Fbs = OpenShock::Serialization::Configuration; - -// Whether `field` is physically present in `table` (flatbuffers omits a scalar equal -// to its schema default). The generated tables derive privately from -// flatbuffers::Table, which is only a view of the same bytes. -static bool isStored(const void* table, flatbuffers::voffset_t field) -{ - return table != nullptr && static_cast(table)->CheckField(field); -} - -// Whether `field` is present in `table` with a non-zero value, ignoring the schema default. -static bool storedTrue(const void* table, flatbuffers::voffset_t field) -{ - return isStored(table, field) && static_cast(table)->GetField(field, 0) != 0; -} - -// Up to 1.5.x the schema declared rf.keepalive_enabled, ota_update.is_enabled and -// ota_update.allow_backend_management with default false; since 1.6 they default to -// true. Flatbuffers omits a field equal to its default, so a field omitted by a 1.5.x -// hub (false) reads back as true under the current schema. -// -// Which convention wrote a file shows only when at least one of the three is stored: -// a stored true can only come from the old schema (1.6+ omits true, and this firmware -// stores all three, so it omits none). When that is the case, the omitted ones meant -// false. A file that omits all three is ambiguous (1.5.x with all three off, or 1.6+ -// with all three on) and keeps the current schema's default (true). -static void applyLegacyBoolDefaults(const Fbs::HubConfig* config, RFConfig& rf, OtaUpdateConfig& otaUpdate) -{ - const Fbs::RFConfig* fbsRf = config->rf(); - const Fbs::OtaUpdateConfig* fbsOta = config->ota_update(); - - const bool legacyWriter = storedTrue(fbsRf, Fbs::RFConfig::VT_KEEPALIVE_ENABLED) || storedTrue(fbsOta, Fbs::OtaUpdateConfig::VT_IS_ENABLED) || storedTrue(fbsOta, Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT); - if (!legacyWriter) { - return; - } - - if (fbsRf != nullptr && !isStored(fbsRf, Fbs::RFConfig::VT_KEEPALIVE_ENABLED)) { - rf.keepAliveEnabled = false; - } - if (fbsOta != nullptr && !isStored(fbsOta, Fbs::OtaUpdateConfig::VT_IS_ENABLED)) { - otaUpdate.isEnabled = false; - } - if (fbsOta != nullptr && !isStored(fbsOta, Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT)) { - otaUpdate.allowBackendManagement = false; - } -} - -RootConfig::RootConfig() - : rf() - , wifi() - , captivePortal() - , backend() - , serialInput() - , otaUpdate() - , estop() -{ -} - -void RootConfig::ToDefault() -{ - rf.ToDefault(); - wifi.ToDefault(); - captivePortal.ToDefault(); - backend.ToDefault(); - serialInput.ToDefault(); - otaUpdate.ToDefault(); - estop.ToDefault(); -} - -bool RootConfig::FromFlatbuffers(const Serialization::Configuration::HubConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!rf.FromFlatbuffers(config->rf())) { - OS_LOGE(TAG, "Unable to load rf config"); - return false; - } - - if (!wifi.FromFlatbuffers(config->wifi())) { - OS_LOGE(TAG, "Unable to load wifi config"); - return false; - } - - if (!captivePortal.FromFlatbuffers(config->captive_portal())) { - OS_LOGE(TAG, "Unable to load captive portal config"); - return false; - } - - if (!backend.FromFlatbuffers(config->backend())) { - OS_LOGE(TAG, "Unable to load backend config"); - return false; - } - - if (!serialInput.FromFlatbuffers(config->serial_input())) { - OS_LOGE(TAG, "Unable to load serial input config"); - return false; - } - - if (!otaUpdate.FromFlatbuffers(config->ota_update())) { - OS_LOGE(TAG, "Unable to load ota update config"); - return false; - } - - if (!estop.FromFlatbuffers(config->estop())) { - OS_LOGE(TAG, "Unable to load estop config"); - return false; - } - - applyLegacyBoolDefaults(config, rf, otaUpdate); - - return true; -} - -flatbuffers::Offset RootConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - auto rfOffset = rf.ToFlatbuffers(builder, withSensitiveData); - auto wifiOffset = wifi.ToFlatbuffers(builder, withSensitiveData); - auto captivePortalOffset = captivePortal.ToFlatbuffers(builder, withSensitiveData); - auto backendOffset = backend.ToFlatbuffers(builder, withSensitiveData); - auto serialInputOffset = serialInput.ToFlatbuffers(builder, withSensitiveData); - auto otaUpdateOffset = otaUpdate.ToFlatbuffers(builder, withSensitiveData); - auto estopOffset = estop.ToFlatbuffers(builder, withSensitiveData); - - return Serialization::Configuration::CreateHubConfig(builder, rfOffset, wifiOffset, captivePortalOffset, backendOffset, serialInputOffset, otaUpdateOffset, estopOffset); -} - -bool RootConfig::FromJSON(JSON::JsonView json) -{ - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - if (!rf.FromJSON(json["rf"])) { - OS_LOGE(TAG, "Unable to load rf config"); - return false; - } - - if (!wifi.FromJSON(json["wifi"])) { - OS_LOGE(TAG, "Unable to load wifi config"); - return false; - } - - if (!captivePortal.FromJSON(json["captivePortal"])) { - OS_LOGE(TAG, "Unable to load captive portal config"); - return false; - } - - if (!backend.FromJSON(json["backend"])) { - OS_LOGE(TAG, "Unable to load backend config"); - return false; - } - - if (!serialInput.FromJSON(json["serialInput"])) { - OS_LOGE(TAG, "Unable to load serial input config"); - return false; - } - - if (!otaUpdate.FromJSON(json["otaUpdate"])) { - OS_LOGE(TAG, "Unable to load ota update config"); - return false; - } - - if (!estop.FromJSON(json["estop"])) { - OS_LOGE(TAG, "Unable to load estop config"); - return false; - } - - return true; -} - -void RootConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const -{ - JSON::objBegin(gen, name); - rf.ToJSON(gen, "rf", withSensitiveData); - wifi.ToJSON(gen, "wifi", withSensitiveData); - captivePortal.ToJSON(gen, "captivePortal", withSensitiveData); - backend.ToJSON(gen, "backend", withSensitiveData); - serialInput.ToJSON(gen, "serialInput", withSensitiveData); - otaUpdate.ToJSON(gen, "otaUpdate", withSensitiveData); - estop.ToJSON(gen, "estop", withSensitiveData); - JSON::objEnd(gen, name); -} diff --git a/components/config/src/Section.cpp b/components/config/src/Section.cpp new file mode 100644 index 000000000..2fe0b8a89 --- /dev/null +++ b/components/config/src/Section.cpp @@ -0,0 +1,57 @@ +#include + +#include "config/Section.h" + +#include "config/Nvs.h" + +#include "json/Json.h" + +using namespace OpenShock; + +// Constant-initialized, so it is valid before any section's constructor runs, whatever the static init order. +static constinit Config::SectionBase* s_first = nullptr; + +Config::SectionBase::SectionBase(const SectionInfo& info) + : m_info(info) + , m_next(nullptr) +{ + // Insert in `order`, so iteration is the jsonconfig document order whatever the link order was + SectionBase** link = &s_first; + while (*link != nullptr && (*link)->m_info.order < info.order) { + link = &(*link)->m_next; + } + m_next = *link; + *link = this; +} + +Config::SectionBase* Config::SectionBase::First() +{ + return s_first; +} + +SimpleMutex& Config::Internal::WriteLock() +{ + static SimpleMutex s_writeLock; + return s_writeLock; +} + +bool Config::Internal::EraseNamespaces(const char* partition, std::span namespaces) +{ + bool ok = true; + for (const char* ns : namespaces) { + ok &= Nvs::EraseNamespace(partition, ns); + } + return ok; +} + +bool Config::Internal::SameJson(Util::FunctionRef renderA, Util::FunctionRef renderB) +{ + JSON::StringWriter a, b; + json_gen_start_object(a.gen()); + renderA(a.gen()); + json_gen_end_object(a.gen()); + json_gen_start_object(b.gen()); + renderB(b.gen()); + json_gen_end_object(b.gen()); + return a.finish() == b.finish(); +} diff --git a/components/config/src/SerialInputConfig.cpp b/components/config/src/SerialInputConfig.cpp deleted file mode 100644 index d48e68ecf..000000000 --- a/components/config/src/SerialInputConfig.cpp +++ /dev/null @@ -1,66 +0,0 @@ -#include "config/SerialInputConfig.h" - -const char* const TAG = "Config::SerialInputConfig"; - -#include "config/internal/utils.h" -#include "Logging.h" - -using namespace OpenShock::Config; - -SerialInputConfig::SerialInputConfig() - : echoEnabled(true) -{ -} - -SerialInputConfig::SerialInputConfig(bool echoEnabled) - : echoEnabled(echoEnabled) -{ -} - -void SerialInputConfig::ToDefault() -{ - echoEnabled = true; -} - -bool SerialInputConfig::FromFlatbuffers(const Serialization::Configuration::SerialInputConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - echoEnabled = config->echo_enabled(); - - return true; -} - -flatbuffers::Offset SerialInputConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - return Serialization::Configuration::CreateSerialInputConfig(builder, echoEnabled); -} - -bool SerialInputConfig::FromJSON(JSON::JsonView json) -{ - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - echoEnabled = json["echoEnabled"].tryGetBool().value_or(true); - - return true; -} - -void SerialInputConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const -{ - JSON::objBegin(gen, name); - json_gen_obj_set_bool(gen, "echoEnabled", echoEnabled); - JSON::objEnd(gen, name); -} diff --git a/components/config/src/Store.cpp b/components/config/src/Store.cpp new file mode 100644 index 000000000..d9e67b026 --- /dev/null +++ b/components/config/src/Store.cpp @@ -0,0 +1,339 @@ +#include + +#include "config/Store.h" + +const char* const TAG = "Config"; + +#include "config/Nvs.h" +#include "config/Section.h" +#include "config/Wire.h" +#include "Logging.h" +#include "SimpleMutex.h" + +#include "fs/LfsPartition.h" +#include "json/Json.h" + +#include +#include + +#include +#include +#include + +using namespace OpenShock; + +static constexpr const char* kPartitionCfg = "cfg"; +static constexpr const char* kPartitionShared = "nvs"; +static constexpr const char* kRootNamespace = "root"; +static constexpr const char* kSchemaKey = "schema"; +static constexpr uint8_t kSchemaVersion = 1; + +// The 1.x config file: the HubConfig the Arduino firmware stored as "config" at the root of the littlefs partition +// labelled "config" (partitions/legacy/ota_4mb_v1.csv). Read once, never written, so a rollback to 1.x finds it. +static constexpr const char* kLegacyPartitionLabel = "config"; +static constexpr const char* kLegacyFilePath = "/config"; + +// Written by Init() alone, under the write lock, before anything else reads a section. +static const char* s_partition = nullptr; + +const char* Config::Internal::Partition() +{ + return s_partition; +} + +const char* Config::StorageLabel() +{ + return s_partition; +} + +#define CONFIG_LOCK_WRITE() ScopedLock lock__(&Config::Internal::WriteLock()) + +static bool hasSchema(const char* partition) +{ + auto handle = Config::Nvs::Handle::Open(partition, kRootNamespace, false); + if (!handle) { + return false; + } + uint8_t schema = 0; + return handle->GetU8(kSchemaKey, schema) == Config::Nvs::Result::Found && schema == kSchemaVersion; +} + +static bool writeSchema(const char* partition) +{ + auto handle = Config::Nvs::Handle::Open(partition, kRootNamespace, true); + return handle && handle->SetU8(kSchemaKey, kSchemaVersion) && handle->Commit(); +} + +// Every namespace the config owns: the store's own and each section's. +static std::vector allNamespaces() +{ + std::vector namespaces {kRootNamespace}; + for (auto* section = Config::SectionBase::First(); section != nullptr; section = section->Next()) { + for (const char* ns : section->Info().namespaces) { + namespaces.push_back(ns); + } + } + return namespaces; +} + +// Brings up `label` as an NVS partition. A partition whose pages are from another NVS version or are all full is +// erased and reformatted: that is the contract of the shared `nvs` (everything in it regenerates), and `cfg` only +// ever holds what this firmware wrote. +static bool initNvsPartition(const char* label) +{ + esp_err_t err = nvs_flash_init_partition(label); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + OS_LOGW(TAG, "NVS partition %s unusable (%s), erasing and reformatting", label, esp_err_to_name(err)); + if (nvs_flash_erase_partition(label) != ESP_OK) { + OS_LOGE(TAG, "Failed to erase NVS partition %s", label); + return false; + } + err = nvs_flash_init_partition(label); + } + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to initialize NVS partition %s: %s", label, esp_err_to_name(err)); + return false; + } + return true; +} + +static bool nvsPartitionExists(const char* label) +{ + return esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, label) != nullptr; +} + +// The 2.x cfg is the 1.x littlefs `config` partition retyped in place, so on the first boot after the table rewrite it +// still holds that littlefs. NVS must never try to make sense of it: it is erased. littlefs names itself in the +// superblock entry at the start of either of its first two blocks (revision count, tag, then "littlefs"). +static void clearLegacyLittlefs(const char* label) +{ + const esp_partition_t* partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, label); + if (partition == nullptr) { + return; + } + static constexpr uint32_t kBlockSize = 0x1000; + static constexpr uint32_t kMagicOffset = 8; + static constexpr char kMagic[] = {'l', 'i', 't', 't', 'l', 'e', 'f', 's'}; + for (uint32_t block = 0; block < 2 && (block + 1) * kBlockSize <= partition->size; ++block) { + char magic[sizeof(kMagic)]; + if (esp_partition_read(partition, block * kBlockSize + kMagicOffset, magic, sizeof(magic)) != ESP_OK || memcmp(magic, kMagic, sizeof(kMagic)) != 0) { + continue; + } + OS_LOGI(TAG, "Partition %s still holds the 1.x littlefs; erasing it", label); + if (esp_partition_erase_range(partition, 0, partition->size) != ESP_OK) { + OS_LOGE(TAG, "Failed to erase partition %s", label); + } + return; + } +} + +static const char* openStorage() +{ + if (nvsPartitionExists(kPartitionCfg)) { + clearLegacyLittlefs(kPartitionCfg); + } + if (nvsPartitionExists(kPartitionCfg) && initNvsPartition(kPartitionCfg)) { + return kPartitionCfg; + } + if (nvsPartitionExists(kPartitionShared) && initNvsPartition(kPartitionShared)) { + OS_LOGW(TAG, "No cfg partition (1.x partition layout): the config is kept in the shared nvs partition until the layout is migrated"); + return kPartitionShared; + } + OS_LOGE(TAG, "No NVS partition for the config; running on defaults in RAM, nothing will be saved"); + return nullptr; +} + +// First boot on a 2.x layout after the table rewrite: what the 1.9.9 pivot kept in the shared `nvs` is copied into +// `cfg` key by key, compared, and only then deleted from the shared partition. Idempotent: once `cfg` has a schema +// the shared copy is only ever cleaned up. +static void moveSharedIntoCfg() +{ + if (!nvsPartitionExists(kPartitionShared) || !initNvsPartition(kPartitionShared) || !hasSchema(kPartitionShared)) { + return; + } + const auto namespaces = allNamespaces(); + + if (!hasSchema(kPartitionCfg)) { + bool ok = true; + for (const char* ns : namespaces) { + ok = ok && Config::Nvs::CopyNamespace(kPartitionShared, kPartitionCfg, ns) && Config::Nvs::NamespacesEqual(kPartitionShared, kPartitionCfg, ns); + } + if (!ok) { + OS_LOGE(TAG, "Failed to copy the config into cfg; it stays in the shared nvs partition"); + for (const char* ns : namespaces) { + (void)Config::Nvs::EraseNamespace(kPartitionCfg, ns); + } + return; + } + OS_LOGI(TAG, "Moved the config from the shared nvs partition into cfg"); + } + + bool erased = true; + for (const char* ns : namespaces) { + erased &= Config::Nvs::EraseNamespace(kPartitionShared, ns); + } + if (erased) { + OS_LOGI(TAG, "Removed the config copy from the shared nvs partition"); + } +} + +// The config file a 1.x hub left behind, as a jsonconfig document. Nullopt when the hub has none. +static std::optional readLegacyConfig() +{ + const esp_partition_t* partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, kLegacyPartitionLabel); + if (partition == nullptr) { + return std::nullopt; // Not a 1.x layout: nothing to import + } + + // Read-only and never formatted: the file must survive a rollback to 1.x + LfsPartition fs; + if (!fs.mount(partition, /*writable=*/false, /*formatOnFail=*/false)) { + OS_LOGW(TAG, "The 1.x config partition does not mount; nothing to import"); + return std::nullopt; + } + std::vector blob; + bool read = fs.readAll(kLegacyFilePath, blob); + fs.unmount(); + if (!read) { + OS_LOGW(TAG, "The 1.x config partition holds no config file; nothing to import"); + return std::nullopt; + } + + auto json = Config::HubConfigToJSON(blob); + if (!json) { + OS_LOGE(TAG, "The 1.x config file (%zu bytes) is not a valid config; nothing to import", blob.size()); + return std::nullopt; + } + OS_LOGI(TAG, "Importing the 1.x config (%zu bytes)", blob.size()); + return json; +} + +// Requires the write lock. Stages `root` in every section; on success commits each (every key when `full`). +static bool applyDocument(JSON::JsonView root, bool full) +{ + if (!root.isObject()) { + OS_LOGE(TAG, "The config is not a JSON object"); + return false; + } + + for (auto* section = Config::SectionBase::First(); section != nullptr; section = section->Next()) { + if (!section->Stage(root[section->Info().name])) { + OS_LOGE(TAG, "Unable to read the %s config", section->Info().name); + for (auto* s = Config::SectionBase::First(); s != nullptr; s = s->Next()) { + s->DiscardStaged(); + } + return false; + } + } + + bool ok = true; + for (auto* section = Config::SectionBase::First(); section != nullptr; section = section->Next()) { + if (!ok) { + section->DiscardStaged(); + continue; + } + if (!section->CommitStaged(s_partition, full)) { + OS_LOGE(TAG, "Failed to save the %s config", section->Info().name); + ok = false; + } + } + return ok; +} + +// Requires the write lock. Fills an unmarked store: the 1.x file if there is one, else the defaults. +static void fillStore() +{ + bool filled = false; + if (auto legacy = readLegacyConfig()) { + JSON::JsonDocument doc; + if (doc.parse(*legacy) && applyDocument(doc.root(), true)) { + filled = true; + } else { + OS_LOGE(TAG, "Failed to apply the imported 1.x config; writing the defaults"); + } + } + if (!filled) { + OS_LOGW(TAG, "No stored config, writing the defaults"); + for (auto* section = Config::SectionBase::First(); section != nullptr; section = section->Next()) { + section->Reset(s_partition); + } + } + + if (s_partition == nullptr) { + return; + } + // Read back: what the hub runs with must be what is on flash, as every later save assumes + for (auto* section = Config::SectionBase::First(); section != nullptr; section = section->Next()) { + if (!section->VerifyStored(s_partition)) { + OS_LOGE(TAG, "The %s config does not read back as written to %s", section->Info().name, s_partition); + } + } + if (!writeSchema(s_partition)) { + OS_LOGE(TAG, "Failed to mark the config store; it will be filled again on the next boot"); + } +} + +void Config::Init() +{ + CONFIG_LOCK_WRITE(); + + s_partition = openStorage(); + if (s_partition != nullptr && std::strcmp(s_partition, kPartitionCfg) == 0) { + moveSharedIntoCfg(); + } + + if (s_partition == nullptr || !hasSchema(s_partition)) { + fillStore(); + return; + } + + for (auto* section = SectionBase::First(); section != nullptr; section = section->Next()) { + if (!section->Load(s_partition)) { + // One unreadable section costs that section only + OS_LOGE(TAG, "Failed to load the %s config from %s, rewriting it with the defaults", section->Info().name, s_partition); + section->Reset(s_partition); + } + } + OS_LOGI(TAG, "Loaded config from %s", s_partition); +} + +void Config::FactoryReset() +{ + CONFIG_LOCK_WRITE(); + + bool ok = true; + for (auto* section = SectionBase::First(); section != nullptr; section = section->Next()) { + ok &= section->Reset(s_partition); + } + if (!ok) { + OS_PANIC(TAG, "Failed to reset the config. Recommend formatting microcontroller and re-flashing firmware"); + } + + OS_LOGI(TAG, "Factory reset complete"); +} + +std::string Config::GetAsJSON(bool withSensitiveData) +{ + JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + + json_gen_start_object(gen); + for (auto* section = SectionBase::First(); section != nullptr; section = section->Next()) { + section->ToJSON(gen, withSensitiveData); + } + json_gen_end_object(gen); + + return writer.finish(); +} + +bool Config::SaveFromJSON(std::string_view json) +{ + JSON::JsonDocument doc; + if (!doc.parse(json)) { + OS_LOGE(TAG, "Failed to parse JSON"); + return false; + } + + CONFIG_LOCK_WRITE(); + return applyDocument(doc.root(), false); +} diff --git a/components/config/src/WiFiConfig.cpp b/components/config/src/WiFiConfig.cpp deleted file mode 100644 index 6a57d177e..000000000 --- a/components/config/src/WiFiConfig.cpp +++ /dev/null @@ -1,103 +0,0 @@ -#include "config/WiFiConfig.h" - -const char* const TAG = "Config::WiFiConfig"; - -#include "config/internal/utils.h" -#include "Logging.h" - -using namespace OpenShock::Config; - -WiFiConfig::WiFiConfig() - : accessPointSSID(CONFIG_OPENSHOCK_FW_AP_PREFIX) - , hostname(CONFIG_OPENSHOCK_FW_HOSTNAME) - , credentialsList() -{ -} - -WiFiConfig::WiFiConfig(std::string_view accessPointSSID, std::string_view hostname, const std::vector& credentialsList) - : accessPointSSID(accessPointSSID) - , hostname(hostname) - , credentialsList(credentialsList) -{ -} - -void WiFiConfig::ToDefault() -{ - accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; - hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; - credentialsList.clear(); -} - -bool WiFiConfig::FromFlatbuffers(const Serialization::Configuration::WiFiConfig* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - Internal::Utils::FromFbsStr(accessPointSSID, config->ap_ssid(), CONFIG_OPENSHOCK_FW_AP_PREFIX); - Internal::Utils::FromFbsStr(hostname, config->hostname(), CONFIG_OPENSHOCK_FW_HOSTNAME); - Internal::Utils::FromFbsVec(credentialsList, config->credentials()); - - return true; -} - -flatbuffers::Offset WiFiConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - std::vector> fbsCredentialsList; - fbsCredentialsList.reserve(credentialsList.size()); - - for (auto& credentials : credentialsList) { - fbsCredentialsList.push_back(credentials.ToFlatbuffers(builder, withSensitiveData)); - } - - return Serialization::Configuration::CreateWiFiConfig(builder, builder.CreateString(accessPointSSID), builder.CreateString(hostname), builder.CreateVector(fbsCredentialsList)); -} - -bool WiFiConfig::FromJSON(JSON::JsonView json) -{ - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - accessPointSSID = json["accessPointSSID"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_AP_PREFIX); - - hostname = json["hostname"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_HOSTNAME); - - JSON::JsonView credentialsListJson = json["credentials"]; - if (!credentialsListJson.valid()) { - OS_LOGE(TAG, "credentials is null"); - return false; - } - - if (!credentialsListJson.isArray()) { - OS_LOGE(TAG, "credentials is not an array"); - return false; - } - - Internal::Utils::FromJsonArray(credentialsList, credentialsListJson); - - return true; -} - -void WiFiConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const -{ - JSON::objBegin(gen, name); - JSON::objSetString(gen, "accessPointSSID", accessPointSSID); - JSON::objSetString(gen, "hostname", hostname); - - json_gen_push_array(gen, "credentials"); - for (auto& credentials : credentialsList) { - credentials.ToJSON(gen, nullptr, withSensitiveData); - } - json_gen_pop_array(gen); - JSON::objEnd(gen, name); -} diff --git a/components/config/src/WiFiCredentials.cpp b/components/config/src/WiFiCredentials.cpp deleted file mode 100644 index 9c43d36f1..000000000 --- a/components/config/src/WiFiCredentials.cpp +++ /dev/null @@ -1,159 +0,0 @@ -#include "config/WiFiCredentials.h" - -const char* const TAG = "Config::WiFiCredentials"; - -#include "config/internal/utils.h" -#include "config/WiFiAuthMode.h" -#include "Logging.h" - -#include "util/HexUtils.h" - -#include -#include - -using namespace OpenShock::Config; -using FbsAuthMode = OpenShock::Serialization::Types::WifiAuthMode; - -WiFiCredentials::WiFiCredentials() - : id(0) - , ssid() - , password() - , authMode(WIFI_AUTH_MAX) - , bssid({0}) -{ -} - -WiFiCredentials::WiFiCredentials(uint8_t id, std::string_view ssid, std::string_view password, wifi_auth_mode_t authMode) - : id(id) - , ssid(ssid) - , password(password) - , authMode(authMode) - , bssid({0}) -{ -} - -bool WiFiCredentials::HasPinnedBSSID() const -{ - for (auto b : bssid) { - if (b != 0) return true; - } - return false; -} - -void WiFiCredentials::ToDefault() -{ - id = 0; - ssid.clear(); - password.clear(); - authMode = WIFI_AUTH_MAX; - bssid.fill(0); -} - -bool WiFiCredentials::FromFlatbuffers(const Serialization::Configuration::WiFiCredentials* config) -{ - if (config == nullptr) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - id = config->id(); - Internal::Utils::FromFbsStr(ssid, config->ssid(), ""); - Internal::Utils::FromFbsStr(password, config->password(), ""); - authMode = FromFbsAuthMode(config->auth_mode()); - - auto fbsBssid = config->bssid(); - if (fbsBssid != nullptr) { - memcpy(bssid.data(), fbsBssid->bytes()->data(), 6); - } else { - bssid.fill(0); - } - - if (ssid.empty()) { - OS_LOGE(TAG, "ssid is empty"); - return false; - } - - return true; -} - -flatbuffers::Offset WiFiCredentials::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const -{ - auto ssidOffset = builder.CreateString(ssid); - - flatbuffers::Offset passwordOffset; - if (withSensitiveData) { - passwordOffset = builder.CreateString(password); - } else { - passwordOffset = 0; - } - - const Serialization::Configuration::MacAddress* bssidPtr = nullptr; - Serialization::Configuration::MacAddress bssidStruct; - if (HasPinnedBSSID()) { - bssidStruct = Serialization::Configuration::MacAddress(flatbuffers::span(bssid.data(), 6)); - bssidPtr = &bssidStruct; - } - - return Serialization::Configuration::CreateWiFiCredentials(builder, id, ssidOffset, passwordOffset, ToFbsAuthMode(authMode), bssidPtr); -} - -bool WiFiCredentials::FromJSON(JSON::JsonView json) -{ - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; - } - - id = json["id"].tryGetU8().value_or(0); - - ssid = json["ssid"].tryGetStr().value_or(std::string()); - - password = json["password"].tryGetStr().value_or(std::string()); - - uint8_t authModeVal = json["authMode"].tryGetU8().value_or(std::to_underlying(FbsAuthMode::UNKNOWN)); - authMode = FromFbsAuthMode(static_cast(authModeVal)); - - bssid.fill(0); - if (auto bssidStr = json["bssid"].tryGetStr(); bssidStr && bssidStr->size() == 12) { - // Parse into a temporary: TryParseHex writes byte by byte and stops at the first bad pair, and a partial - // BSSID would pin the network to an AP that doesn't exist. - std::array parsed {}; - if (HexUtils::TryParseHex(*bssidStr, parsed) == parsed.size()) { - bssid = parsed; - } - } - - if (ssid.empty()) { - OS_LOGE(TAG, "ssid is empty"); - return false; - } - - return true; -} - -void WiFiCredentials::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const -{ - JSON::objBegin(gen, name); - json_gen_obj_set_int(gen, "id", id); - JSON::objSetString(gen, "ssid", ssid); - if (withSensitiveData) { - JSON::objSetString(gen, "password", password); - } - json_gen_obj_set_int(gen, "authMode", std::to_underlying(ToFbsAuthMode(authMode))); - if (HasPinnedBSSID()) { - char hex[13]; - for (std::size_t i = 0; i < bssid.size(); ++i) { - HexUtils::ToHex(bssid[i], &hex[i * 2]); - } - hex[12] = '\0'; - JSON::objSetString(gen, "bssid", hex); - } - JSON::objEnd(gen, name); -} diff --git a/components/config/src/Wire.cpp b/components/config/src/Wire.cpp new file mode 100644 index 000000000..4d551f5f7 --- /dev/null +++ b/components/config/src/Wire.cpp @@ -0,0 +1,278 @@ +#include + +#include "config/Wire.h" + +const char* const TAG = "Config::Wire"; + +#include "config/Parse.h" +#include "config/Section.h" +#include "config/Store.h" +#include "Logging.h" +#include "serialization/VerifiedRoot.h" + +#include "json/Json.h" + +#include +#include +#include +#include + +using namespace OpenShock; +namespace Fbs = OpenShock::Serialization::Configuration; + +flatbuffers::Offset Config::GetAsFlatBuffer(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) +{ + // Every sub-table is finished before the HubConfig table starts, as flatbuffers requires + std::vector> fields; + for (auto* section = SectionBase::First(); section != nullptr; section = section->Next()) { + if (section->Info().hubConfigField != 0) { + fields.emplace_back(section->Info().hubConfigField, section->ToFlatbuffers(builder, withSensitiveData)); + } + } + + const flatbuffers::uoffset_t start = builder.StartTable(); + for (const auto& [field, offset] : fields) { + builder.AddOffset(field, flatbuffers::Offset(offset)); + } + return flatbuffers::Offset(builder.EndTable(start)); +} + +bool Config::GetRaw(TinyVec& buffer) +{ + // Write every field, even ones equal to the schema default. Older firmware may use different defaults (1.5.x reads a + // missing is_enabled/keepalive_enabled as false), so an omitted field changes meaning when the dump is loaded there. + flatbuffers::FlatBufferBuilder builder; + builder.ForceDefaults(true); + Fbs::FinishHubConfigBuffer(builder, GetAsFlatBuffer(builder, true)); + + buffer.resize(builder.GetSize()); + memcpy(buffer.data(), builder.GetBufferPointer(), builder.GetSize()); + return true; +} + +bool Config::SetRaw(const uint8_t* buffer, std::size_t size) +{ + if (buffer == nullptr) { + return false; + } + auto json = HubConfigToJSON(std::span(buffer, size)); + if (!json) { + OS_LOGE(TAG, "Not a valid HubConfig (%zu bytes)", size); + return false; + } + return SaveFromJSON(*json); +} + +// --------------------------------------------------------------------------- +// HubConfig -> jsonconfig document. Emits, per section, exactly what the section's own FromJSON reads, so a +// HubConfig and the JSON it converts to configure a hub the same way. +// --------------------------------------------------------------------------- + +namespace { + // Whether `field` is physically present in `table` (flatbuffers omits a scalar equal to its schema default). The + // generated tables derive privately from flatbuffers::Table, which is only a view of the same bytes. + bool isStored(const void* table, flatbuffers::voffset_t field) + { + return table != nullptr && static_cast(table)->CheckField(field); + } + + // Whether `field` is present in `table` with a non-zero value, ignoring the schema default. + bool storedTrue(const void* table, flatbuffers::voffset_t field) + { + return isStored(table, field) && static_cast(table)->GetField(field, 0) != 0; + } + + void setString(json_gen_str_t* gen, const char* name, const flatbuffers::String* value) + { + if (value != nullptr) { + JSON::objSetString(gen, name, std::string_view(value->c_str(), value->size())); + } + } + + struct LegacyBools { + bool keepAliveEnabled; + bool otaEnabled; + bool allowBackendManagement; + }; + + // Up to 1.5.x the schema declared rf.keepalive_enabled, ota_update.is_enabled and ota_update.allow_backend_management + // with default false; since 1.6 they default to true. Flatbuffers omits a field equal to its default, so a field + // omitted by a 1.5.x hub (false) reads back as true under the current schema. + // + // Which convention wrote a buffer shows only when at least one of the three is stored: a stored true can only come + // from the old schema (1.6+ omits true, and the 2.x wire form stores all three, so it omits none). When that is the + // case, the omitted ones meant false. A buffer that omits all three is ambiguous (1.5.x with all three off, or 1.6+ + // with all three on) and keeps the current schema's default (true). + LegacyBools resolveFlippedDefaults(const Fbs::HubConfig* config) + { + const Fbs::RFConfig* rf = config->rf(); + const Fbs::OtaUpdateConfig* ota = config->ota_update(); + + LegacyBools out { + .keepAliveEnabled = rf != nullptr ? rf->keepalive_enabled() : true, + .otaEnabled = ota != nullptr ? ota->is_enabled() : true, + .allowBackendManagement = ota != nullptr ? ota->allow_backend_management() : true, + }; + + const bool legacyWriter = storedTrue(rf, Fbs::RFConfig::VT_KEEPALIVE_ENABLED) || storedTrue(ota, Fbs::OtaUpdateConfig::VT_IS_ENABLED) || storedTrue(ota, Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT); + if (legacyWriter) { + if (rf != nullptr && !isStored(rf, Fbs::RFConfig::VT_KEEPALIVE_ENABLED)) out.keepAliveEnabled = false; + if (ota != nullptr && !isStored(ota, Fbs::OtaUpdateConfig::VT_IS_ENABLED)) out.otaEnabled = false; + if (ota != nullptr && !isStored(ota, Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT)) out.allowBackendManagement = false; + } + return out; + } + + void writeRf(json_gen_str_t* gen, const Fbs::RFConfig* rf, const LegacyBools& bools) + { + if (rf == nullptr) { + return; + } + json_gen_push_object(gen, "rf"); + json_gen_obj_set_int(gen, "txPin", rf->tx_pin()); // RFConfig::FromJSON maps "no pin" and invalid pins to the board's + json_gen_obj_set_bool(gen, "keepAliveEnabled", bools.keepAliveEnabled); + json_gen_pop_object(gen); + } + + void writeWifi(json_gen_str_t* gen, const Fbs::WiFiConfig* wifi) + { + if (wifi == nullptr) { + return; + } + json_gen_push_object(gen, "wifi"); + setString(gen, "accessPointSSID", wifi->ap_ssid()); + setString(gen, "hostname", wifi->hostname()); + json_gen_push_array(gen, "credentials"); // Required by WiFiConfig::FromJSON whenever "wifi" is given + if (const auto* list = wifi->credentials()) { + for (const Fbs::WiFiCredentials* creds : *list) { + if (creds == nullptr) { + continue; + } + json_gen_start_object(gen); + json_gen_obj_set_int(gen, "id", creds->id()); + setString(gen, "ssid", creds->ssid()); // A credential without an SSID is dropped by WiFiCredentials::FromJSON + setString(gen, "password", creds->password()); + json_gen_obj_set_int(gen, "authMode", std::to_underlying(creds->auth_mode())); + if (const auto* bssid = creds->bssid()) { + static constexpr char kHex[] = "0123456789ABCDEF"; + char hex[13]; + for (int i = 0; i < 6; ++i) { + const uint8_t b = bssid->bytes()->Get(i); + hex[i * 2] = kHex[b >> 4]; + hex[i * 2 + 1] = kHex[b & 0x0F]; + } + hex[12] = '\0'; + JSON::objSetString(gen, "bssid", hex); + } + json_gen_end_object(gen); + } + } + json_gen_pop_array(gen); + json_gen_pop_object(gen); + } + + void writeCaptivePortal(json_gen_str_t* gen, const Fbs::CaptivePortalConfig* portal) + { + if (portal == nullptr) { + return; + } + json_gen_push_object(gen, "captivePortal"); + json_gen_obj_set_bool(gen, "alwaysEnabled", portal->always_enabled()); + json_gen_pop_object(gen); + } + + void writeBackend(json_gen_str_t* gen, const Fbs::BackendConfig* backend) + { + if (backend == nullptr) { + return; + } + json_gen_push_object(gen, "backend"); + setString(gen, "domain", backend->domain()); + setString(gen, "authToken", backend->auth_token()); + json_gen_pop_object(gen); + } + + void writeSerialInput(json_gen_str_t* gen, const Fbs::SerialInputConfig* serial) + { + if (serial == nullptr) { + return; + } + json_gen_push_object(gen, "serialInput"); + json_gen_obj_set_bool(gen, "echoEnabled", serial->echo_enabled()); + json_gen_pop_object(gen); + } + + void writeOtaUpdate(json_gen_str_t* gen, const Fbs::OtaUpdateConfig* ota, const LegacyBools& bools) + { + if (ota == nullptr) { + return; + } + json_gen_push_object(gen, "otaUpdate"); + json_gen_obj_set_bool(gen, "isEnabled", bools.otaEnabled); + setString(gen, "cdnDomain", ota->cdn_domain()); + // Out-of-range values (bit flips, configs from newer firmware) have no name and are left out: the default + if (const char* channel = Fbs::EnumNameOtaUpdateChannel(ota->update_channel()); channel != nullptr && channel[0] != '\0') { + JSON::objSetString(gen, "updateChannel", channel); + } + json_gen_obj_set_bool(gen, "checkOnStartup", ota->check_on_startup()); + json_gen_obj_set_bool(gen, "checkPeriodically", ota->check_periodically()); + json_gen_obj_set_int(gen, "checkInterval", ota->check_interval()); + json_gen_obj_set_bool(gen, "allowBackendManagement", bools.allowBackendManagement); + json_gen_obj_set_bool(gen, "requireManualApproval", ota->require_manual_approval()); + json_gen_obj_set_int(gen, "updateId", ota->update_id()); + if (const char* step = Fbs::EnumNameOtaUpdateStep(ota->update_step()); step != nullptr && step[0] != '\0') { + JSON::objSetString(gen, "updateStep", step); + } + json_gen_pop_object(gen); + } + + void writeEStop(json_gen_str_t* gen, const Fbs::EStopConfig* estop) + { + if (estop == nullptr) { + return; + } + // A pin that is neither "no pin" nor a real GPIO means a corrupt section: left out, so it takes its defaults + if (!Config::Parse::GpioNum(estop->gpio_pin())) { + OS_LOGW(TAG, "Invalid E-Stop GPIO pin %d, using the default E-Stop config", estop->gpio_pin()); + return; + } + json_gen_push_object(gen, "estop"); + json_gen_obj_set_bool(gen, "enabled", estop->enabled()); + json_gen_obj_set_int(gen, "gpioPin", estop->gpio_pin()); + json_gen_pop_object(gen); + } +} // namespace + +std::optional Config::HubConfigToJSON(std::span buffer) +{ + if (buffer.empty() || buffer.size() > kMaxHubConfigSize) { + OS_LOGE(TAG, "HubConfig has an impossible size (%zu bytes)", buffer.size()); + return std::nullopt; + } + const Fbs::HubConfig* config = Serialization::GetVerifiedRoot(buffer, kMaxHubConfigSize); + if (config == nullptr) { + OS_LOGE(TAG, "HubConfig failed verification (%zu bytes)", buffer.size()); + return std::nullopt; + } + + const LegacyBools bools = resolveFlippedDefaults(config); + + JSON::StringWriter writer; + json_gen_str_t* gen = writer.gen(); + json_gen_start_object(gen); + writeRf(gen, config->rf(), bools); + writeWifi(gen, config->wifi()); + writeCaptivePortal(gen, config->captive_portal()); + writeBackend(gen, config->backend()); + writeSerialInput(gen, config->serial_input()); + writeOtaUpdate(gen, config->ota_update(), bools); + writeEStop(gen, config->estop()); + json_gen_end_object(gen); + + std::string json = writer.finish(); + if (writer.failed()) { + OS_LOGE(TAG, "HubConfig holds a string that is not valid UTF-8"); + return std::nullopt; + } + return json; +} diff --git a/components/config/src/internal/utils.cpp b/components/config/src/internal/utils.cpp deleted file mode 100644 index d1c73b9ba..000000000 --- a/components/config/src/internal/utils.cpp +++ /dev/null @@ -1,44 +0,0 @@ -#include "config/internal/utils.h" - -const char* const TAG = "Config::Internal::Utils"; - -#include "Chipset.h" -#include "Logging.h" - -#include -#include - -using namespace OpenShock; - -std::optional Config::Internal::Utils::FromIntGpioNum(int32_t intVal) -{ - if (intVal == GPIO_NUM_NC) { - return GPIO_NUM_NC; - } - - if (intVal < 0 || intVal >= GPIO_NUM_MAX || !GPIO_IS_VALID_GPIO(intVal)) { - OS_LOGE(TAG, "invalid GPIO number %" PRId32, intVal); - return std::nullopt; - } - - return static_cast(intVal); -} - -void Config::Internal::Utils::FromFbsStr(std::string& str, const flatbuffers::String* fbsStr, const char* defaultStr) -{ - if (fbsStr != nullptr) { - str = fbsStr->c_str(); - } else { - str = defaultStr; - } -} - -std::optional Config::Internal::Utils::FromJsonGpioNum(JSON::JsonView json, std::string_view name) -{ - auto intVal = json[name].tryGetI32(); - if (!intVal) { - return std::nullopt; - } - - return FromIntGpioNum(*intVal); -} diff --git a/components/core/include/BackendConfig.h b/components/core/include/BackendConfig.h new file mode 100644 index 000000000..8b29ff3d8 --- /dev/null +++ b/components/core/include/BackendConfig.h @@ -0,0 +1,48 @@ +#pragma once + +// The `backend` config section: the API domain and the hub's auth token. Owned by core (GatewayConnectionManager) +// and kept in NVS through the config framework (config/Section.h); namespace "backend", keys domain and token (str). + +#include "config/Section.h" +#include "serialization/_fbs/HubConfig_generated.h" + +#include +#include +#include + +namespace OpenShock::JSON { + class JsonView; +} + +namespace OpenShock::Config { + struct BackendConfig { + BackendConfig(); + BackendConfig(std::string_view domain, std::string_view authToken); + + std::string domain; + std::string authToken; + + void ToDefault(); + + [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; + + /// @brief Reads this section from `partition`; a missing key is its default, a read error fails. + bool LoadNvs(const char* partition); + /// @brief Writes the keys that differ from `previous` (every key when null) to `partition`. + bool SaveNvs(const char* partition, const BackendConfig* previous) const; + + bool FromJSON(JSON::JsonView json); + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; + }; + + Section& BackendSection(); + + [[nodiscard]] std::optional GetBackendConfig(); + bool SetBackendConfig(const BackendConfig& config); + [[nodiscard]] std::optional GetBackendDomain(); + bool SetBackendDomain(std::string domain); + bool HasBackendAuthToken(); + [[nodiscard]] std::optional GetBackendAuthToken(); + bool SetBackendAuthToken(std::string token); + bool ClearBackendAuthToken(); +} // namespace OpenShock::Config diff --git a/components/core/include/OtaUpdateConfig.h b/components/core/include/OtaUpdateConfig.h new file mode 100644 index 000000000..970cded22 --- /dev/null +++ b/components/core/include/OtaUpdateConfig.h @@ -0,0 +1,72 @@ +#pragma once + +// The `otaUpdate` config section: update channel, checks and approval, plus the step of an update in flight. +// It lives in core, the lowest component that uses it: the gateway reports the update step, the captive portal edits +// the channel, and the ota component (above both) runs updates. Kept in NVS through the config framework +// (config/Section.h); namespace "ota", keys enabled, domain, channel, chk_boot, chk_period, chk_min, backend_mgmt, +// manual, update_id, step. + +#include "config/Section.h" +#include "enums/FirmwareBootType.h" +#include "enums/OtaUpdateChannel.h" +#include "enums/OtaUpdateStep.h" +#include "serialization/_fbs/HubConfig_generated.h" + +#include "util/FunctionRef.h" + +#include +#include +#include + +namespace OpenShock::JSON { + class JsonView; +} + +namespace OpenShock::Config { + struct OtaUpdateConfig { + OtaUpdateConfig(); + OtaUpdateConfig( + bool isEnabled, std::string cdnDomain, OtaUpdateChannel updateChannel, bool checkOnStartup, bool checkPeriodically, uint16_t checkInterval, bool allowBackendManagement, bool requireManualApproval, int32_t updateId, OtaUpdateStep updateStep + ); + + bool isEnabled; + std::string cdnDomain; + OtaUpdateChannel updateChannel; + bool checkOnStartup; + bool checkPeriodically; + uint16_t checkInterval; + bool allowBackendManagement; + bool requireManualApproval; + int32_t updateId; + OtaUpdateStep updateStep; + + void ToDefault(); + + [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; + + /// @brief Reads this section from `partition`; a missing key is its default, a read error fails. + bool LoadNvs(const char* partition); + /// @brief Writes the keys that differ from `previous` (every key when null) to `partition`. + bool SaveNvs(const char* partition, const OtaUpdateConfig* previous) const; + + bool FromJSON(JSON::JsonView json); + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; + }; + + Section& OtaUpdateSection(); + + [[nodiscard]] std::optional GetOtaUpdateConfig(); + bool SetOtaUpdateConfig(const OtaUpdateConfig& config); + /// @brief Read-modify-write that no other writer can interleave with. `update` edits the current config in place and + /// returns whether it changed anything (nothing is saved otherwise). It runs under the config write lock: keep + /// it to plain field updates. + bool UpdateOtaUpdateConfig(Util::FunctionRef update); + + [[nodiscard]] std::optional GetOtaUpdateId(); + bool SetOtaUpdateId(int32_t updateId); + [[nodiscard]] std::optional GetOtaUpdateStep(); + bool SetOtaUpdateStep(OtaUpdateStep updateStep); + /// @brief Sets the step only if it is still `expected`, so a stale writer can't clobber an update started meanwhile. + /// @return True if the step was `expected` and is now `desired` + bool CompareExchangeOtaUpdateStep(OtaUpdateStep expected, OtaUpdateStep desired); +} // namespace OpenShock::Config diff --git a/components/config/include/config/WiFiAuthMode.h b/components/core/include/wifi/WiFiAuthMode.h similarity index 91% rename from components/config/include/config/WiFiAuthMode.h rename to components/core/include/wifi/WiFiAuthMode.h index 7d17a99bb..190f204e9 100644 --- a/components/config/include/config/WiFiAuthMode.h +++ b/components/core/include/wifi/WiFiAuthMode.h @@ -5,8 +5,8 @@ #include namespace OpenShock::Config { - // The one mapping between ESP-IDF's wifi_auth_mode_t and the FlatBuffers WifiAuthMode used by the stored config - // and the local (captive portal) protocol. Modes without a FlatBuffers counterpart map to UNKNOWN / WIFI_AUTH_MAX. + // The one mapping between ESP-IDF's wifi_auth_mode_t and the FlatBuffers WifiAuthMode used by the stored credential + // blobs, the jsonconfig document and the local (captive portal) protocol. Modes without a FlatBuffers counterpart map to UNKNOWN / WIFI_AUTH_MAX. constexpr Serialization::Types::WifiAuthMode ToFbsAuthMode(wifi_auth_mode_t mode) { using FbsAuthMode = Serialization::Types::WifiAuthMode; diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h new file mode 100644 index 000000000..ad89f317c --- /dev/null +++ b/components/core/include/wifi/WiFiConfig.h @@ -0,0 +1,61 @@ +#pragma once + +// The `wifi` config section: the soft-AP SSID, the hostname and the saved networks. Owned by core (WiFiManager) and +// kept in NVS through the config framework (config/Section.h): +// namespace "wifi.cfg": ap_ssid (str), hostname (str) +// namespace "wifi": one blob per credential, key = its id in decimal ("1".."255"); enumerated, never counted + +#include "config/Section.h" +#include "serialization/_fbs/HubConfig_generated.h" +#include "util/FunctionRef.h" +#include "wifi/WiFiCredentials.h" + +#include +#include +#include +#include +#include + +namespace OpenShock::Config { + struct WiFiConfig { + WiFiConfig(); + WiFiConfig(std::string_view accessPointSSID, std::string_view hostname, const std::vector& credentialsList); + + std::string accessPointSSID; + std::string hostname; + std::vector credentialsList; // in id order once loaded from NVS + + void ToDefault(); + + bool LoadNvs(const char* partition); + bool SaveNvs(const char* partition, const WiFiConfig* previous) const; + + bool FromJSON(JSON::JsonView json); + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; + + [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; + }; + + Section& WiFiSection(); + + [[nodiscard]] std::optional GetWiFiConfig(); + bool SetWiFiConfig(const WiFiConfig& config); + + [[nodiscard]] std::optional> GetWiFiCredentials(); + // Emits each stored credential as an object into an already-open JSON array + // (the caller opens/closes it with json_gen_{start,end}_array). + bool GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData); + bool SetWiFiCredentials(const std::vector& credentials); + + bool AnyWiFiCredentials(Util::FunctionRef predicate); + /// @return The id of the stored credential (new, or the existing one for that SSID updated), or 0 on failure. + uint8_t AddWiFiCredentials(std::string_view ssid, std::string_view password, wifi_auth_mode_t authMode = WIFI_AUTH_MAX); + [[nodiscard]] std::optional TryGetWiFiCredentialsByID(uint8_t id); + [[nodiscard]] std::optional TryGetWiFiCredentialsBySSID(const char* ssid); + uint8_t GetWiFiCredentialsIDbySSID(const char* ssid); + bool PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]); + bool RemoveWiFiCredentials(uint8_t id); + bool ClearWiFiCredentials(); + [[nodiscard]] std::optional GetWiFiHostname(); + bool SetWiFiHostname(std::string hostname); +} // namespace OpenShock::Config diff --git a/components/config/include/config/WiFiCredentials.h b/components/core/include/wifi/WiFiCredentials.h similarity index 52% rename from components/config/include/config/WiFiCredentials.h rename to components/core/include/wifi/WiFiCredentials.h index b79b64e92..5da4e7cb9 100644 --- a/components/config/include/config/WiFiCredentials.h +++ b/components/core/include/wifi/WiFiCredentials.h @@ -1,17 +1,26 @@ #pragma once -#include "config/ConfigBase.h" +#include "serialization/_fbs/HubConfig_generated.h" + +#include #include #include #include #include +#include #include #include +#include + +namespace OpenShock::JSON { + class JsonView; +} namespace OpenShock::Config { - struct WiFiCredentials : public ConfigBase { + // One saved network: an element of the wifi section's credential list, stored as one blob per credential. + struct WiFiCredentials { WiFiCredentials(); WiFiCredentials(uint8_t id, std::string_view ssid, std::string_view password, wifi_auth_mode_t authMode = WIFI_AUTH_MAX); @@ -23,12 +32,16 @@ namespace OpenShock::Config { bool HasPinnedBSSID() const; - void ToDefault() override; + /// @brief The credential as the blob stored under its id in the `wifi` NVS namespace (wifi/WiFiConfig.h). + [[nodiscard]] std::vector ToBlob() const; + /// @brief Decodes a blob written by ToBlob (any version it ever wrote). False on a malformed blob. + bool FromBlob(std::span blob); + + void ToDefault(); - bool FromFlatbuffers(const Serialization::Configuration::WiFiCredentials* config) override; - [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const override; + [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; - bool FromJSON(JSON::JsonView json) override; - void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const override; + bool FromJSON(JSON::JsonView json); + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; }; } // namespace OpenShock::Config diff --git a/components/core/src/BackendConfig.cpp b/components/core/src/BackendConfig.cpp new file mode 100644 index 000000000..256551d7b --- /dev/null +++ b/components/core/src/BackendConfig.cpp @@ -0,0 +1,157 @@ +#include + +#include "BackendConfig.h" + +const char* const TAG = "Config::BackendConfig"; + +#include "config/Nvs.h" +#include "config/Parse.h" +#include "Logging.h" + +#include "json/Json.h" + +using namespace OpenShock::Config; +namespace Fbs = OpenShock::Serialization::Configuration; + +static constexpr const char* kNamespace = "backend"; +static constexpr const char* kKeyDomain = "domain"; +static constexpr const char* kKeyToken = "token"; + +static constexpr const char* kNamespaces[] = {kNamespace}; +static Section s_section({.name = "backend", .order = 3, .hubConfigField = Fbs::HubConfig::VT_BACKEND, .namespaces = kNamespaces}); + +Section& OpenShock::Config::BackendSection() +{ + return s_section; +} + +BackendConfig::BackendConfig() + : domain(CONFIG_OPENSHOCK_API_DOMAIN) + , authToken() +{ +} + +BackendConfig::BackendConfig(std::string_view domain, std::string_view authToken) + : domain(domain) + , authToken(authToken) +{ +} + +void BackendConfig::ToDefault() +{ + domain = CONFIG_OPENSHOCK_API_DOMAIN; + authToken.clear(); +} + +flatbuffers::Offset BackendConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + auto domainOffset = builder.CreateString(domain); + + flatbuffers::Offset authTokenOffset; + if (withSensitiveData) { + authTokenOffset = builder.CreateString(authToken); + } else { + authTokenOffset = 0; + } + + return Serialization::Configuration::CreateBackendConfig(builder, domainOffset, authTokenOffset); +} + +bool BackendConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + domain = json["domain"].tryGetStr().value_or(CONFIG_OPENSHOCK_API_DOMAIN); + + authToken = json["authToken"].tryGetStr().value_or(std::string()); + + return true; +} + +void BackendConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + JSON::objSetString(gen, "domain", domain); + + if (withSensitiveData) { + JSON::objSetString(gen, "authToken", authToken); + } + JSON::objEnd(gen, name); +} + +bool BackendConfig::LoadNvs(const char* partition) +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, false); + if (!handle) { + ToDefault(); + return true; + } + return Nvs::Load(*handle, kKeyDomain, domain, CONFIG_OPENSHOCK_API_DOMAIN) && Nvs::Load(*handle, kKeyToken, authToken, ""); +} + +bool BackendConfig::SaveNvs(const char* partition, const BackendConfig* previous) const +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, true); + if (!handle) { + return false; + } + return handle->SetStr(kKeyDomain, domain, previous != nullptr ? &previous->domain : nullptr) && handle->SetStr(kKeyToken, authToken, previous != nullptr ? &previous->authToken : nullptr) && handle->Commit(); +} + +std::optional OpenShock::Config::GetBackendConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetBackendConfig(const BackendConfig& config) +{ + return s_section.Set(config); +} + +std::optional OpenShock::Config::GetBackendDomain() +{ + return s_section.Read([](const BackendConfig& backend) { return backend.domain; }); +} + +bool OpenShock::Config::SetBackendDomain(std::string domain) +{ + return s_section.Update([&](BackendConfig& backend) { + backend.domain = std::move(domain); + return true; + }); +} + +bool OpenShock::Config::HasBackendAuthToken() +{ + return s_section.Read([](const BackendConfig& backend) { return !backend.authToken.empty(); }); +} + +std::optional OpenShock::Config::GetBackendAuthToken() +{ + return s_section.Read([](const BackendConfig& backend) { return backend.authToken; }); +} + +bool OpenShock::Config::SetBackendAuthToken(std::string token) +{ + return s_section.Update([&](BackendConfig& backend) { + backend.authToken = std::move(token); + return true; + }); +} + +bool OpenShock::Config::ClearBackendAuthToken() +{ + return s_section.Update([](BackendConfig& backend) { + backend.authToken.clear(); + return true; + }); +} diff --git a/components/core/src/GatewayClient.cpp b/components/core/src/GatewayClient.cpp index 79b2230cb..4e09cfe16 100644 --- a/components/core/src/GatewayClient.cpp +++ b/components/core/src/GatewayClient.cpp @@ -6,11 +6,11 @@ const char* const TAG = "GatewayClient"; #include "AppHooks.h" -#include "config/Config.h" #include "events/Events.h" #include "Logging.h" #include "message_handlers/WebSocket.h" #include "OpenShock.h" +#include "OtaUpdateConfig.h" #include "serialization/WSGateway.h" #include "Temporal.h" #include "visual/VisualStateManager.h" diff --git a/components/core/src/GatewayConnectionManager.cpp b/components/core/src/GatewayConnectionManager.cpp index 13ef31a49..f8ae6f489 100644 --- a/components/core/src/GatewayConnectionManager.cpp +++ b/components/core/src/GatewayConnectionManager.cpp @@ -1,3 +1,5 @@ +#include + #include "GatewayConnectionManager.h" const char* const TAG = "GatewayConnectionManager"; @@ -5,7 +7,7 @@ const char* const TAG = "GatewayConnectionManager"; #include "visual/VisualStateManager.h" #include "AppHooks.h" -#include "config/Config.h" +#include "BackendConfig.h" #include "events/Events.h" #include "GatewayClient.h" #include "http/JsonAPI.h" diff --git a/components/core/src/OtaUpdateConfig.cpp b/components/core/src/OtaUpdateConfig.cpp new file mode 100644 index 000000000..4b5df8142 --- /dev/null +++ b/components/core/src/OtaUpdateConfig.cpp @@ -0,0 +1,258 @@ +#include + +#include "OtaUpdateConfig.h" + +const char* const TAG = "Config::OtaUpdateConfig"; + +#include "config/Nvs.h" +#include "config/Parse.h" +#include "Logging.h" + +#include "json/Json.h" + +#include + +using namespace OpenShock::Config; +namespace Fbs = OpenShock::Serialization::Configuration; + +static constexpr const char* kNamespace = "ota"; +static constexpr const char* kKeyEnabled = "enabled"; +static constexpr const char* kKeyDomain = "domain"; +static constexpr const char* kKeyChannel = "channel"; +static constexpr const char* kKeyCheckBoot = "chk_boot"; +static constexpr const char* kKeyCheckPeriod = "chk_period"; +static constexpr const char* kKeyCheckMin = "chk_min"; +static constexpr const char* kKeyBackendMgmt = "backend_mgmt"; +static constexpr const char* kKeyManual = "manual"; +static constexpr const char* kKeyUpdateId = "update_id"; +static constexpr const char* kKeyStep = "step"; + +static constexpr const char* kNamespaces[] = {kNamespace}; +static Section s_section({.name = "otaUpdate", .order = 5, .hubConfigField = Fbs::HubConfig::VT_OTA_UPDATE, .namespaces = kNamespaces}); + +Section& OpenShock::Config::OtaUpdateSection() +{ + return s_section; +} + +using namespace std::string_view_literals; + +OtaUpdateConfig::OtaUpdateConfig() + : isEnabled(true) + , cdnDomain(CONFIG_OPENSHOCK_FW_CDN_DOMAIN) + , updateChannel(OtaUpdateChannel::Stable) + , checkOnStartup(false) + , checkPeriodically(false) + , checkInterval(30) + , allowBackendManagement(true) + , requireManualApproval(false) + , updateId(0) + , updateStep(OtaUpdateStep::None) +{ +} + +OtaUpdateConfig::OtaUpdateConfig( + bool isEnabled, std::string cdnDomain, OtaUpdateChannel updateChannel, bool checkOnStartup, bool checkPeriodically, uint16_t checkInterval, bool allowBackendManagement, bool requireManualApproval, int32_t updateId, OtaUpdateStep updateStep +) + : isEnabled(isEnabled) + , cdnDomain(std::move(cdnDomain)) + , updateChannel(updateChannel) + , checkOnStartup(checkOnStartup) + , checkPeriodically(checkPeriodically) + , checkInterval(checkInterval) + , allowBackendManagement(allowBackendManagement) + , requireManualApproval(requireManualApproval) + , updateId(updateId) + , updateStep(updateStep) +{ +} + +void OtaUpdateConfig::ToDefault() +{ + isEnabled = true; + cdnDomain = CONFIG_OPENSHOCK_FW_CDN_DOMAIN; + updateChannel = OtaUpdateChannel::Stable; + checkOnStartup = false; + checkPeriodically = false; + checkInterval = 30; // 30 minutes + allowBackendManagement = true; + requireManualApproval = false; + updateId = 0; + updateStep = OtaUpdateStep::None; +} + +flatbuffers::Offset OtaUpdateConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + namespace Fbs = Serialization::Configuration; + + auto cdnDomainOffset = builder.CreateString(cdnDomain); + + // Same field order as CreateOtaUpdateConfig, except that is_enabled and + // allow_backend_management are stored even when they equal the schema default, so + // they read back the same under every schema version (up to 1.5.x both defaulted + // to false) and RootConfig::FromFlatbuffers never has to infer them. + Fbs::OtaUpdateConfigBuilder otaBuilder(builder); + otaBuilder.add_update_id(updateId); + otaBuilder.add_cdn_domain(cdnDomainOffset); + otaBuilder.add_check_interval(checkInterval); + otaBuilder.add_update_step(static_cast(updateStep)); + otaBuilder.add_require_manual_approval(requireManualApproval); + builder.AddElement(Fbs::OtaUpdateConfig::VT_ALLOW_BACKEND_MANAGEMENT, static_cast(allowBackendManagement)); + otaBuilder.add_check_periodically(checkPeriodically); + otaBuilder.add_check_on_startup(checkOnStartup); + otaBuilder.add_update_channel(static_cast(updateChannel)); + builder.AddElement(Fbs::OtaUpdateConfig::VT_IS_ENABLED, static_cast(isEnabled)); + return otaBuilder.Finish(); +} + +bool OtaUpdateConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + isEnabled = json["isEnabled"].tryGetBool().value_or(true); + + cdnDomain = json["cdnDomain"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_CDN_DOMAIN); + + Parse::JsonStrParsed(updateChannel, json, "updateChannel"sv, OpenShock::TryParseOtaUpdateChannel, OpenShock::OtaUpdateChannel::Stable); + + checkOnStartup = json["checkOnStartup"].tryGetBool().value_or(false); + checkPeriodically = json["checkPeriodically"].tryGetBool().value_or(false); + checkInterval = json["checkInterval"].tryGetU16().value_or(30); + allowBackendManagement = json["allowBackendManagement"].tryGetBool().value_or(true); + requireManualApproval = json["requireManualApproval"].tryGetBool().value_or(false); + updateId = json["updateId"].tryGetI32().value_or(0); + + Parse::JsonStrParsed(updateStep, json, "updateStep"sv, OpenShock::TryParseOtaUpdateStep, OpenShock::OtaUpdateStep::None); + + return true; +} + +void OtaUpdateConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + json_gen_obj_set_bool(gen, "isEnabled", isEnabled); + JSON::objSetString(gen, "cdnDomain", cdnDomain); + JSON::objSetString(gen, "updateChannel", OpenShock::Serialization::Configuration::EnumNameOtaUpdateChannel(static_cast(updateChannel))); + json_gen_obj_set_bool(gen, "checkOnStartup", checkOnStartup); + json_gen_obj_set_bool(gen, "checkPeriodically", checkPeriodically); + json_gen_obj_set_int(gen, "checkInterval", checkInterval); + json_gen_obj_set_bool(gen, "allowBackendManagement", allowBackendManagement); + json_gen_obj_set_bool(gen, "requireManualApproval", requireManualApproval); + json_gen_obj_set_int(gen, "updateId", updateId); + JSON::objSetString(gen, "updateStep", OpenShock::Serialization::Configuration::EnumNameOtaUpdateStep(static_cast(updateStep))); + JSON::objEnd(gen, name); +} + +bool OtaUpdateConfig::LoadNvs(const char* partition) +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, false); + if (!handle) { + ToDefault(); + return true; + } + + uint8_t channel = std::to_underlying(OtaUpdateChannel::Stable); + uint8_t step = std::to_underlying(OtaUpdateStep::None); + bool ok = Nvs::Load(*handle, kKeyEnabled, isEnabled, true) && Nvs::Load(*handle, kKeyDomain, cdnDomain, CONFIG_OPENSHOCK_FW_CDN_DOMAIN) && Nvs::Load(*handle, kKeyChannel, channel, channel) && Nvs::Load(*handle, kKeyCheckBoot, checkOnStartup, false) + && Nvs::Load(*handle, kKeyCheckPeriod, checkPeriodically, false) && Nvs::Load(*handle, kKeyCheckMin, checkInterval, static_cast(30)) && Nvs::Load(*handle, kKeyBackendMgmt, allowBackendManagement, true) + && Nvs::Load(*handle, kKeyManual, requireManualApproval, false) && Nvs::Load(*handle, kKeyUpdateId, updateId, 0) && Nvs::Load(*handle, kKeyStep, step, step); + if (!ok) { + return false; + } + // Out-of-range enum values (bit flips, configs from newer firmware) fall back to the defaults + updateChannel = channel <= std::to_underlying(OtaUpdateChannel::Develop) ? static_cast(channel) : OtaUpdateChannel::Stable; + updateStep = step <= std::to_underlying(OtaUpdateStep::RollingBack) ? static_cast(step) : OtaUpdateStep::None; + return true; +} + +bool OtaUpdateConfig::SaveNvs(const char* partition, const OtaUpdateConfig* previous) const +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, true); + if (!handle) { + return false; + } + const uint8_t channel = std::to_underlying(updateChannel); + const uint8_t step = std::to_underlying(updateStep); + const uint8_t prevChannel = previous != nullptr ? std::to_underlying(previous->updateChannel) : 0; + const uint8_t prevStep = previous != nullptr ? std::to_underlying(previous->updateStep) : 0; + const bool p = previous != nullptr; + return handle->SetBool(kKeyEnabled, isEnabled, p ? &previous->isEnabled : nullptr) && handle->SetStr(kKeyDomain, cdnDomain, p ? &previous->cdnDomain : nullptr) && handle->SetU8(kKeyChannel, channel, p ? &prevChannel : nullptr) + && handle->SetBool(kKeyCheckBoot, checkOnStartup, p ? &previous->checkOnStartup : nullptr) && handle->SetBool(kKeyCheckPeriod, checkPeriodically, p ? &previous->checkPeriodically : nullptr) + && handle->SetU16(kKeyCheckMin, checkInterval, p ? &previous->checkInterval : nullptr) && handle->SetBool(kKeyBackendMgmt, allowBackendManagement, p ? &previous->allowBackendManagement : nullptr) + && handle->SetBool(kKeyManual, requireManualApproval, p ? &previous->requireManualApproval : nullptr) && handle->SetI32(kKeyUpdateId, updateId, p ? &previous->updateId : nullptr) && handle->SetU8(kKeyStep, step, p ? &prevStep : nullptr) + && handle->Commit(); +} + +std::optional OpenShock::Config::GetOtaUpdateConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetOtaUpdateConfig(const OtaUpdateConfig& config) +{ + return s_section.Set(config); +} + +bool OpenShock::Config::UpdateOtaUpdateConfig(Util::FunctionRef update) +{ + return s_section.Update(update); +} + +std::optional OpenShock::Config::GetOtaUpdateId() +{ + return s_section.Read([](const OtaUpdateConfig& ota) { return ota.updateId; }); +} + +bool OpenShock::Config::SetOtaUpdateId(int32_t updateId) +{ + return s_section.Update([&](OtaUpdateConfig& ota) { + if (ota.updateId == updateId) { + return false; + } + ota.updateId = updateId; + return true; + }); +} + +std::optional OpenShock::Config::GetOtaUpdateStep() +{ + return s_section.Read([](const OtaUpdateConfig& ota) { return ota.updateStep; }); +} + +bool OpenShock::Config::SetOtaUpdateStep(OpenShock::OtaUpdateStep updateStep) +{ + return s_section.Update([&](OtaUpdateConfig& ota) { + if (ota.updateStep == updateStep) { + return false; + } + ota.updateStep = updateStep; + return true; + }); +} + +bool OpenShock::Config::CompareExchangeOtaUpdateStep(OpenShock::OtaUpdateStep expected, OpenShock::OtaUpdateStep desired) +{ + // Writers are serialized by the write lock, so the step can't change between this check and the save + bool matched = false; + bool saved = s_section.Update([&](OtaUpdateConfig& ota) { + if (ota.updateStep != expected) { + return false; + } + matched = true; + if (expected == desired) { + return false; + } + ota.updateStep = desired; + return true; + }); + return matched && saved; +} diff --git a/components/core/src/http/JsonAPI.cpp b/components/core/src/http/JsonAPI.cpp index b332a1164..e9731e207 100644 --- a/components/core/src/http/JsonAPI.cpp +++ b/components/core/src/http/JsonAPI.cpp @@ -1,8 +1,10 @@ +#include + #include "http/JsonAPI.h" const char* const TAG = "JsonAPI"; -#include "config/Config.h" +#include "BackendConfig.h" #include "Logging.h" #include "OpenShock.h" #include "StringHelpers.h" diff --git a/components/core/src/serialization/WSLocal.cpp b/components/core/src/serialization/WSLocal.cpp index 555c804fd..e68ca2ad0 100644 --- a/components/core/src/serialization/WSLocal.cpp +++ b/components/core/src/serialization/WSLocal.cpp @@ -3,10 +3,10 @@ const char* const TAG = "WSLocal"; #include "Chipset.h" -#include "config/Config.h" -#include "config/WiFiAuthMode.h" +#include "config/Wire.h" #include "Logging.h" #include "util/HexUtils.h" +#include "wifi/WiFiAuthMode.h" #include "wifi/WiFiNetwork.h" #include "serialization/_fbs/HubToLocalMessage_generated.h" diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp new file mode 100644 index 000000000..ea94b01fe --- /dev/null +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -0,0 +1,453 @@ +#include + +#include "wifi/WiFiConfig.h" + +const char* const TAG = "Config::WiFiConfig"; + +#include "config/Nvs.h" +#include "config/Parse.h" +#include "Convert.h" +#include "Logging.h" + +#include "json/Json.h" + +#include +#include +#include +#include + +using namespace OpenShock::Config; +namespace Fbs = OpenShock::Serialization::Configuration; + +static constexpr const char* kNamespaceCfg = "wifi.cfg"; +static constexpr const char* kNamespaceCreds = "wifi"; +static constexpr const char* kKeyApSsid = "ap_ssid"; +static constexpr const char* kKeyHostname = "hostname"; + +static constexpr const char* kNamespaces[] = {kNamespaceCfg, kNamespaceCreds}; +static Section s_section({.name = "wifi", .order = 1, .hubConfigField = Fbs::HubConfig::VT_WIFI, .namespaces = kNamespaces}); + +Section& OpenShock::Config::WiFiSection() +{ + return s_section; +} + +WiFiConfig::WiFiConfig() + : accessPointSSID(CONFIG_OPENSHOCK_FW_AP_PREFIX) + , hostname(CONFIG_OPENSHOCK_FW_HOSTNAME) + , credentialsList() +{ +} + +WiFiConfig::WiFiConfig(std::string_view accessPointSSID, std::string_view hostname, const std::vector& credentialsList) + : accessPointSSID(accessPointSSID) + , hostname(hostname) + , credentialsList(credentialsList) +{ +} + +void WiFiConfig::ToDefault() +{ + accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; + hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; + credentialsList.clear(); +} + +flatbuffers::Offset WiFiConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + std::vector> fbsCredentialsList; + fbsCredentialsList.reserve(credentialsList.size()); + + for (auto& credentials : credentialsList) { + fbsCredentialsList.push_back(credentials.ToFlatbuffers(builder, withSensitiveData)); + } + + return Serialization::Configuration::CreateWiFiConfig(builder, builder.CreateString(accessPointSSID), builder.CreateString(hostname), builder.CreateVector(fbsCredentialsList)); +} + +bool WiFiConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + accessPointSSID = json["accessPointSSID"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_AP_PREFIX); + + hostname = json["hostname"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_HOSTNAME); + + JSON::JsonView credentialsListJson = json["credentials"]; + if (!credentialsListJson.valid()) { + OS_LOGE(TAG, "credentials is null"); + return false; + } + + if (!credentialsListJson.isArray()) { + OS_LOGE(TAG, "credentials is not an array"); + return false; + } + + Parse::JsonArray(credentialsList, credentialsListJson); + + return true; +} + +void WiFiConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + JSON::objSetString(gen, "accessPointSSID", accessPointSSID); + JSON::objSetString(gen, "hostname", hostname); + + json_gen_push_array(gen, "credentials"); + for (auto& credentials : credentialsList) { + credentials.ToJSON(gen, nullptr, withSensitiveData); + } + json_gen_pop_array(gen); + JSON::objEnd(gen, name); +} + +namespace { + // The NVS key of a credential: its id in decimal ("1".."255") + void credentialKey(uint8_t id, char (&key)[4]) + { + snprintf(key, sizeof(key), "%u", id); + } + + const WiFiCredentials* findById(const std::vector& list, uint8_t id) + { + for (const auto& creds : list) { + if (creds.id == id) return &creds; + } + return nullptr; + } + + bool sameCredentials(const WiFiCredentials& a, const WiFiCredentials& b) + { + return a.id == b.id && a.ssid == b.ssid && a.password == b.password && a.authMode == b.authMode && a.bssid == b.bssid; + } +} // namespace + +bool WiFiConfig::LoadNvs(const char* partition) +{ + if (auto handle = Nvs::Handle::Open(partition, kNamespaceCfg, false)) { + if (!Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { + return false; + } + } else { + accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; + hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; + } + + credentialsList.clear(); + auto handle = Nvs::Handle::Open(partition, kNamespaceCreds, false); + if (!handle) { + return true; // No credential was ever stored + } + + // Enumerated, never counted: the keys are the ids. Collected first, since reading while iterating is not allowed. + std::vector keys; + if (!handle->ForEachKey(NVS_TYPE_BLOB, [&](const char* key) { keys.emplace_back(key); })) { + OS_LOGE(TAG, "Failed to enumerate credentials"); + return false; + } + for (const auto& key : keys) { + auto id = Convert::ToUint8(key); + if (!id || *id == 0) { + OS_LOGW(TAG, "Ignoring credential under key %s", key.c_str()); + continue; + } + std::vector blob; + if (handle->GetBlob(key.c_str(), blob) != Nvs::Result::Found) { + return false; + } + WiFiCredentials creds; + if (!creds.FromBlob(blob) || creds.id != *id) { + OS_LOGW(TAG, "Dropping malformed credential %s", key.c_str()); + continue; + } + credentialsList.push_back(std::move(creds)); + } + std::sort(credentialsList.begin(), credentialsList.end(), [](const WiFiCredentials& a, const WiFiCredentials& b) { return a.id < b.id; }); + return true; +} + +bool WiFiConfig::SaveNvs(const char* partition, const WiFiConfig* previous) const +{ + { + auto handle = Nvs::Handle::Open(partition, kNamespaceCfg, true); + if (!handle) { + return false; + } + if (!handle->SetStr(kKeyApSsid, accessPointSSID, previous != nullptr ? &previous->accessPointSSID : nullptr) || !handle->SetStr(kKeyHostname, hostname, previous != nullptr ? &previous->hostname : nullptr) || !handle->Commit()) { + return false; + } + } + + auto handle = Nvs::Handle::Open(partition, kNamespaceCreds, true); + if (!handle) { + return false; + } + + // Credentials that are gone: the previous list's, or - with no previous list to go by - whatever is stored. + char key[4]; + if (previous != nullptr) { + for (const auto& old : previous->credentialsList) { + if (findById(credentialsList, old.id) == nullptr) { + credentialKey(old.id, key); + if (!handle->Erase(key)) return false; + } + } + } else { + std::vector stale; + if (!handle->ForEachKey( + NVS_TYPE_ANY, + [&](const char* stored) { + auto id = Convert::ToUint8(stored); + if (!id || findById(credentialsList, *id) == nullptr) stale.emplace_back(stored); + } + )) + { + return false; + } + for (const auto& stored : stale) { + if (!handle->Erase(stored.c_str())) return false; + } + } + + for (const auto& creds : credentialsList) { + const WiFiCredentials* old = previous != nullptr ? findById(previous->credentialsList, creds.id) : nullptr; + if (old != nullptr && sameCredentials(*old, creds)) { + continue; + } + credentialKey(creds.id, key); + if (!handle->SetBlob(key, creds.ToBlob())) { + return false; + } + } + return handle->Commit(); +} + +// 802.11 SSIDs are 1-32 bytes; WPA passphrases are up to 63 characters, or 64 for a raw hex PSK. +static bool isValidWiFiCredentials(std::string_view ssid, std::string_view password) +{ + if (ssid.empty() || ssid.size() > 32) { + OS_LOGE(TAG, "Invalid SSID length %zu", ssid.size()); + return false; + } + if (password.size() > 64) { + OS_LOGE(TAG, "Invalid password length %zu", password.size()); + return false; + } + return true; +} + +std::optional OpenShock::Config::GetWiFiConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetWiFiConfig(const WiFiConfig& config) +{ + return s_section.Set(config); +} + +std::optional> OpenShock::Config::GetWiFiCredentials() +{ + return s_section.Read([](const WiFiConfig& wifi) { return wifi.credentialsList; }); +} + +bool OpenShock::Config::GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData) +{ + s_section.Read([&](const WiFiConfig& wifi) { + for (const auto& creds : wifi.credentialsList) { + creds.ToJSON(gen, nullptr, withSensitiveData); + } + }); + return true; +} + +bool OpenShock::Config::SetWiFiCredentials(const std::vector& credentials) +{ + std::bitset<256> seenIds; + for (const auto& creds : credentials) { + if (creds.id == 0) { + OS_LOGE(TAG, "Cannot set WiFi credentials: credential ID cannot be 0"); + return false; + } + if (seenIds[creds.id]) { + OS_LOGE(TAG, "Cannot set WiFi credentials: duplicate credential ID %u", creds.id); + return false; + } + seenIds[creds.id] = true; + + if (!isValidWiFiCredentials(creds.ssid, creds.password)) { + return false; + } + } + + return s_section.Update([&](WiFiConfig& wifi) { + wifi.credentialsList = credentials; + return true; + }); +} + +bool OpenShock::Config::AnyWiFiCredentials(Util::FunctionRef predicate) +{ + // The predicate is caller code, so run it on a copy outside the lock + auto creds = GetWiFiCredentials(); + return std::any_of(creds->begin(), creds->end(), predicate); +} + +uint8_t OpenShock::Config::AddWiFiCredentials(std::string_view ssid, std::string_view password, wifi_auth_mode_t authMode) +{ + if (!isValidWiFiCredentials(ssid, password)) { + return 0; + } + + uint8_t id = 0; + bool stored = s_section.Update([&](WiFiConfig& wifi) { + auto& list = wifi.credentialsList; + + std::bitset<255> bits; + for (auto it = list.begin(); it != list.end();) { + auto& creds = *it; + + if (std::string_view(creds.ssid) == ssid) { + creds.password = password; + if (authMode != WIFI_AUTH_MAX) { + creds.authMode = authMode; + } + id = creds.id; + return true; + } + + if (creds.id == 0) { + OS_LOGW(TAG, "Found WiFi credentials with ID 0, removing"); + it = list.erase(it); + continue; + } + + // Mark ID as used + bits[creds.id - 1] = true; + ++it; + } + + // First available ID + for (std::size_t i = 0; i < bits.size(); ++i) { + if (!bits[i]) { + id = static_cast(i + 1); + break; + } + } + if (id == 0) { + OS_LOGE(TAG, "Failed to add WiFi credentials: no available IDs"); + return false; + } + + list.emplace_back(id, ssid, password, authMode); + return true; + }); + + // Only credentials that made it to flash count. The partition is the cap: once NVS has no room for another blob + // the save fails and the list stays as it was. + if (!stored) { + OS_LOGE(TAG, "Failed to persist WiFi credentials for SSID %.*s", static_cast(ssid.size()), ssid.data()); + return 0; + } + return id; +} + +std::optional OpenShock::Config::TryGetWiFiCredentialsByID(uint8_t id) +{ + return s_section.Read([&](const WiFiConfig& wifi) -> std::optional { + for (const auto& creds : wifi.credentialsList) { + if (creds.id == id) { + return creds; + } + } + return std::nullopt; + }); +} + +std::optional OpenShock::Config::TryGetWiFiCredentialsBySSID(const char* ssid) +{ + return s_section.Read([&](const WiFiConfig& wifi) -> std::optional { + for (const auto& creds : wifi.credentialsList) { + if (creds.ssid == ssid) { + return creds; + } + } + return std::nullopt; + }); +} + +uint8_t OpenShock::Config::GetWiFiCredentialsIDbySSID(const char* ssid) +{ + return s_section.Read([&](const WiFiConfig& wifi) -> uint8_t { + for (const auto& creds : wifi.credentialsList) { + if (creds.ssid == ssid) { + return creds.id; + } + } + return 0; + }); +} + +bool OpenShock::Config::PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]) +{ + bool found = false; + bool saved = s_section.Update([&](WiFiConfig& wifi) { + for (auto& creds : wifi.credentialsList) { + if (creds.id == id) { + memcpy(creds.bssid.data(), bssid, 6); + found = true; + return true; + } + } + return false; + }); + return found && saved; +} + +bool OpenShock::Config::RemoveWiFiCredentials(uint8_t id) +{ + bool found = false; + bool saved = s_section.Update([&](WiFiConfig& wifi) { + auto& list = wifi.credentialsList; + auto it = std::find_if(list.begin(), list.end(), [id](const WiFiCredentials& creds) { return creds.id == id; }); + if (it == list.end()) { + return false; + } + list.erase(it); + found = true; + return true; + }); + return found && saved; +} + +bool OpenShock::Config::ClearWiFiCredentials() +{ + return s_section.Update([](WiFiConfig& wifi) { + wifi.credentialsList.clear(); + return true; + }); +} + +std::optional OpenShock::Config::GetWiFiHostname() +{ + return s_section.Read([](const WiFiConfig& wifi) { return wifi.hostname; }); +} + +bool OpenShock::Config::SetWiFiHostname(std::string hostname) +{ + return s_section.Update([&](WiFiConfig& wifi) { + wifi.hostname = std::move(hostname); + return true; + }); +} diff --git a/components/core/src/wifi/WiFiCredentials.cpp b/components/core/src/wifi/WiFiCredentials.cpp new file mode 100644 index 000000000..6b7218a3d --- /dev/null +++ b/components/core/src/wifi/WiFiCredentials.cpp @@ -0,0 +1,228 @@ +#include "wifi/WiFiCredentials.h" + +const char* const TAG = "Config::WiFiCredentials"; + +#include "Logging.h" +#include "wifi/WiFiAuthMode.h" + +#include "util/HexUtils.h" +#include "json/Json.h" + +#include +#include +#include + +using namespace OpenShock::Config; +using FbsAuthMode = OpenShock::Serialization::Types::WifiAuthMode; + +WiFiCredentials::WiFiCredentials() + : id(0) + , ssid() + , password() + , authMode(WIFI_AUTH_MAX) + , bssid({0}) +{ +} + +WiFiCredentials::WiFiCredentials(uint8_t id, std::string_view ssid, std::string_view password, wifi_auth_mode_t authMode) + : id(id) + , ssid(ssid) + , password(password) + , authMode(authMode) + , bssid({0}) +{ +} + +bool WiFiCredentials::HasPinnedBSSID() const +{ + for (auto b : bssid) { + if (b != 0) return true; + } + return false; +} + +void WiFiCredentials::ToDefault() +{ + id = 0; + ssid.clear(); + password.clear(); + authMode = WIFI_AUTH_MAX; + bssid.fill(0); +} + +flatbuffers::Offset WiFiCredentials::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + auto ssidOffset = builder.CreateString(ssid); + + flatbuffers::Offset passwordOffset; + if (withSensitiveData) { + passwordOffset = builder.CreateString(password); + } else { + passwordOffset = 0; + } + + const Serialization::Configuration::MacAddress* bssidPtr = nullptr; + Serialization::Configuration::MacAddress bssidStruct; + if (HasPinnedBSSID()) { + bssidStruct = Serialization::Configuration::MacAddress(flatbuffers::span(bssid.data(), 6)); + bssidPtr = &bssidStruct; + } + + return Serialization::Configuration::CreateWiFiCredentials(builder, id, ssidOffset, passwordOffset, ToFbsAuthMode(authMode), bssidPtr); +} + +bool WiFiCredentials::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + id = json["id"].tryGetU8().value_or(0); + + ssid = json["ssid"].tryGetStr().value_or(std::string()); + + password = json["password"].tryGetStr().value_or(std::string()); + + uint8_t authModeVal = json["authMode"].tryGetU8().value_or(std::to_underlying(FbsAuthMode::UNKNOWN)); + authMode = FromFbsAuthMode(static_cast(authModeVal)); + + bssid.fill(0); + if (auto bssidStr = json["bssid"].tryGetStr(); bssidStr && bssidStr->size() == 12) { + // Parse into a temporary: TryParseHex writes byte by byte and stops at the first bad pair, and a partial + // BSSID would pin the network to an AP that doesn't exist. + std::array parsed {}; + if (HexUtils::TryParseHex(*bssidStr, parsed) == parsed.size()) { + bssid = parsed; + } + } + + if (ssid.empty()) { + OS_LOGE(TAG, "ssid is empty"); + return false; + } + + return true; +} + +void WiFiCredentials::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + json_gen_obj_set_int(gen, "id", id); + JSON::objSetString(gen, "ssid", ssid); + if (withSensitiveData) { + JSON::objSetString(gen, "password", password); + } + json_gen_obj_set_int(gen, "authMode", std::to_underlying(ToFbsAuthMode(authMode))); + if (HasPinnedBSSID()) { + char hex[13]; + for (std::size_t i = 0; i < bssid.size(); ++i) { + HexUtils::ToHex(bssid[i], &hex[i * 2]); + } + hex[12] = '\0'; + JSON::objSetString(gen, "bssid", hex); + } + JSON::objEnd(gen, name); +} + +// Blob layout, version 1: version u8, id u8, auth mode u8 (kBlobAuthModes), bssid u8[6] (all zero: no pin), ssid +// length u8, ssid, password length u8, password. +// +// Versioning: a new field is appended and the version stays 1, since a reader ignores whatever follows the fields it +// knows; firmware rolled back to an older build then still reads every credential. The version changes only for a +// change an older reader must not misread, and that costs a rolled-back hub its saved networks. +static constexpr uint8_t kBlobVersion = 1; + +// The auth mode codes the blob stores. Owned by this format alone: neither ESP-IDF's wifi_auth_mode_t nor the +// FlatBuffers schema may renumber what is on flash. Never reuse or renumber a code; append new ones. +static constexpr std::pair kBlobAuthModes[] = { + {0, WIFI_AUTH_OPEN}, + {1, WIFI_AUTH_WEP}, + {2, WIFI_AUTH_WPA_PSK}, + {3, WIFI_AUTH_WPA2_PSK}, + {4, WIFI_AUTH_WPA_WPA2_PSK}, + {5, WIFI_AUTH_WPA2_ENTERPRISE}, + {6, WIFI_AUTH_WPA3_PSK}, + {7, WIFI_AUTH_WPA2_WPA3_PSK}, + {8, WIFI_AUTH_WAPI_PSK}, +}; +static constexpr uint8_t kBlobAuthUnknown = 9; + +static uint8_t toBlobAuthMode(wifi_auth_mode_t mode) +{ + for (const auto& [code, known] : kBlobAuthModes) { + if (known == mode) { + return code; + } + } + return kBlobAuthUnknown; +} + +static wifi_auth_mode_t fromBlobAuthMode(uint8_t code) +{ + for (const auto& [known, mode] : kBlobAuthModes) { + if (known == code) { + return mode; + } + } + return WIFI_AUTH_MAX; +} + +std::vector WiFiCredentials::ToBlob() const +{ + std::vector blob; + blob.reserve(11 + ssid.size() + password.size()); + blob.push_back(kBlobVersion); + blob.push_back(id); + blob.push_back(toBlobAuthMode(authMode)); + blob.insert(blob.end(), bssid.begin(), bssid.end()); + blob.push_back(static_cast(std::min(ssid.size(), 255))); + blob.insert(blob.end(), ssid.begin(), ssid.begin() + std::min(ssid.size(), 255)); + blob.push_back(static_cast(std::min(password.size(), 255))); + blob.insert(blob.end(), password.begin(), password.begin() + std::min(password.size(), 255)); + return blob; +} + +bool WiFiCredentials::FromBlob(std::span blob) +{ + if (blob.size() < 11 || blob[0] != kBlobVersion) { + OS_LOGE(TAG, "Unknown credential blob (%zu bytes, version %u)", blob.size(), blob.empty() ? 0u : blob[0]); + return false; + } + std::size_t pos = 1; + uint8_t storedId = blob[pos++]; + uint8_t storedAuth = blob[pos++]; + std::array storedBssid; + std::copy_n(blob.begin() + pos, 6, storedBssid.begin()); + pos += 6; + const std::size_t ssidLen = blob[pos++]; + if (blob.size() < pos + ssidLen + 1) { + OS_LOGE(TAG, "Credential blob truncated"); + return false; + } + std::string storedSsid(reinterpret_cast(blob.data() + pos), ssidLen); + pos += ssidLen; + const std::size_t pwLen = blob[pos++]; + if (blob.size() < pos + pwLen) { + OS_LOGE(TAG, "Credential blob truncated"); + return false; + } + std::string storedPassword(reinterpret_cast(blob.data() + pos), pwLen); + if (storedId == 0 || storedSsid.empty()) { + OS_LOGE(TAG, "Credential blob has no id or no ssid"); + return false; + } + + id = storedId; + ssid = std::move(storedSsid); + password = std::move(storedPassword); + authMode = fromBlobAuthMode(storedAuth); + bssid = storedBssid; + return true; +} diff --git a/components/core/src/wifi/WiFiManager.cpp b/components/core/src/wifi/WiFiManager.cpp index 997488499..df82a77e2 100644 --- a/components/core/src/wifi/WiFiManager.cpp +++ b/components/core/src/wifi/WiFiManager.cpp @@ -5,7 +5,6 @@ const char* const TAG = "WiFiManager"; #include "AppHooks.h" -#include "config/Config.h" #include "events/Events.h" #include "FormatHelpers.h" #include "Logging.h" @@ -13,6 +12,7 @@ const char* const TAG = "WiFiManager"; #include "Temporal.h" #include "util/FunctionRef.h" #include "util/TaskUtils.h" +#include "wifi/WiFiConfig.h" #include "wifi/WiFiNetwork.h" #include "wifi/WiFiScanManager.h" diff --git a/components/device_control/CMakeLists.txt b/components/device_control/CMakeLists.txt index bc4f3bf32..19b63b478 100644 --- a/components/device_control/CMakeLists.txt +++ b/components/device_control/CMakeLists.txt @@ -12,7 +12,8 @@ idf_component_register( SRCS ${device_control_sources} INCLUDE_DIRS "include" REQUIRES common esp_hal_gpio esp_driver_rmt freertos # public headers (enums, gpio_types, rmt_tx/encoder, TaskHandle/QueueHandle) - PRIV_REQUIRES config chipset events led_drivers logging temporal protocols # concurrency/util folded into common (public REQUIRE) + config serialization # its config sections (radio/RFConfig.h, estop/EStopConfig.h): config/Section.h, HubConfig wire types + PRIV_REQUIRES chipset events led_drivers logging temporal protocols # concurrency/util folded into common (public REQUIRE) esp_driver_gpio esp_wifi esp_event esp_netif soc ) diff --git a/components/device_control/host_test/main/CMakeLists.txt b/components/device_control/host_test/main/CMakeLists.txt index eb931c8fa..955331b88 100644 --- a/components/device_control/host_test/main/CMakeLists.txt +++ b/components/device_control/host_test/main/CMakeLists.txt @@ -1,7 +1,8 @@ # Compile device_control's CommandHandler + RFTransmitter (and what they use from # protocols, common and events) directly, against the real OpenShock and ESP-IDF # headers. The IDF APIs are CMock mocks whose callbacks (host_fakes.cpp) step a fake -# clock; config (stubs/config/Config.h), TaskUtils and EStopManager are faked in +# clock; the rf and estop config sections (stubs/radio/RFConfig.h, stubs/estop/EStopConfig.h), +# TaskUtils and EStopManager are faked in # host_fakes.cpp too, so a test can run the transmit/keep-alive task loops # deterministically. EStopManager's debounce/hold-to-clear logic is tested through # the pure EStopStateMachine.h. @@ -22,7 +23,7 @@ idf_component_register( "../../../protocols/src/PetrainerEncoder.cpp" "../../../protocols/src/T330Encoder.cpp" INCLUDE_DIRS "." - "stubs" # config/Config.h (fake RF/E-Stop settings) + "stubs" # radio/RFConfig.h, estop/EStopConfig.h (fake settings; shadow the real ones) "../../src" # estop/EStopStateMachine.h "../../include" # CommandHandler.h, estop/*, radio/RFTransmitter.h "../../../chipset/include" # Chipset.h diff --git a/components/device_control/host_test/main/host_fakes.h b/components/device_control/host_test/main/host_fakes.h index 3921bde8e..8ba124807 100644 --- a/components/device_control/host_test/main/host_fakes.h +++ b/components/device_control/host_test/main/host_fakes.h @@ -13,8 +13,9 @@ // A timed receive on an empty queue advances the fake clock by its timeout (inside a // task, a zero-timeout poll by 1 ms), and every RMT frame advances it by RmtFrameMs, so // task loops replay deterministically. -#include "config/Config.h" +#include "estop/EStopConfig.h" #include "events/Events.h" +#include "radio/RFConfig.h" #include #include diff --git a/components/device_control/host_test/main/stubs/estop/EStopConfig.h b/components/device_control/host_test/main/stubs/estop/EStopConfig.h new file mode 100644 index 000000000..71c27ac15 --- /dev/null +++ b/components/device_control/host_test/main/stubs/estop/EStopConfig.h @@ -0,0 +1,17 @@ +#pragma once + +// Host-test stub for device_control's estop/EStopConfig.h: the estop section's value and the accessors CommandHandler +// calls, backed by a plain value in host_fakes.cpp (HostFake::EStopCfg) instead of the config framework. +#include + +#include + +namespace OpenShock::Config { + struct EStopConfig { + bool enabled; + gpio_num_t gpioPin; + }; + + [[nodiscard]] std::optional GetEStopConfig(); + [[nodiscard]] std::optional GetEStopGpioPin(); +} // namespace OpenShock::Config diff --git a/components/device_control/host_test/main/stubs/config/Config.h b/components/device_control/host_test/main/stubs/radio/RFConfig.h similarity index 57% rename from components/device_control/host_test/main/stubs/config/Config.h rename to components/device_control/host_test/main/stubs/radio/RFConfig.h index 90efbb652..c1528a52e 100644 --- a/components/device_control/host_test/main/stubs/config/Config.h +++ b/components/device_control/host_test/main/stubs/radio/RFConfig.h @@ -1,7 +1,7 @@ #pragma once -// Host-test stub for config's Config.h: just the RF/EStop accessors device_control -// calls, backed by plain values in host_fakes.cpp (HostFake::RfConfig/EStopCfg). +// Host-test stub for device_control's radio/RFConfig.h: the rf section's value and the accessors CommandHandler and +// RFTransmitter call, backed by a plain value in host_fakes.cpp (HostFake::RfConfig) instead of the config framework. #include #include @@ -12,17 +12,9 @@ namespace OpenShock::Config { bool keepAliveEnabled; }; - struct EStopConfig { - bool enabled; - gpio_num_t gpioPin; - }; - [[nodiscard]] std::optional GetRFConfig(); [[nodiscard]] std::optional GetRFConfigTxPin(); bool SetRFConfigTxPin(gpio_num_t txPin); [[nodiscard]] std::optional GetRFConfigKeepAliveEnabled(); bool SetRFConfigKeepAliveEnabled(bool enabled); - - [[nodiscard]] std::optional GetEStopConfig(); - [[nodiscard]] std::optional GetEStopGpioPin(); } // namespace OpenShock::Config diff --git a/components/device_control/include/estop/EStopConfig.h b/components/device_control/include/estop/EStopConfig.h new file mode 100644 index 000000000..ad31d2952 --- /dev/null +++ b/components/device_control/include/estop/EStopConfig.h @@ -0,0 +1,68 @@ +#pragma once + +// The `estop` config section: whether the E-Stop input is used and on which pin. Owned by device_control and kept in +// NVS through the config framework (config/Section.h); NVS namespace "estop", keys enabled (u8) and gpio (i8). +// +// The same namespace holds the E-Stop latch, `latched` (u8): whether the E-Stop was active when it was last saved, so +// a reboot or power cut does not release it. It is state, not config: not part of the section's value, never in +// jsonconfig or the wire form. Keeping it in the section's namespace moves it with the store; a factory reset (which +// erases the section's namespaces) clears it. + +#include "config/Section.h" +#include "serialization/_fbs/HubConfig_generated.h" + +#include + +#include +#include + +namespace OpenShock::JSON { + class JsonView; +} + +namespace OpenShock::Config { + // The schema's `active` and `latching` fields are not modelled: nothing reads them. + struct EStopConfig { + EStopConfig(); + EStopConfig(bool enabled, gpio_num_t gpioPin); + + bool enabled; + gpio_num_t gpioPin; + + void ToDefault(); + + /// @brief Disables the E-Stop when its pin can't be used as an input, so every input path stores the same thing. + void Normalize(); + + bool LoadNvs(const char* partition); + bool SaveNvs(const char* partition, const EStopConfig* previous) const; + + bool FromJSON(JSON::JsonView json); + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; + + [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; + }; + + Section& EStopSection(); + + [[nodiscard]] std::optional GetEStopConfig(); + bool SetEStopConfig(const EStopConfig& config); + [[nodiscard]] std::optional GetEStopEnabled(); + bool SetEStopEnabled(bool enabled); + [[nodiscard]] std::optional GetEStopGpioPin(); + bool SetEStopGpioPin(gpio_num_t gpioPin); + + /// @brief Whether the stored E-Stop latch is set. A missing key, or a hub whose config only lives in RAM, reads as + /// not latched (a fresh hub must start usable); a read error reads as latched. + [[nodiscard]] bool LoadEStopLatched(); + + enum class LatchSave : uint8_t { + Saved, // on flash (or nothing to keep: the config only lives in RAM) + Busy, // another config writer holds the write lock; nothing was written, try again later + Failed, // the write failed + }; + + /// @brief Stores the E-Stop latch. Never waits for another config writer (the layout migration holds the write lock + /// across closing NVS), so the E-Stop task can call it between samples. Never call it from an ISR. + [[nodiscard]] LatchSave SaveEStopLatched(bool latched); +} // namespace OpenShock::Config diff --git a/components/device_control/include/estop/EStopManager.h b/components/device_control/include/estop/EStopManager.h index 16fc76889..8180f28a2 100644 --- a/components/device_control/include/estop/EStopManager.h +++ b/components/device_control/include/estop/EStopManager.h @@ -7,6 +7,9 @@ #include namespace OpenShock::EStopManager { + /// @brief Restores an E-Stop latched before the restart (the `latched` key of the estop section, estop/EStopConfig.h) + /// and starts sampling the input. Call before anything can transmit. While the task runs, every activation and + /// every clear is written to that key, so a reboot or power cut never releases an active E-Stop. [[nodiscard]] bool Init(); // Both apply the change to the running E-Stop and then persist it to the config (like CommandHandler::SetRfTxPin), // so callers don't have to keep the two in sync. They refuse while the E-Stop is active. diff --git a/components/device_control/include/radio/RFConfig.h b/components/device_control/include/radio/RFConfig.h new file mode 100644 index 000000000..9d833b53b --- /dev/null +++ b/components/device_control/include/radio/RFConfig.h @@ -0,0 +1,45 @@ +#pragma once + +// The `rf` config section: the radio transmitter's pin and keep-alive. Owned by device_control and kept in NVS +// through the config framework (config/Section.h); NVS namespace "rf", keys tx_pin (i8) and keepalive (u8). + +#include "config/Section.h" +#include "serialization/_fbs/HubConfig_generated.h" + +#include + +#include + +namespace OpenShock::JSON { + class JsonView; +} + +namespace OpenShock::Config { + struct RFConfig { + RFConfig(); + RFConfig(gpio_num_t txPin, bool keepAliveEnabled); + + gpio_num_t txPin; + bool keepAliveEnabled; + + void ToDefault(); + + bool LoadNvs(const char* partition); + bool SaveNvs(const char* partition, const RFConfig* previous) const; + + bool FromJSON(JSON::JsonView json); + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; + + [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; + }; + + /// @brief The section itself, for whole-value reads and read-modify-writes. + Section& RFSection(); + + [[nodiscard]] std::optional GetRFConfig(); + bool SetRFConfig(const RFConfig& config); + [[nodiscard]] std::optional GetRFConfigTxPin(); + bool SetRFConfigTxPin(gpio_num_t txPin); + [[nodiscard]] std::optional GetRFConfigKeepAliveEnabled(); + bool SetRFConfigKeepAliveEnabled(bool enabled); +} // namespace OpenShock::Config diff --git a/components/device_control/src/CommandHandler.cpp b/components/device_control/src/CommandHandler.cpp index b68b44616..7f254691c 100644 --- a/components/device_control/src/CommandHandler.cpp +++ b/components/device_control/src/CommandHandler.cpp @@ -6,9 +6,10 @@ const char* const TAG = "CommandHandler"; #include "BoardDescriptor.h" #include "Chipset.h" -#include "config/Config.h" +#include "estop/EStopConfig.h" #include "estop/EStopManager.h" #include "Logging.h" +#include "radio/RFConfig.h" #include "OpenShock.h" #include "radio/RFTransmitter.h" diff --git a/components/device_control/src/EStopManager.cpp b/components/device_control/src/EStopManager.cpp index 4cb66e676..a99f3a601 100644 --- a/components/device_control/src/EStopManager.cpp +++ b/components/device_control/src/EStopManager.cpp @@ -5,7 +5,7 @@ const char* const TAG = "EStopManager"; #include "Chipset.h" -#include "config/Config.h" +#include "estop/EStopConfig.h" #include "estop/EStopStateMachine.h" #include "events/Events.h" #include "Logging.h" @@ -18,8 +18,10 @@ const char* const TAG = "EStopManager"; #include #include +#include #include #include +#include using namespace OpenShock; @@ -47,6 +49,13 @@ static std::atomic s_externallyTriggered = false; static bool s_estopInitialized = false; +// The E-Stop latch as last written to (or read from) the config store; nullopt when that is not known. Owned by Init +// until the task starts, then by the task: one task runs at a time, and start and stop are serialized by s_estopMutex. +static std::optional s_latchStored = std::nullopt; + +// How long to wait before writing the latch again after a write failed (a busy store is retried on the next sample). +const int64_t k_latchRetryInterval = 1000; + static void estopmgr_publishState(EStopState state, EStopState& lastState, TickType_t timeout = pdMS_TO_TICKS(750)) { if (state == lastState) { @@ -65,13 +74,36 @@ static void estopmgr_publishState(EStopState state, EStopState& lastState, TickT static void estopmgr_latchWithoutTask(); +// Stores the latch when it differs from what is stored. Returns whether it touched flash, which takes the task off its +// fixed sample rate for the length of the write. +static bool estopmgr_storeLatch(bool latched, int64_t now, int64_t& nextAttempt) +{ + if (s_latchStored == latched || now < nextAttempt) { + return false; + } + + switch (Config::SaveEStopLatched(latched)) { + case Config::LatchSave::Saved: + s_latchStored = latched; + return true; + case Config::LatchSave::Busy: + return false; // Another config writer: try again on the next sample + case Config::LatchSave::Failed: + default: + OS_LOGE(TAG, "Failed to store the E-Stop latch (%s), retrying", latched ? "latched" : "released"); + nextAttempt = now + k_latchRetryInterval; + return true; + } +} + // Samples the estop at a fixed rate and updates internal state + events static void estopmgr_managerTask(gpio_num_t estopPin) { EStopStateMachine machine; EStopState lastPublishedState = EStopState::Idle; - // Carry over an E-Stop latched while no task was running (software trigger); it must be cleared with the button. + // Carry over an E-Stop latched while no task was running (software trigger, or the latch stored before a reboot); + // it must be cleared with the button. int64_t latchedAt = s_estopActivatedAt.load(std::memory_order_relaxed); if (latchedAt != 0) { machine.Trigger(latchedAt); @@ -82,6 +114,8 @@ static void estopmgr_managerTask(gpio_num_t estopPin) // Fed every 5 ms sample, so only a real hang (not a slow post or log) can trip it. TaskUtils::TaskWatchdog watchdog; + int64_t nextLatchAttempt = 0; + // Check if killing manager was requested, continue looping otherwise TickType_t lastWake = xTaskGetTickCount(); while (!s_estopTask.stopRequested()) { @@ -110,6 +144,14 @@ static void estopmgr_managerTask(gpio_num_t estopPin) } estopmgr_publishState(machine.state(), lastPublishedState); + + // Only once the state is mirrored and published: the E-Stop is in effect before its latch reaches flash, and a + // slow write never delays it. + if (estopmgr_storeLatch(machine.activatedAt() != 0, now, nextLatchAttempt)) { + // Resume the fixed rate from here instead of catching up with back-to-back samples, which would shorten the + // debounce window. + lastWake = xTaskGetTickCount(); + } } // An active E-Stop survives the stop: s_estopActivatedAt stays set, and the next task takes it over (or, with no @@ -228,12 +270,33 @@ bool EStopManager::Init() return false; } + // Read before anything can transmit: CommandHandler and the RF transmitter start after this. + bool latched = OpenShock::Config::LoadEStopLatched(); + s_latchStored = latched; + if (!cfg->enabled) { + if (latched) { + // Without an input there is no button to clear it, so it would stop the hub for good. Drop it. + OS_LOGW(TAG, "E-Stop was latched, but the E-Stop is disabled; releasing the latch"); + if (OpenShock::Config::SaveEStopLatched(false) == OpenShock::Config::LatchSave::Saved) { + s_latchStored = false; + } + } return true; } OpenShock::ScopedLock lock__(&s_estopMutex); + if (latched) { + // Active from here on, through IsEStopped(); the task takes it over and it is cleared with the button. Even if the + // input can't be set up below, it stays active until a reboot that can. + OS_LOGW(TAG, "E-Stop was latched before the restart, keeping it active until it is cleared with the button"); + s_estopActivatedAt.store(std::max(OpenShock::millis(), 1)); + s_estopActive.store(true); + EStopState lastState = EStopState::Idle; + estopmgr_publishState(EStopState::Active, lastState); + } + if (!estopmgr_configurePin(cfg->gpioPin)) { return false; } diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp new file mode 100644 index 000000000..9cd046162 --- /dev/null +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -0,0 +1,231 @@ +#include + +#include "estop/EStopConfig.h" + +const char* const TAG = "Config::EStopConfig"; + +#include "BoardDescriptor.h" +#include "Chipset.h" +#include "config/Nvs.h" +#include "config/Parse.h" +#include "Logging.h" + +#include "json/Json.h" + +using namespace OpenShock::Config; +namespace Fbs = OpenShock::Serialization::Configuration; + +static constexpr const char* kNamespace = "estop"; +static constexpr const char* kKeyEnabled = "enabled"; +static constexpr const char* kKeyGpio = "gpio"; +static constexpr const char* kKeyLatched = "latched"; + +static constexpr const char* kNamespaces[] = {kNamespace}; +static Section s_section({.name = "estop", .order = 6, .hubConfigField = Fbs::HubConfig::VT_ESTOP, .namespaces = kNamespaces}); + +Section& OpenShock::Config::EStopSection() +{ + return s_section; +} + +EStopConfig::EStopConfig() + : enabled(OpenShock::IsValidInputPin(Board::Get().estopGpio)) + , gpioPin(static_cast(Board::Get().estopGpio)) +{ +} + +EStopConfig::EStopConfig(bool enabled, gpio_num_t gpioPin) + : enabled(enabled) + , gpioPin(gpioPin) +{ +} + +void EStopConfig::ToDefault() +{ + enabled = OpenShock::IsValidInputPin(Board::Get().estopGpio); + gpioPin = static_cast(Board::Get().estopGpio); +} + +void EStopConfig::Normalize() +{ + // An E-Stop on a pin that can't be read as an input would never trigger; store it as disabled on every path. + if (enabled && !OpenShock::IsValidInputPin(gpioPin)) { + OS_LOGW(TAG, "E-Stop pin %d is not a valid input pin, disabling E-Stop", gpioPin); + enabled = false; + } +} + +bool EStopConfig::LoadNvs(const char* partition) +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, false); + if (!handle) { + ToDefault(); + return true; + } + + const int8_t boardPin = static_cast(Board::Get().estopGpio); + int8_t storedPin = boardPin; + if (!Nvs::Load(*handle, kKeyGpio, storedPin, boardPin)) { + return false; + } + // Anything that isn't "no pin" or a real GPIO means a corrupt value, so the whole section falls back. + auto pin = Parse::GpioNum(storedPin); + if (!pin) { + OS_LOGW(TAG, "Invalid E-Stop GPIO pin %d, using default", storedPin); + ToDefault(); + return true; + } + gpioPin = *pin; + if (!Nvs::Load(*handle, kKeyEnabled, enabled, OpenShock::IsValidInputPin(gpioPin))) { + return false; + } + Normalize(); + return true; +} + +bool EStopConfig::SaveNvs(const char* partition, const EStopConfig* previous) const +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, true); + if (!handle) { + return false; + } + const int8_t pin = static_cast(gpioPin); + const int8_t prevPin = previous != nullptr ? static_cast(previous->gpioPin) : 0; + return handle->SetBool(kKeyEnabled, enabled, previous != nullptr ? &previous->enabled : nullptr) && handle->SetI8(kKeyGpio, pin, previous != nullptr ? &prevPin : nullptr) && handle->Commit(); +} + +bool EStopConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + gpioPin = Parse::JsonGpioNum(json, "gpioPin").value_or(static_cast(Board::Get().estopGpio)); + + if (JSON::JsonView enabledJson = json["enabled"]; enabledJson.valid()) { + auto parsed = enabledJson.tryGetBool(); + if (!parsed) { + OS_LOGE(TAG, "Failed to parse enabled"); + return false; + } + enabled = *parsed; + } else { + enabled = OpenShock::IsValidInputPin(gpioPin); + } + Normalize(); + + return true; +} + +void EStopConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + (void)withSensitiveData; + JSON::objBegin(gen, name); + json_gen_obj_set_bool(gen, "enabled", enabled); + json_gen_obj_set_int(gen, "gpioPin", gpioPin); + JSON::objEnd(gen, name); +} + +flatbuffers::Offset EStopConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + (void)withSensitiveData; + return Fbs::CreateEStopConfig(builder, enabled, gpioPin); +} + +std::optional OpenShock::Config::GetEStopConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetEStopConfig(const EStopConfig& config) +{ + return s_section.Update([&](EStopConfig& estop) { + estop = config; + estop.Normalize(); + return true; + }); +} + +std::optional OpenShock::Config::GetEStopEnabled() +{ + return s_section.Read([](const EStopConfig& estop) { return estop.enabled; }); +} + +bool OpenShock::Config::SetEStopEnabled(bool enabled) +{ + return s_section.Update([&](EStopConfig& estop) { + estop.enabled = enabled; + return true; + }); +} + +std::optional OpenShock::Config::GetEStopGpioPin() +{ + return s_section.Read([](const EStopConfig& estop) { return estop.gpioPin; }); +} + +bool OpenShock::Config::SetEStopGpioPin(gpio_num_t gpioPin) +{ + if (!OpenShock::IsValidInputPin(gpioPin)) { + OS_LOGE(TAG, "Invalid EStop GPIO Pin: %d", gpioPin); + return false; + } + + return s_section.Update([&](EStopConfig& estop) { + estop.gpioPin = gpioPin; + return true; + }); +} + +bool OpenShock::Config::LoadEStopLatched() +{ + ScopedLock lock__(&Internal::WriteLock()); // The partition can't change or close under the read + + const char* partition = Internal::Partition(); + if (partition == nullptr) { + return false; + } + + auto handle = Nvs::Handle::Open(partition, kNamespace, false); + if (!handle) { + return false; // No estop namespace yet: never latched + } + + uint8_t latched = 0; + switch (handle->GetU8(kKeyLatched, latched)) { + case Nvs::Result::Found: + return latched != 0; + case Nvs::Result::Missing: + return false; + case Nvs::Result::Error: + default: + OS_LOGE(TAG, "Failed to read the E-Stop latch, treating it as latched"); + return true; + } +} + +LatchSave OpenShock::Config::SaveEStopLatched(bool latched) +{ + ScopedTryLock lock__(&Internal::WriteLock(), 0); + if (!lock__.isLocked()) { + return LatchSave::Busy; + } + + const char* partition = Internal::Partition(); + if (partition == nullptr) { + return LatchSave::Saved; + } + + auto handle = Nvs::Handle::Open(partition, kNamespace, true); + if (!handle || !handle->SetU8(kKeyLatched, latched ? 1 : 0) || !handle->Commit()) { + return LatchSave::Failed; + } + + return LatchSave::Saved; +} diff --git a/components/device_control/src/radio/RFConfig.cpp b/components/device_control/src/radio/RFConfig.cpp new file mode 100644 index 000000000..30c3168a2 --- /dev/null +++ b/components/device_control/src/radio/RFConfig.cpp @@ -0,0 +1,156 @@ +#include + +#include "radio/RFConfig.h" + +const char* const TAG = "Config::RFConfig"; + +#include "BoardDescriptor.h" +#include "config/Nvs.h" +#include "config/Parse.h" +#include "Logging.h" + +#include "json/Json.h" + +using namespace OpenShock::Config; +namespace Fbs = OpenShock::Serialization::Configuration; + +static constexpr const char* kNamespace = "rf"; +static constexpr const char* kKeyTxPin = "tx_pin"; +static constexpr const char* kKeyKeepAlive = "keepalive"; + +static constexpr const char* kNamespaces[] = {kNamespace}; +static Section s_section({.name = "rf", .order = 0, .hubConfigField = Fbs::HubConfig::VT_RF, .namespaces = kNamespaces}); + +Section& OpenShock::Config::RFSection() +{ + return s_section; +} + +RFConfig::RFConfig() + : txPin(static_cast(Board::Get().rfTxGpio)) + , keepAliveEnabled(true) +{ +} + +RFConfig::RFConfig(gpio_num_t txPin, bool keepAliveEnabled) + : txPin(txPin) + , keepAliveEnabled(keepAliveEnabled) +{ +} + +void RFConfig::ToDefault() +{ + txPin = static_cast(Board::Get().rfTxGpio); + keepAliveEnabled = true; +} + +bool RFConfig::LoadNvs(const char* partition) +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, false); + if (!handle) { + ToDefault(); + return true; + } + + const int8_t boardPin = static_cast(Board::Get().rfTxGpio); + int8_t storedPin = boardPin; + if (!Nvs::Load(*handle, kKeyTxPin, storedPin, boardPin)) { + return false; + } + // "No pin" (-1) is not a usable TX pin either; both fall back to the board default, as FromJSON does. + if (auto pin = Parse::GpioNum(storedPin); pin && *pin != GPIO_NUM_NC) { + txPin = *pin; + } else { + txPin = static_cast(Board::Get().rfTxGpio); + } + return Nvs::Load(*handle, kKeyKeepAlive, keepAliveEnabled, true); +} + +bool RFConfig::SaveNvs(const char* partition, const RFConfig* previous) const +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, true); + if (!handle) { + return false; + } + const int8_t pin = static_cast(txPin); + const int8_t prevPin = previous != nullptr ? static_cast(previous->txPin) : 0; + return handle->SetI8(kKeyTxPin, pin, previous != nullptr ? &prevPin : nullptr) && handle->SetBool(kKeyKeepAlive, keepAliveEnabled, previous != nullptr ? &previous->keepAliveEnabled : nullptr) && handle->Commit(); +} + +bool RFConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + if (auto pin = Parse::JsonGpioNum(json, "txPin"); pin && *pin != GPIO_NUM_NC) { + txPin = *pin; + } else { + txPin = static_cast(Board::Get().rfTxGpio); + } + keepAliveEnabled = json["keepAliveEnabled"].tryGetBool().value_or(true); + + return true; +} + +void RFConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + (void)withSensitiveData; + JSON::objBegin(gen, name); + json_gen_obj_set_int(gen, "txPin", static_cast(txPin)); + json_gen_obj_set_bool(gen, "keepAliveEnabled", keepAliveEnabled); + JSON::objEnd(gen, name); +} + +flatbuffers::Offset RFConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + (void)withSensitiveData; + // keepalive_enabled is stored even when it equals the schema default, so the value reads back the same under every + // schema version (up to 1.5.x it defaulted to false): Config::HubConfigToJSON never has to infer it. + Fbs::RFConfigBuilder rfBuilder(builder); + rfBuilder.add_tx_pin(txPin); + builder.AddElement(Fbs::RFConfig::VT_KEEPALIVE_ENABLED, static_cast(keepAliveEnabled)); + return rfBuilder.Finish(); +} + +std::optional OpenShock::Config::GetRFConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetRFConfig(const RFConfig& config) +{ + return s_section.Set(config); +} + +std::optional OpenShock::Config::GetRFConfigTxPin() +{ + return s_section.Read([](const RFConfig& rf) { return rf.txPin; }); +} + +bool OpenShock::Config::SetRFConfigTxPin(gpio_num_t txPin) +{ + return s_section.Update([&](RFConfig& rf) { + rf.txPin = txPin; + return true; + }); +} + +std::optional OpenShock::Config::GetRFConfigKeepAliveEnabled() +{ + return s_section.Read([](const RFConfig& rf) { return rf.keepAliveEnabled; }); +} + +bool OpenShock::Config::SetRFConfigKeepAliveEnabled(bool enabled) +{ + return s_section.Update([&](RFConfig& rf) { + rf.keepAliveEnabled = enabled; + return true; + }); +} diff --git a/components/fs/host_test/main/test_config_partition.cpp b/components/fs/host_test/main/test_config_partition.cpp index 480741965..6c0dc219e 100644 --- a/components/fs/host_test/main/test_config_partition.cpp +++ b/components/fs/host_test/main/test_config_partition.cpp @@ -1,4 +1,4 @@ -// The config partition is 3 x 4 KiB (partitions/ota_4mb.csv). littlefs keeps its root metadata pair in two of those +// The 1.x config partition is 3 x 4 KiB (partitions/legacy/ota_4mb_v1.csv). littlefs keeps its root metadata pair in two of those // blocks, and ConfigFs::writeAll writes ".tmp" and renames it over the old file. A file too large to be // inlined in the metadata needs its own data block while the old copy still occupies the only free one, so the // review suspected that larger configs can't be rewritten at all (REVIEW_ACTIONS.md, X01). diff --git a/components/ota/src/OtaUpdateManager.cpp b/components/ota/src/OtaUpdateManager.cpp index fe02e3a0c..2e7a76790 100644 --- a/components/ota/src/OtaUpdateManager.cpp +++ b/components/ota/src/OtaUpdateManager.cpp @@ -1,3 +1,5 @@ +#include + #include "OtaUpdateManager.h" const char* const TAG = "OtaUpdateManager"; @@ -5,7 +7,6 @@ const char* const TAG = "OtaUpdateManager"; #include "AppHooks.h" #include "BoardDescriptor.h" #include "captiveportal/Manager.h" -#include "config/Config.h" #include "fs/FsCheck.h" #include "GatewayConnectionManager.h" #include "Hashing.h" @@ -13,6 +14,7 @@ const char* const TAG = "OtaUpdateManager"; #include "hwutil/PartitionUtils.h" #include "Logging.h" #include "OpenShock.h" +#include "OtaUpdateConfig.h" #include "SemVer.h" #include "serialization/WSGateway.h" #include "SimpleMutex.h" diff --git a/components/partition_layout/CMakeLists.txt b/components/partition_layout/CMakeLists.txt new file mode 100644 index 000000000..6c59921f5 --- /dev/null +++ b/components/partition_layout/CMakeLists.txt @@ -0,0 +1,12 @@ +# (Named partition_layout: ESP-IDF owns the component name partition_table.) +# The ESP-IDF partition table as data: parse the binary table and retype one entry in place, re-sealing its MD5, +# for the in-place table migrations (docs/STORAGE.md). PartitionTable.cpp is pure C++ over bytes with its own MD5, +# so the host tests run it unchanged. LiveTable.cpp is the device side: reading the table on flash and the guarded +# write that replaces it. +idf_component_register( + SRCS "src/PartitionTable.cpp" + "src/Md5.cpp" + "src/LiveTable.cpp" + INCLUDE_DIRS "include" + PRIV_REQUIRES logging freertos spi_flash bootloader_support esp_partition app_update # OS_LOG*, vTaskDelay, esp_flash_read, the table offset, the external partition, esp_ota_* +) diff --git a/components/partition_layout/host_test/CMakeLists.txt b/components/partition_layout/host_test/CMakeLists.txt new file mode 100644 index 000000000..ca0da412c --- /dev/null +++ b/components/partition_layout/host_test/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# Only build 'main'; the component is pure C++ over byte spans, compiled directly (see main/CMakeLists.txt). +set(COMPONENTS main) + +# linux-target freertos mock, so unity's IDF integration has its dependencies. +list(APPEND EXTRA_COMPONENT_DIRS "$ENV{IDF_PATH}/tools/mocks/freertos/") + +project(partition_layout_host_test) diff --git a/components/partition_layout/host_test/main/CMakeLists.txt b/components/partition_layout/host_test/main/CMakeLists.txt new file mode 100644 index 000000000..e6eea8727 --- /dev/null +++ b/components/partition_layout/host_test/main/CMakeLists.txt @@ -0,0 +1,13 @@ +# Runs the parser and the in-place retype against the 1.x table every fielded hub carries (built in the test from +# its entries) and against deliberately broken copies of it. +idf_component_register( + SRCS "test_main.cpp" + "test_partition_table.cpp" + "../../src/PartitionTable.cpp" + "../../src/Md5.cpp" + INCLUDE_DIRS "." + "../../include" # partition_layout/PartitionTable.h + "../../src" # Md5.h (known-answer test) + REQUIRES unity + WHOLE_ARCHIVE # keep the auto-registered TEST_CASEs from being stripped +) diff --git a/components/partition_layout/host_test/main/test_main.cpp b/components/partition_layout/host_test/main/test_main.cpp new file mode 100644 index 000000000..fab0f6457 --- /dev/null +++ b/components/partition_layout/host_test/main/test_main.cpp @@ -0,0 +1,10 @@ +// Host-only (linux target) test binary for the partition_table component. FreeRTOS is +// mocked, so there is no ESP startup - drive Unity directly. +#include "unity.h" + +extern "C" int main(void) +{ + UNITY_BEGIN(); + unity_run_all_tests(); + return UNITY_END(); +} diff --git a/components/partition_layout/host_test/main/test_partition_table.cpp b/components/partition_layout/host_test/main/test_partition_table.cpp new file mode 100644 index 000000000..89d7e1b71 --- /dev/null +++ b/components/partition_layout/host_test/main/test_partition_table.cpp @@ -0,0 +1,195 @@ +// The parser and the in-place retype against the 1.x table every fielded hub carries and a corpus of broken copies. +#include "unity.h" + +#include "Md5.h" +#include "partition_layout/PartitionTable.h" + +#include +#include +#include + +using namespace OpenShock; +namespace PT = OpenShock::PartitionTable; + +namespace { + struct Row { + const char* label; + uint8_t type; + uint8_t subtype; + uint32_t offset; + uint32_t size; + }; + + // partitions/legacy/ota_4mb_v1.csv + const std::vector kV1 = { + { "nvs", PT::kTypeData, PT::kSubtypeDataNvs, 0x009000, 0x005000}, + { "otadata", PT::kTypeData, 0x00, 0x00E000, 0x002000}, + { "app0", PT::kTypeApp, 0x10, 0x010000, 0x1A0000}, + { "app1", PT::kTypeApp, 0x11, 0x1B0000, 0x1A0000}, + { "config", PT::kTypeData, PT::kSubtypeDataSpiffs, 0x350000, 0x003000}, + { "static0", PT::kTypeData, PT::kSubtypeDataSpiffs, 0x353000, 0x09D000}, + {"coredump", PT::kTypeData, 0x03, 0x3F0000, 0x010000}, + }; + + void Put32(uint8_t* p, uint32_t value) + { + for (int i = 0; i < 4; ++i) p[i] = static_cast(value >> (8 * i)); + } + + // The bytes gen_esp32part.py writes for `rows`: the entries, the MD5 entry, 0xFF to the end. + PT::Bytes Build(const std::vector& rows) + { + PT::Bytes bytes; + bytes.fill(0xFF); + std::size_t pos = 0; + for (const Row& row : rows) { + uint8_t* p = bytes.data() + pos; + p[0] = PT::kMagic & 0xFF; + p[1] = PT::kMagic >> 8; + p[2] = row.type; + p[3] = row.subtype; + Put32(p + 4, row.offset); + Put32(p + 8, row.size); + memset(p + 12, 0, PT::kLabelSize); + memcpy(p + 12, row.label, strlen(row.label)); + Put32(p + 28, 0); + pos += PT::kEntrySize; + } + bytes[pos] = PT::kMd5Magic & 0xFF; + bytes[pos + 1] = PT::kMd5Magic >> 8; + uint8_t digest[16]; + PT::Internal::Md5(bytes.data(), pos, digest); + memcpy(bytes.data() + pos + 16, digest, 16); + return bytes; + } + + PT::Table ParseOk(std::span bytes) + { + PT::Table table {}; + auto result = PT::Parse(bytes, table); + TEST_ASSERT_TRUE_MESSAGE(result.has_value(), result ? "" : PT::ParseErrorName(result.error())); + return table; + } + + void ExpectParseError(std::span bytes, PT::ParseError expected) + { + PT::Table table {}; + auto result = PT::Parse(bytes, table); + TEST_ASSERT_FALSE_MESSAGE(result.has_value(), "parsed a broken table"); + TEST_ASSERT_EQUAL_STRING(PT::ParseErrorName(expected), PT::ParseErrorName(result.error())); + } + + void ExpectRetypeError(PT::Bytes bytes, std::string_view label, std::string_view newLabel, PT::RetypeError expected) + { + const PT::Bytes before = bytes; + auto result = PT::Retype(bytes, label, PT::kTypeData, PT::kSubtypeDataNvs, newLabel); + TEST_ASSERT_FALSE_MESSAGE(result.has_value(), "retyped what it should not have"); + TEST_ASSERT_EQUAL_STRING(PT::RetypeErrorName(expected), PT::RetypeErrorName(result.error())); + TEST_ASSERT_TRUE_MESSAGE(bytes == before, "a refused retype changed the table"); + } +} // namespace + +TEST_CASE("MD5 matches the RFC 1321 test suite", "[partition_table][md5]") +{ + uint8_t digest[16]; + PT::Internal::Md5(reinterpret_cast(""), 0, digest); + const uint8_t empty[16] = {0xd4, 0x1d, 0x8c, 0xd9, 0x8f, 0x00, 0xb2, 0x04, 0xe9, 0x80, 0x09, 0x98, 0xec, 0xf8, 0x42, 0x7e}; + TEST_ASSERT_EQUAL_UINT8_ARRAY(empty, digest, 16); + + const char* msg = "The quick brown fox jumps over the lazy dog"; + PT::Internal::Md5(reinterpret_cast(msg), strlen(msg), digest); + const uint8_t fox[16] = {0x9e, 0x10, 0x7d, 0x9d, 0x37, 0x2b, 0xb6, 0x82, 0x6b, 0xd8, 0x1d, 0x35, 0x42, 0xa4, 0x19, 0xd6}; + TEST_ASSERT_EQUAL_UINT8_ARRAY(fox, digest, 16); + + std::string eighty(80, 'a'); // crosses a block, length lands in the next padding block + PT::Internal::Md5(reinterpret_cast(eighty.data()), eighty.size(), digest); + const uint8_t a80[16] = {0xb1, 0x5a, 0xf9, 0xcd, 0xab, 0xba, 0xea, 0x05, 0x16, 0x86, 0x6a, 0x33, 0xd8, 0xfd, 0x0f, 0x98}; + TEST_ASSERT_EQUAL_UINT8_ARRAY(a80, digest, 16); +} + +TEST_CASE("The 1.x table parses with its MD5", "[partition_table]") +{ + PT::Table v1 = ParseOk(Build(kV1)); + TEST_ASSERT_EQUAL_size_t(7, v1.count); + TEST_ASSERT_EQUAL_STRING("nvs", v1.entries[0].label); + TEST_ASSERT_EQUAL_HEX32(0x9000, v1.entries[0].offset); + TEST_ASSERT_EQUAL_HEX32(0x5000, v1.entries[0].size); + TEST_ASSERT_EQUAL_UINT8(PT::kTypeData, v1.entries[0].type); + TEST_ASSERT_EQUAL_UINT8(PT::kSubtypeDataNvs, v1.entries[0].subtype); + const PT::Entry* config = v1.Find("config"); + TEST_ASSERT_NOT_NULL(config); + TEST_ASSERT_EQUAL_UINT8(PT::kSubtypeDataSpiffs, config->subtype); + TEST_ASSERT_EQUAL_HEX32(0x350000, config->offset); + TEST_ASSERT_NULL(v1.Find("cfg")); +} + +TEST_CASE("A table without an MD5 entry, or with a wrong one, is refused", "[partition_table][corrupt]") +{ + const PT::Bytes good = Build(kV1); + const std::size_t md5Pos = kV1.size() * PT::kEntrySize; + + PT::Bytes bytes = good; + bytes[16] ^= 0x01; // a byte inside the first entry: the MD5 no longer matches + ExpectParseError(bytes, PT::ParseError::Md5Mismatch); + + bytes = good; + memset(bytes.data() + md5Pos, 0xFF, PT::kEntrySize); // the MD5 entry erased + ExpectParseError(bytes, PT::ParseError::NoMd5); + + bytes = good; + bytes[md5Pos + 16] ^= 0x01; // the digest itself + ExpectParseError(bytes, PT::ParseError::Md5Mismatch); + + bytes = good; + bytes[md5Pos + PT::kEntrySize + 5] = 0x00; // garbage after the MD5 entry + ExpectParseError(bytes, PT::ParseError::Trailing); + + bytes = good; + memcpy(bytes.data() + md5Pos + PT::kEntrySize, bytes.data(), PT::kEntrySize); // an entry after the MD5 entry + ExpectParseError(bytes, PT::ParseError::Trailing); + + PT::Bytes erased; + erased.fill(0xFF); + ExpectParseError(erased, PT::ParseError::NoEntries); + ExpectParseError(std::vector(10, 0xFF), PT::ParseError::TooShort); + ExpectParseError(std::span(good).first(3 * PT::kEntrySize), PT::ParseError::NoMd5); +} + +TEST_CASE("Retype changes one entry's type, subtype and label and re-seals the MD5", "[partition_table][retype]") +{ + PT::Bytes bytes = Build(kV1); + TEST_ASSERT_TRUE(PT::Retype(bytes, "config", PT::kTypeData, PT::kSubtypeDataNvs, "cfg").has_value()); + + // What gen_esp32part.py would write for the retyped table, byte for byte + std::vector expected = kV1; + expected[4] = {"cfg", PT::kTypeData, PT::kSubtypeDataNvs, 0x350000, 0x003000}; + TEST_ASSERT_EQUAL_UINT8_ARRAY(Build(expected).data(), bytes.data(), PT::kMaxLen); + + PT::Table table = ParseOk(bytes); + TEST_ASSERT_NULL(table.Find("config")); + const PT::Entry* cfg = table.Find("cfg"); + TEST_ASSERT_NOT_NULL(cfg); + TEST_ASSERT_EQUAL_UINT8(PT::kSubtypeDataNvs, cfg->subtype); + TEST_ASSERT_EQUAL_HEX32(0x350000, cfg->offset); + TEST_ASSERT_EQUAL_HEX32(0x3000, cfg->size); +} + +TEST_CASE("Retype refuses what it cannot do and leaves the table alone", "[partition_table][retype]") +{ + const PT::Bytes good = Build(kV1); + ExpectRetypeError(good, "nope", "cfg", PT::RetypeError::NoSuchEntry); + ExpectRetypeError(good, "config", "static0", PT::RetypeError::LabelTaken); + + PT::Bytes broken = good; + broken[16] ^= 0x01; + ExpectRetypeError(broken, "config", "cfg", PT::RetypeError::Unparsable); +} + +TEST_CASE("Retype takes a label of the full 16 bytes and refuses a longer one", "[partition_table][retype]") +{ + PT::Bytes bytes = Build(kV1); + TEST_ASSERT_TRUE(PT::Retype(bytes, "config", PT::kTypeData, PT::kSubtypeDataNvs, "sixteen-chars-xx").has_value()); + TEST_ASSERT_NOT_NULL(ParseOk(bytes).Find("sixteen-chars-xx")); + + ExpectRetypeError(Build(kV1), "config", "seventeen-chars-x", PT::RetypeError::LabelTooLong); +} diff --git a/components/partition_layout/include/partition_layout/LiveTable.h b/components/partition_layout/include/partition_layout/LiveTable.h new file mode 100644 index 000000000..4d6fe55f1 --- /dev/null +++ b/components/partition_layout/include/partition_layout/LiveTable.h @@ -0,0 +1,32 @@ +#pragma once + +// The device side of PartitionTable.h: the table this hub's flash holds right now, and the one dangerous write the +// firmware ever makes. Not built for the host tests. + +#include "partition_layout/PartitionTable.h" + +#include +#include + +namespace OpenShock::PartitionTable { + /// @brief The raw table at 0x8000; false when the flash read fails. + bool ReadLiveBytes(Bytes& out); + + /// @brief The table at 0x8000, parsed, on the heap; null (logged) when it cannot be read or does not parse. + [[nodiscard]] std::unique_ptr ReadLive(); + + enum class ReplaceResult : uint8_t { + Written, // `next` is on flash + Restored, // `next` would not stick; `live` is back + Untouched, // could not even start: the table on flash was never touched + }; + + /// @brief Writes `next` over the table at 0x8000, whose exact bytes are `live`, and reads it back. + /// + /// The caller quiesces first (no NVS, nothing else using the partition API) and restarts afterwards whatever the + /// result: the running firmware keeps the table it loaded at boot until then. A write that fails (an error, a + /// read-back mismatch) is retried, then `live` is written back, and that is retried for as long as it takes: this + /// never returns while no table that parses is on flash. A power cut between the erase and the write of that one + /// sector is the only thing it cannot recover from. + [[nodiscard]] ReplaceResult Replace(const Bytes& next, const Bytes& live); +} // namespace OpenShock::PartitionTable diff --git a/components/partition_layout/include/partition_layout/PartitionTable.h b/components/partition_layout/include/partition_layout/PartitionTable.h new file mode 100644 index 000000000..fe64050a4 --- /dev/null +++ b/components/partition_layout/include/partition_layout/PartitionTable.h @@ -0,0 +1,78 @@ +#pragma once + +// The binary partition table the bootloader reads at 0x8000 (components/bootloader_support/include/ +// esp_flash_partitions.h): 32-byte entries - magic 0x50AA, type, subtype, offset u32, size u32, label[16], +// flags u32 - followed by an MD5 entry (magic 0xEBEB, 14 bytes 0xFF, the MD5 of everything before it) and +// 0xFF up to 0xC00. +// +// What the in-place table migrations need (docs/STORAGE.md): read the table, and retype one entry - its type, +// subtype and label - without moving anything, re-sealing the MD5. Pure C++ over bytes, so the host tests run it. + +#include +#include +#include +#include +#include +#include + +namespace OpenShock::PartitionTable { + inline constexpr std::size_t kMaxLen = 0xC00; + inline constexpr std::size_t kEntrySize = 32; + inline constexpr std::size_t kMaxEntries = kMaxLen / kEntrySize - 1; // minus the MD5 entry + inline constexpr std::size_t kLabelSize = 16; + inline constexpr uint16_t kMagic = 0x50AA; + inline constexpr uint16_t kMd5Magic = 0xEBEB; + + inline constexpr uint8_t kTypeApp = 0x00; + inline constexpr uint8_t kTypeData = 0x01; + inline constexpr uint8_t kSubtypeDataNvs = 0x02; + inline constexpr uint8_t kSubtypeDataSpiffs = 0x82; + + /// @brief The raw table, as it is on flash. + using Bytes = std::array; + + struct Entry { + uint8_t type; + uint8_t subtype; + uint32_t offset; + uint32_t size; + uint32_t flags; + char label[kLabelSize + 1]; // NUL-terminated + + [[nodiscard]] std::string_view Label() const { return label; } + }; + + struct Table { + std::array entries; + std::size_t count; + + [[nodiscard]] std::span Entries() const { return std::span(entries).first(count); } + [[nodiscard]] const Entry* Find(std::string_view label) const; + }; + + enum class ParseError : uint8_t { + TooShort, + NoEntries, + TooManyEntries, + NoMd5, // the table ended without an MD5 entry: the bootloader refuses it + Md5Mismatch, // the MD5 entry does not match the entries + Trailing, // something other than 0xFF after the MD5 entry + }; + [[nodiscard]] const char* ParseErrorName(ParseError error); + + /// @brief Decodes `bytes` (the first 0xC00 of a table; longer is fine) into `out` and verifies its MD5. A Table is + /// some 3.4 KiB, so on the device `out` belongs on the heap, not on a task's stack. + [[nodiscard]] std::expected Parse(std::span bytes, Table& out); + + enum class RetypeError : uint8_t { + Unparsable, // `bytes` is not a table that parses + NoSuchEntry, // no entry is labelled `label` + LabelTooLong, // the new label does not fit the 16-byte field + LabelTaken, // another entry already has the new label + }; + [[nodiscard]] const char* RetypeErrorName(RetypeError error); + + /// @brief Gives the entry labelled `label` a new type, subtype and label in place - offset, size and flags stay - + /// and re-seals the MD5. On failure `bytes` is unchanged. + [[nodiscard]] std::expected Retype(Bytes& bytes, std::string_view label, uint8_t type, uint8_t subtype, std::string_view newLabel); +} // namespace OpenShock::PartitionTable diff --git a/components/partition_layout/src/LiveTable.cpp b/components/partition_layout/src/LiveTable.cpp new file mode 100644 index 000000000..031181164 --- /dev/null +++ b/components/partition_layout/src/LiveTable.cpp @@ -0,0 +1,98 @@ +#include + +#include "partition_layout/LiveTable.h" + +const char* const TAG = "PartitionTable"; + +#include "Logging.h" + +#include +#include +#include +#include + +using namespace OpenShock; + +namespace { + constexpr int kWriteAttempts = 3; + constexpr TickType_t kWriteRetryDelay = pdMS_TO_TICKS(1'000); + + // Writes `table` through the OTA path, which lifts the dangerous-write protection for that one sector, erases it + // and verifies the written table; then reads it back. False unless `table` is what is on flash. + bool write(const esp_partition_t* pt, const PartitionTable::Bytes& table) + { + esp_err_t err = ESP_OK; + esp_ota_handle_t handle = 0; + // ---- From the erase in esp_ota_begin until esp_ota_write completes, a power cut leaves no partition table ---- + err = esp_ota_begin(pt, ESP_PARTITION_TABLE_MAX_LEN, &handle); + if (err == ESP_OK) { + err = esp_ota_write(handle, table.data(), ESP_PARTITION_TABLE_MAX_LEN); + // ---- The table is whole again from here ---- + esp_err_t endErr = esp_ota_end(handle); + if (err == ESP_OK) { + err = endErr; + } + } + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to write the partition table: %s", esp_err_to_name(err)); + return false; + } + + auto readBack = std::make_unique(); + if (!PartitionTable::ReadLiveBytes(*readBack) || *readBack != table) { + OS_LOGE(TAG, "The partition table does not read back as written"); + return false; + } + return true; + } +} // namespace + +bool PartitionTable::ReadLiveBytes(Bytes& out) +{ + esp_err_t err = esp_flash_read(nullptr, out.data(), ESP_PRIMARY_PARTITION_TABLE_OFFSET, out.size()); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to read the partition table: %s", esp_err_to_name(err)); + return false; + } + return true; +} + +std::unique_ptr PartitionTable::ReadLive() +{ + auto bytes = std::make_unique(); + if (!ReadLiveBytes(*bytes)) { + return nullptr; + } + auto table = std::make_unique
(); + auto parsed = Parse(*bytes, *table); + if (!parsed) { + OS_LOGE(TAG, "The live partition table does not parse: %s", ParseErrorName(parsed.error())); + return nullptr; + } + return table; +} + +PartitionTable::ReplaceResult PartitionTable::Replace(const Bytes& next, const Bytes& live) +{ + const esp_partition_t* pt = nullptr; + esp_err_t err = esp_partition_register_external(nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "pt", ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &pt); + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to register the partition table region: %s", esp_err_to_name(err)); + return ReplaceResult::Untouched; + } + + for (int attempt = 0; attempt < kWriteAttempts; ++attempt) { + if (write(pt, next)) { + return ReplaceResult::Written; + } + } + OS_LOGE(TAG, "The new partition table does not stick; writing the old one back"); + for (uint32_t attempt = 1;; ++attempt) { + if (write(pt, live)) { + return ReplaceResult::Restored; + } + // Whatever is on flash now may not parse: restarting would brick the hub. Keep trying. + OS_LOGE(TAG, "Failed to write the old partition table back (attempt %u). Do not unplug the hub.", static_cast(attempt)); + vTaskDelay(kWriteRetryDelay); + } +} diff --git a/components/partition_layout/src/Md5.cpp b/components/partition_layout/src/Md5.cpp new file mode 100644 index 000000000..b76c35094 --- /dev/null +++ b/components/partition_layout/src/Md5.cpp @@ -0,0 +1,84 @@ +#include "Md5.h" + +#include + +namespace { + constexpr uint32_t S[64] + = {7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21}; + constexpr uint32_t K[64] = { + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, + 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, + 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, + 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, + }; + + inline uint32_t rotl(uint32_t x, uint32_t c) + { + return (x << c) | (x >> (32 - c)); + } + + void transform(uint32_t state[4], const uint8_t block[64]) + { + uint32_t m[16]; + for (int i = 0; i < 16; ++i) { + m[i] = static_cast(block[i * 4]) | (static_cast(block[i * 4 + 1]) << 8) | (static_cast(block[i * 4 + 2]) << 16) | (static_cast(block[i * 4 + 3]) << 24); + } + uint32_t a = state[0], b = state[1], c = state[2], d = state[3]; + for (uint32_t i = 0; i < 64; ++i) { + uint32_t f, g; + if (i < 16) { + f = (b & c) | (~b & d); + g = i; + } else if (i < 32) { + f = (d & b) | (~d & c); + g = (5 * i + 1) % 16; + } else if (i < 48) { + f = b ^ c ^ d; + g = (3 * i + 5) % 16; + } else { + f = c ^ (b | ~d); + g = (7 * i) % 16; + } + uint32_t tmp = d; + d = c; + c = b; + b = b + rotl(a + f + K[i] + m[g], S[i]); + a = tmp; + } + state[0] += a; + state[1] += b; + state[2] += c; + state[3] += d; + } +} // namespace + +void OpenShock::PartitionTable::Internal::Md5(const uint8_t* data, std::size_t length, uint8_t (&digest)[16]) +{ + uint32_t state[4] = {0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476}; + std::size_t pos = 0; + for (; pos + 64 <= length; pos += 64) { + transform(state, data + pos); + } + uint8_t block[64]; + std::size_t rest = length - pos; + memcpy(block, data + pos, rest); + block[rest] = 0x80; + if (rest + 1 > 56) { + memset(block + rest + 1, 0, 64 - rest - 1); + transform(state, block); + memset(block, 0, 56); + } else { + memset(block + rest + 1, 0, 56 - rest - 1); + } + uint64_t bits = static_cast(length) * 8; + for (int i = 0; i < 8; ++i) { + block[56 + i] = static_cast(bits >> (8 * i)); + } + transform(state, block); + for (int i = 0; i < 4; ++i) { + digest[i * 4] = static_cast(state[i]); + digest[i * 4 + 1] = static_cast(state[i] >> 8); + digest[i * 4 + 2] = static_cast(state[i] >> 16); + digest[i * 4 + 3] = static_cast(state[i] >> 24); + } +} diff --git a/components/partition_layout/src/Md5.h b/components/partition_layout/src/Md5.h new file mode 100644 index 000000000..dd0a14c98 --- /dev/null +++ b/components/partition_layout/src/Md5.h @@ -0,0 +1,11 @@ +#pragma once + +// RFC 1321 MD5, for the partition table's MD5 entry. Kept inside this component so the table parser has no +// mbedTLS dependency and runs on the host unchanged. Not for anything security related; the bootloader uses +// the MD5 as a checksum and so does this. +#include +#include + +namespace OpenShock::PartitionTable::Internal { + void Md5(const uint8_t* data, std::size_t length, uint8_t (&digest)[16]); +} // namespace OpenShock::PartitionTable::Internal diff --git a/components/partition_layout/src/PartitionTable.cpp b/components/partition_layout/src/PartitionTable.cpp new file mode 100644 index 000000000..421fbd28a --- /dev/null +++ b/components/partition_layout/src/PartitionTable.cpp @@ -0,0 +1,185 @@ +#include "partition_layout/PartitionTable.h" + +#include "Md5.h" + +#include +#include + +using namespace OpenShock; + +namespace { + uint16_t readU16(const uint8_t* p) + { + return static_cast(p[0] | (p[1] << 8)); + } + uint32_t readU32(const uint8_t* p) + { + return static_cast(p[0]) | (static_cast(p[1]) << 8) | (static_cast(p[2]) << 16) | (static_cast(p[3]) << 24); + } + + std::string_view rawLabel(const uint8_t* entry) + { + const char* label = reinterpret_cast(entry + 12); + return std::string_view(label, strnlen(label, PartitionTable::kLabelSize)); + } + + // Checks the structure and the MD5 without decoding anything: the number of entries, or why the bootloader would + // refuse the table. + std::expected scan(std::span bytes) + { + using namespace PartitionTable; + + if (bytes.size() < kEntrySize) { + return std::unexpected(ParseError::TooShort); + } + const std::size_t limit = std::min(bytes.size(), kMaxLen); + + std::size_t count = 0; + std::size_t pos = 0; + bool md5Seen = false; + for (; pos + kEntrySize <= limit; pos += kEntrySize) { + const uint8_t* p = bytes.data() + pos; + const uint16_t magic = readU16(p); + if (magic == kMagic) { + if (md5Seen) { + return std::unexpected(ParseError::Trailing); + } + if (count >= kMaxEntries) { + return std::unexpected(ParseError::TooManyEntries); + } + ++count; + continue; + } + if (magic == kMd5Magic) { + if (md5Seen) { + return std::unexpected(ParseError::Trailing); + } + uint8_t digest[16]; + Internal::Md5(bytes.data(), pos, digest); + if (memcmp(digest, p + 16, 16) != 0) { + return std::unexpected(ParseError::Md5Mismatch); + } + md5Seen = true; + continue; + } + // 0xFFFF (erased flash) or anything else: the end of the table. What follows must be erased. + for (std::size_t i = pos; i < limit; ++i) { + if (bytes[i] != 0xFF) { + return std::unexpected(md5Seen ? ParseError::Trailing : ParseError::NoMd5); + } + } + break; + } + + if (count == 0) { + return std::unexpected(ParseError::NoEntries); + } + if (!md5Seen) { + return std::unexpected(ParseError::NoMd5); + } + return count; + } +} // namespace + +const PartitionTable::Entry* PartitionTable::Table::Find(std::string_view label) const +{ + for (const Entry& entry : Entries()) { + if (entry.Label() == label) { + return &entry; + } + } + return nullptr; +} + +const char* PartitionTable::ParseErrorName(ParseError error) +{ + switch (error) { + case ParseError::TooShort: + return "too short"; + case ParseError::NoEntries: + return "no entries"; + case ParseError::TooManyEntries: + return "too many entries"; + case ParseError::NoMd5: + return "no MD5 entry"; + case ParseError::Md5Mismatch: + return "MD5 mismatch"; + case ParseError::Trailing: + return "data after the MD5 entry"; + } + return "unknown"; +} + +std::expected PartitionTable::Parse(std::span bytes, Table& out) +{ + auto count = scan(bytes); + if (!count) { + return std::unexpected(count.error()); + } + + out.count = *count; + for (std::size_t i = 0; i < out.count; ++i) { + const uint8_t* p = bytes.data() + i * kEntrySize; + Entry& entry = out.entries[i]; + entry.type = p[2]; + entry.subtype = p[3]; + entry.offset = readU32(p + 4); + entry.size = readU32(p + 8); + memcpy(entry.label, p + 12, kLabelSize); + entry.label[kLabelSize] = '\0'; + entry.flags = readU32(p + 28); + } + return {}; +} + +const char* PartitionTable::RetypeErrorName(RetypeError error) +{ + switch (error) { + case RetypeError::Unparsable: + return "the table does not parse"; + case RetypeError::NoSuchEntry: + return "no entry has that label"; + case RetypeError::LabelTooLong: + return "the new label is too long"; + case RetypeError::LabelTaken: + return "another entry has the new label"; + } + return "unknown"; +} + +std::expected PartitionTable::Retype(Bytes& bytes, std::string_view label, uint8_t type, uint8_t subtype, std::string_view newLabel) +{ + if (newLabel.size() > kLabelSize) { + return std::unexpected(RetypeError::LabelTooLong); + } + auto count = scan(bytes); + if (!count) { + return std::unexpected(RetypeError::Unparsable); + } + + uint8_t* target = nullptr; + for (std::size_t i = 0; i < *count; ++i) { + uint8_t* p = bytes.data() + i * kEntrySize; + const std::string_view own = rawLabel(p); + if (own == label) { + target = p; + } else if (own == newLabel) { + return std::unexpected(RetypeError::LabelTaken); + } + } + if (target == nullptr) { + return std::unexpected(RetypeError::NoSuchEntry); + } + + target[2] = type; + target[3] = subtype; + memset(target + 12, 0, kLabelSize); + memcpy(target + 12, newLabel.data(), newLabel.size()); + + // The MD5 entry follows the last entry; its digest covers every entry before it + const std::size_t md5Pos = *count * kEntrySize; + uint8_t digest[16]; + Internal::Md5(bytes.data(), md5Pos, digest); + memcpy(bytes.data() + md5Pos + 16, digest, 16); + return {}; +} diff --git a/components/serial_console/CMakeLists.txt b/components/serial_console/CMakeLists.txt index a07c62d4b..a1864043f 100644 --- a/components/serial_console/CMakeLists.txt +++ b/components/serial_console/CMakeLists.txt @@ -10,8 +10,9 @@ FILE(GLOB_RECURSE console_sources idf_component_register( SRCS ${console_sources} INCLUDE_DIRS "include" + REQUIRES config serialization # its config section (serial_console/SerialInputConfig.h) PRIV_REQUIRES core serial device_control crypto # core: wifi/gateway/http/serialization; serial: Serial::Init/Read; device_control: estop/CommandHandler/visual/radio; crypto: Base64 - common config logging temporal osjson chipset # used directly; previously only reachable through core's public REQUIRES + common logging temporal osjson chipset # used directly; previously only reachable through core's public REQUIRES ) target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error) diff --git a/components/serial_console/include/serial_console/SerialInputConfig.h b/components/serial_console/include/serial_console/SerialInputConfig.h new file mode 100644 index 000000000..5031fae60 --- /dev/null +++ b/components/serial_console/include/serial_console/SerialInputConfig.h @@ -0,0 +1,41 @@ +#pragma once + +// The `serialInput` config section. Owned by serial_console and kept in NVS through the config framework +// (config/Section.h); namespace "serial", key echo (u8). + +#include "config/Section.h" +#include "serialization/_fbs/HubConfig_generated.h" + +#include + +namespace OpenShock::JSON { + class JsonView; +} + +namespace OpenShock::Config { + struct SerialInputConfig { + SerialInputConfig(); + SerialInputConfig(bool echoEnabled); + + bool echoEnabled; + + void ToDefault(); + + [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; + + /// @brief Reads this section from `partition`; a missing key is its default, a read error fails. + bool LoadNvs(const char* partition); + /// @brief Writes the keys that differ from `previous` (every key when null) to `partition`. + bool SaveNvs(const char* partition, const SerialInputConfig* previous) const; + + bool FromJSON(JSON::JsonView json); + void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; + }; + + Section& SerialInputSection(); + + [[nodiscard]] std::optional GetSerialInputConfig(); + bool SetSerialInputConfig(const SerialInputConfig& config); + [[nodiscard]] std::optional GetSerialInputConfigEchoEnabled(); + bool SetSerialInputConfigEchoEnabled(bool enabled); +} // namespace OpenShock::Config diff --git a/components/serial_console/src/SerialInputConfig.cpp b/components/serial_console/src/SerialInputConfig.cpp new file mode 100644 index 000000000..b3bd78816 --- /dev/null +++ b/components/serial_console/src/SerialInputConfig.cpp @@ -0,0 +1,112 @@ +#include + +#include "serial_console/SerialInputConfig.h" + +const char* const TAG = "Config::SerialInputConfig"; + +#include "config/Nvs.h" +#include "config/Parse.h" +#include "Logging.h" + +#include "json/Json.h" + +using namespace OpenShock::Config; +namespace Fbs = OpenShock::Serialization::Configuration; + +static constexpr const char* kNamespace = "serial"; +static constexpr const char* kKeyEcho = "echo"; + +static constexpr const char* kNamespaces[] = {kNamespace}; +static Section s_section({.name = "serialInput", .order = 4, .hubConfigField = Fbs::HubConfig::VT_SERIAL_INPUT, .namespaces = kNamespaces}); + +Section& OpenShock::Config::SerialInputSection() +{ + return s_section; +} + +SerialInputConfig::SerialInputConfig() + : echoEnabled(true) +{ +} + +SerialInputConfig::SerialInputConfig(bool echoEnabled) + : echoEnabled(echoEnabled) +{ +} + +void SerialInputConfig::ToDefault() +{ + echoEnabled = true; +} + +flatbuffers::Offset SerialInputConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + return Serialization::Configuration::CreateSerialInputConfig(builder, echoEnabled); +} + +bool SerialInputConfig::FromJSON(JSON::JsonView json) +{ + if (!json.valid()) { + OS_LOGW(TAG, "Config is null, setting to default"); + ToDefault(); + return true; + } + + if (!json.isObject()) { + OS_LOGE(TAG, "json is not an object"); + return false; + } + + echoEnabled = json["echoEnabled"].tryGetBool().value_or(true); + + return true; +} + +void SerialInputConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const +{ + JSON::objBegin(gen, name); + json_gen_obj_set_bool(gen, "echoEnabled", echoEnabled); + JSON::objEnd(gen, name); +} + +bool SerialInputConfig::LoadNvs(const char* partition) +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, false); + if (!handle) { + ToDefault(); + return true; + } + return Nvs::Load(*handle, kKeyEcho, echoEnabled, true); +} + +bool SerialInputConfig::SaveNvs(const char* partition, const SerialInputConfig* previous) const +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, true); + if (!handle) { + return false; + } + return handle->SetBool(kKeyEcho, echoEnabled, previous != nullptr ? &previous->echoEnabled : nullptr) && handle->Commit(); +} + +std::optional OpenShock::Config::GetSerialInputConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetSerialInputConfig(const SerialInputConfig& config) +{ + return s_section.Set(config); +} + +std::optional OpenShock::Config::GetSerialInputConfigEchoEnabled() +{ + return s_section.Read([](const SerialInputConfig& serial) { return serial.echoEnabled; }); +} + +bool OpenShock::Config::SetSerialInputConfigEchoEnabled(bool enabled) +{ + return s_section.Update([&](SerialInputConfig& serial) { + serial.echoEnabled = enabled; + return true; + }); +} diff --git a/components/serial_console/src/SerialInputHandler.cpp b/components/serial_console/src/SerialInputHandler.cpp index 4efbf6d31..51526b272 100644 --- a/components/serial_console/src/SerialInputHandler.cpp +++ b/components/serial_console/src/SerialInputHandler.cpp @@ -1,3 +1,5 @@ +#include + #include "serial_console/SerialInputHandler.h" const char* const TAG = "SerialInputHandler"; @@ -5,8 +7,6 @@ const char* const TAG = "SerialInputHandler"; #include "Base64.h" #include "Chipset.h" #include "CommandHandler.h" -#include "config/Config.h" -#include "config/SerialInputConfig.h" #include "Convert.h" #include "estop/EStopManager.h" #include "FormatHelpers.h" @@ -17,6 +17,7 @@ const char* const TAG = "SerialInputHandler"; #include "serial_console/command_handlers/CommandEntry.h" #include "serial_console/command_handlers/common.h" #include "serial_console/command_handlers/index.h" +#include "serial_console/SerialInputConfig.h" #include "serialization/JsonAPI.h" #include "serialization/JsonSerial.h" #include "StringHelpers.h" diff --git a/components/serial_console/src/command_handlers/authtoken.cpp b/components/serial_console/src/command_handlers/authtoken.cpp index 4095c1c34..c2e2e4ee8 100644 --- a/components/serial_console/src/command_handlers/authtoken.cpp +++ b/components/serial_console/src/command_handlers/authtoken.cpp @@ -1,6 +1,8 @@ +#include + #include "serial_console/command_handlers/common.h" -#include "config/Config.h" +#include "BackendConfig.h" #include "GatewayConnectionManager.h" #include "http/JsonAPI.h" diff --git a/components/serial_console/src/command_handlers/domain.cpp b/components/serial_console/src/command_handlers/domain.cpp index aaa16dddc..9b0192eea 100644 --- a/components/serial_console/src/command_handlers/domain.cpp +++ b/components/serial_console/src/command_handlers/domain.cpp @@ -1,6 +1,8 @@ +#include + #include "serial_console/command_handlers/common.h" -#include "config/Config.h" +#include "BackendConfig.h" #include "http/HTTPRequestManager.h" #include "serialization/JsonAPI.h" diff --git a/components/serial_console/src/command_handlers/echo.cpp b/components/serial_console/src/command_handlers/echo.cpp index dad6aa8e5..30ef39bc6 100644 --- a/components/serial_console/src/command_handlers/echo.cpp +++ b/components/serial_console/src/command_handlers/echo.cpp @@ -1,9 +1,11 @@ +#include + #include "serial_console/command_handlers/common.h" #include "serial_console/SerialInputHandler.h" -#include "config/Config.h" #include "Convert.h" +#include "serial_console/SerialInputConfig.h" #include "StringHelpers.h" static void handleSerialEchoCommand(std::string_view arg, bool isAutomated) diff --git a/components/serial_console/src/command_handlers/estop.cpp b/components/serial_console/src/command_handlers/estop.cpp index 34ed213e8..87fcd7c00 100644 --- a/components/serial_console/src/command_handlers/estop.cpp +++ b/components/serial_console/src/command_handlers/estop.cpp @@ -1,7 +1,9 @@ +#include + #include "serial_console/command_handlers/common.h" -#include "config/Config.h" #include "Convert.h" +#include "estop/EStopConfig.h" #include "estop/EStopManager.h" static void handleEStopEnabledCommand(std::string_view arg, bool isAutomated) diff --git a/components/serial_console/src/command_handlers/factoryreset.cpp b/components/serial_console/src/command_handlers/factoryreset.cpp index 8aaf91b99..dceaf6af2 100644 --- a/components/serial_console/src/command_handlers/factoryreset.cpp +++ b/components/serial_console/src/command_handlers/factoryreset.cpp @@ -1,7 +1,7 @@ #include "serial_console/command_handlers/common.h" #include "serial_console/SerialInputHandler.h" -#include "config/Config.h" +#include "config/Store.h" #include diff --git a/components/serial_console/src/command_handlers/hostname.cpp b/components/serial_console/src/command_handlers/hostname.cpp index d125a0466..2ed90893c 100644 --- a/components/serial_console/src/command_handlers/hostname.cpp +++ b/components/serial_console/src/command_handlers/hostname.cpp @@ -1,6 +1,8 @@ +#include + #include "serial_console/command_handlers/common.h" -#include "config/Config.h" +#include "wifi/WiFiConfig.h" #include diff --git a/components/serial_console/src/command_handlers/jsonconfig.cpp b/components/serial_console/src/command_handlers/jsonconfig.cpp index 7460259ba..4e8d357c8 100644 --- a/components/serial_console/src/command_handlers/jsonconfig.cpp +++ b/components/serial_console/src/command_handlers/jsonconfig.cpp @@ -1,6 +1,6 @@ #include "serial_console/command_handlers/common.h" -#include "config/Config.h" +#include "config/Store.h" #include diff --git a/components/serial_console/src/command_handlers/keepalive.cpp b/components/serial_console/src/command_handlers/keepalive.cpp index 22333c906..a72ec2526 100644 --- a/components/serial_console/src/command_handlers/keepalive.cpp +++ b/components/serial_console/src/command_handlers/keepalive.cpp @@ -1,8 +1,10 @@ +#include + #include "serial_console/command_handlers/common.h" #include "CommandHandler.h" -#include "config/Config.h" #include "Convert.h" +#include "radio/RFConfig.h" #include "StringHelpers.h" static void handleKeepAliveCommand(std::string_view arg, bool isAutomated) diff --git a/components/serial_console/src/command_handlers/networks.cpp b/components/serial_console/src/command_handlers/networks.cpp index 687f63cab..c3a356c4b 100644 --- a/components/serial_console/src/command_handlers/networks.cpp +++ b/components/serial_console/src/command_handlers/networks.cpp @@ -1,6 +1,8 @@ +#include + #include "serial_console/command_handlers/common.h" -#include "config/Config.h" +#include "wifi/WiFiConfig.h" #include "wifi/WiFiManager.h" #include "json/Json.h" diff --git a/components/serial_console/src/command_handlers/rawconfig.cpp b/components/serial_console/src/command_handlers/rawconfig.cpp index 33b1dc85c..353dc4bd3 100644 --- a/components/serial_console/src/command_handlers/rawconfig.cpp +++ b/components/serial_console/src/command_handlers/rawconfig.cpp @@ -1,7 +1,7 @@ #include "serial_console/command_handlers/common.h" #include "Base64.h" -#include "config/Config.h" +#include "config/Wire.h" #include "TinyVec.h" #include diff --git a/components/serial_console/src/command_handlers/rftxpin.cpp b/components/serial_console/src/command_handlers/rftxpin.cpp index ed19a7a3d..6d5e1744a 100644 --- a/components/serial_console/src/command_handlers/rftxpin.cpp +++ b/components/serial_console/src/command_handlers/rftxpin.cpp @@ -1,9 +1,11 @@ +#include + #include "serial_console/command_handlers/common.h" #include "CommandHandler.h" -#include "config/Config.h" #include "Convert.h" #include "enums/SetGPIOResultCode.h" +#include "radio/RFConfig.h" static void handleRfTxPinCommand(std::string_view arg, bool isAutomated) { diff --git a/components/serial_console/src/command_handlers/sysinfo.cpp b/components/serial_console/src/command_handlers/sysinfo.cpp index 7b2cd1a45..5e27929fd 100644 --- a/components/serial_console/src/command_handlers/sysinfo.cpp +++ b/components/serial_console/src/command_handlers/sysinfo.cpp @@ -2,6 +2,7 @@ #include "serial_console/command_handlers/common.h" +#include "config/Store.h" #include "FormatHelpers.h" #include "Temporal.h" #include "wifi/WiFiManager.h" @@ -23,6 +24,9 @@ static void handleDebugInfoCommand(std::string_view arg, bool isAutomated) const int64_t days = hours / 24; SERPR_RESPONSE("RTOSInfo|Uptime|%llid %llih %llim %llis", days, hours % 24, minutes % 60, seconds % 60); + const char* store = OpenShock::Config::StorageLabel(); + SERPR_RESPONSE("FlashInfo|Config store|%s", store != nullptr ? store : "RAM only"); + OpenShock::WiFiNetwork network; bool connected = OpenShock::WiFiManager::GetConnectedNetwork(network); SERPR_RESPONSE("WiFiInfo|Connected|%s", connected ? "true" : "false"); diff --git a/docs/STORAGE.md b/docs/STORAGE.md new file mode 100644 index 000000000..fb798c85e --- /dev/null +++ b/docs/STORAGE.md @@ -0,0 +1,94 @@ +# Storage and partition layouts + +How the firmware lays out flash, where it keeps its config, and how a 1.x hub's littlefs config partition becomes the +NVS config store in place. The reasoning behind each choice is in the design document (OpenShock Firmware 2.0: +Storage and Partition Migration Design); this page describes what the code does. + +## Partition tables + +One table, `partitions/ota_4mb.csv`, for every board whatever its flash size. Board files never name it. + +| Partition | Offset | Size | 1.x (`partitions/legacy/ota_4mb_v1.csv`) | +| --- | --- | --- | --- | +| `nvs` | 0x9000 | 20 KiB | the same | +| `otadata` | 0xE000 | 8 KiB | the same | +| `app0`, `app1` | 0x10000, 0x1B0000 | 1,664 KiB each | the same | +| `cfg` (data/nvs) | 0x350000 | 12 KiB | littlefs `config` (data/spiffs) | +| `static0` (data/spiffs) | 0x353000 | 628 KiB | the same | +| `coredump` | 0x3F0000 | 64 KiB | the same | + +It is the 1.x geometry entry for entry: a fielded hub rewrites its table in place and nothing moves. Only `config` +changes, in type and label. The flash past 4 MB on bigger boards stays unused. + +## Config + +The config lives in the `cfg` NVS partition as typed keys, one namespace per section. At 12 KiB (three NVS pages, one +always kept free for compaction) it holds the whole config with some 32 long WiFi credentials, more than the 4 KiB +1.x config file did; it fills up at about 36. The config component +(`components/config`) is only the framework; each module owns its section, its keys and its accessors: + +| Section | Owner | NVS namespaces | +| --- | --- | --- | +| `rf` | device_control (`radio/RFConfig.h`) | `rf` | +| `wifi` | core (`wifi/WiFiConfig.h`) | `wifi.cfg`, `wifi` (one blob per credential, key = its id) | +| `captivePortal` | captive_portal (`captiveportal/CaptivePortalConfig.h`) | `portal` | +| `backend` | core (`BackendConfig.h`) | `backend` | +| `serialInput` | serial_console (`serial_console/SerialInputConfig.h`) | `serial` | +| `otaUpdate` | core (`OtaUpdateConfig.h`) | `ota` | +| `estop` | device_control (`estop/EStopConfig.h`) | `estop` | + +The `estop` namespace also holds the E-Stop latch, `latched` (u8), which is state rather than config: it is not in +`jsonconfig` or the wire form. The E-Stop task writes it when the E-Stop activates and when it is cleared with the +button, after the change is already in effect. At boot it is read before anything can transmit, and a set latch keeps the +E-Stop active until it is cleared with the button. A missing key reads as not latched, so a fresh hub starts usable, and +a read error reads as latched. A latch on a hub whose E-Stop is disabled is dropped, since no button could clear it. +A factory reset clears it. A latch set while another config write holds the store (the migration's table write, for +one) reaches flash only once that write is done. + +The store's own namespace is `root`, with `schema` (u8, 1) marking a filled store. Namespace and key names are at +most 15 characters and are permanent once hubs hold them: renaming one is a migration, not an edit. A missing key +reads as its default and an unknown key is ignored, so adding or removing a field never changes the schema. + +To add a section, define a value type with `ToDefault`, `LoadNvs`, `SaveNvs`, `FromJSON`, `ToJSON` and +`ToFlatbuffers`, and one `Config::Section` for it in the module (`config/Section.h` has the contract). It joins +`jsonconfig`, `rawconfig`, the local protocol's Ready message and factory reset through the section registry. + +Where the store is: + +- `cfg` once the table has it. On the first boot after the table rewrite `cfg` still holds the 1.x littlefs; it is + erased before NVS opens it. +- On the 1.x table, which has no `cfg`, the same namespaces in the shared `nvs` partition. The first boot that finds + `cfg` copies them over key by key, compares the copy, and deletes the shared one. +- An empty store is filled once: from the 1.x littlefs config file (`/config` on the 1.x `config` partition, read + only, so a rollback to 1.x still finds it) when there is one, else from every section's defaults. +- Without any usable NVS, the hub runs on defaults in RAM and logs it. + +The shared `nvs` belongs to ESP-IDF (PHY calibration cache) and may be erased at any time; nothing of OpenShock's is +kept there once `cfg` exists. The WiFi driver's own NVS store is compiled out (`CONFIG_ESP_WIFI_NVS_ENABLED=n`). +`sysinfo` on the serial console prints where the config is. + +## The config partition migration + +`Config::MigrateToCfgPartition()` (`config/Migration.h`) moves a 1.x hub onto `cfg`. Nothing calls it yet: the release +that pipelines the storage migrations decides when it runs. + +1. It refuses unless the live table has the 1.x littlefs `config` partition and no `cfg`, and the config is in the + shared `nvs` (where a 1.x hub keeps it after its first boot on this firmware, imported from the 1.x file). +2. It retypes `config` as `cfg` (data/nvs) in a copy of the live table and re-seals the MD5 + (`components/partition_layout`). Every offset and size stays, so the bootloader that boots the 1.x table boots it. +3. Config writes are held, the WiFi driver's stale `nvs.net80211` store is dropped, NVS is closed, and the table sector + is erased and written through the OTA path and read back. A write that will not stick puts the old table back; the + hub never restarts without a table that parses. +4. The hub restarts; `Store` erases the littlefs left in `cfg` and moves the config in. + +### Recovery + +The table sector's erase-to-write window (one sector, well under a second, once per hub) is the one step a power cut +cannot recover from: the bootloader then finds no partition table. The app and config are still on flash, so writing +the board's table restores the hub with its config: + +```sh +esptool write_flash 0x8000 partition-table.bin # this board's, from its release +``` + +Flashing the merged image for the board at 0x0 also works but erases the config. diff --git a/main/main.cpp b/main/main.cpp index 40c6d4125..efadfc685 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -6,7 +6,7 @@ const char* const TAG = "main"; #include "captiveportal/Manager.h" #include "Chipset.h" #include "CommandHandler.h" -#include "config/Config.h" +#include "config/Store.h" #include "estop/EStopManager.h" #include "events/Events.h" #include "GatewayConnectionManager.h" diff --git a/partitions/legacy/ota_4mb_v1.csv b/partitions/legacy/ota_4mb_v1.csv new file mode 100644 index 000000000..ec1cd6f1b --- /dev/null +++ b/partitions/legacy/ota_4mb_v1.csv @@ -0,0 +1,14 @@ +# OpenShock 1.x partition table (every board, whatever its flash size). NOT built any more. +# +# Kept as the reference for what fielded 1.5.x / 1.6.x hubs carry and as a fixture for the +# partition-table tests (components/partition_layout/host_test): the config migration recognises +# this layout and retypes its littlefs `config` into the `cfg` NVS partition of partitions/ota_4mb.csv, +# keeping every offset and size. +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x009000, 0x005000, +otadata, data, ota, 0x00E000, 0x002000, +app0, app, ota_0, 0x010000, 0x1A0000, +app1, app, ota_1, 0x1B0000, 0x1A0000, +config, data, spiffs, 0x350000, 0x003000, +static0, data, spiffs, 0x353000, 0x09D000, +coredump, data, coredump, 0x3F0000, 0x010000, diff --git a/partitions/ota_4mb.csv b/partitions/ota_4mb.csv index 778817c43..8f2f5b7f2 100644 --- a/partitions/ota_4mb.csv +++ b/partitions/ota_4mb.csv @@ -1,9 +1,13 @@ -# OpenShock 4MB Partition Table - with OTA +# OpenShock partition table, for every board whatever its flash size. +# +# The geometry of the 1.x table (partitions/legacy/ota_4mb_v1.csv), entry for entry: the config +# migration rewrites the table in place and moves nothing. The 1.x littlefs `config` becomes `cfg`, +# the NVS config store; only its type and label change. See docs/STORAGE.md. # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x009000, 0x005000, -otadata, data, ota, 0x00e000, 0x002000, +otadata, data, ota, 0x00E000, 0x002000, app0, app, ota_0, 0x010000, 0x1A0000, app1, app, ota_1, 0x1B0000, 0x1A0000, -config, data, spiffs, 0x350000, 0x003000, +cfg, data, nvs, 0x350000, 0x003000, static0, data, spiffs, 0x353000, 0x09D000, -coredump, data, coredump, 0x3F0000, 0x010000, \ No newline at end of file +coredump, data, coredump, 0x3F0000, 0x010000, diff --git a/scripts/tests/test_build_scripts.py b/scripts/tests/test_build_scripts.py index 63c3d9f68..8150baca5 100644 --- a/scripts/tests/test_build_scripts.py +++ b/scripts/tests/test_build_scripts.py @@ -16,7 +16,7 @@ def test_reads_the_repository_partition_table(): assert table['static0']['offset'] == 0x353000 assert table['static0']['size'] == 0x9D000 - assert table['config']['size'] == 0x3000 + assert table['cfg']['size'] == 0x3000 assert partitions.first_app_partition(table)['offset'] == 0x10000 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index ac41e1c0e..8ea7c0ccc 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -59,13 +59,20 @@ CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE=y CONFIG_MBEDTLS_CUSTOM_CERTIFICATE_BUNDLE_PATH="certificates/cacert-merged.pem" # Partition table: every board uses the same 4 MB OTA layout (app0/app1 1.6 MB each plus -# config/static0/coredump). Set it here so a build without a board fragment (e.g. the +# cfg/static0/coredump). Set it here so a build without a board fragment (e.g. the # VS Code ESP-IDF extension) does not fall back to IDF's 1 MB single-app table, which # the app overflows and which has no static0 partition for the captive-portal assets. CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_4mb.csv" +# The config lives in the dedicated `cfg` NVS partition (components/config); the shared `nvs` at 0x9000 is +# ESP-IDF's own (PHY calibration cache) and may be erased at any time. The WiFi driver's NVS store +# (nvs.net80211: one STA profile, one AP profile) cannot express the credential list the config keeps, +# and WiFiManager already runs the driver with WIFI_STORAGE_RAM; compiling the store out drops the +# module from the image and an NVS read at startup. +CONFIG_ESP_WIFI_NVS_ENABLED=n + # The CONFIG_ARDUINO_SELECTIVE_* block that used to live here is gone. Arduino is no # longer a component of this project - it is in neither dependencies.lock nor any # idf_component.yml - so kconfgen reported every one of those symbols as unknown and From 88eeff8c8770ac0a7c9098279458c02072987344 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 30 Oct 2026 19:50:36 +0100 Subject: [PATCH 02/23] style(config): clang-format the config host tests --- components/config/host_test/main/test_corrupt.cpp | 12 ++++++++++-- components/config/host_test/main/test_roundtrip.cpp | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/components/config/host_test/main/test_corrupt.cpp b/components/config/host_test/main/test_corrupt.cpp index 25855dd72..612396690 100644 --- a/components/config/host_test/main/test_corrupt.cpp +++ b/components/config/host_test/main/test_corrupt.cpp @@ -178,8 +178,16 @@ TEST_CASE("The credential blob's auth mode codes never change", "[config][corrup { // What hubs hold on flash: owned by the blob format, not by ESP-IDF or the FlatBuffers schema const std::pair codes[] = { - {WIFI_AUTH_OPEN, 0}, {WIFI_AUTH_WEP, 1}, {WIFI_AUTH_WPA_PSK, 2}, {WIFI_AUTH_WPA2_PSK, 3}, {WIFI_AUTH_WPA_WPA2_PSK, 4}, - {WIFI_AUTH_WPA2_ENTERPRISE, 5}, {WIFI_AUTH_WPA3_PSK, 6}, {WIFI_AUTH_WPA2_WPA3_PSK, 7}, {WIFI_AUTH_WAPI_PSK, 8}, {WIFI_AUTH_MAX, 9}, + { WIFI_AUTH_OPEN, 0}, + { WIFI_AUTH_WEP, 1}, + { WIFI_AUTH_WPA_PSK, 2}, + { WIFI_AUTH_WPA2_PSK, 3}, + { WIFI_AUTH_WPA_WPA2_PSK, 4}, + {WIFI_AUTH_WPA2_ENTERPRISE, 5}, + { WIFI_AUTH_WPA3_PSK, 6}, + { WIFI_AUTH_WPA2_WPA3_PSK, 7}, + { WIFI_AUTH_WAPI_PSK, 8}, + { WIFI_AUTH_MAX, 9}, }; for (const auto& [mode, code] : codes) { const auto blob = Config::WiFiCredentials(1, "Net", "pw", mode).ToBlob(); diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 35ff98e44..4c5655a68 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -127,7 +127,9 @@ TEST_CASE("GetRaw writes fields that equal their schema default", "[config][roun TEST_ASSERT_TRUE(Config::GetRaw(raw)); const auto* root = Serialization::Configuration::GetHubConfig(raw.data()); // The generated tables derive from flatbuffers::Table privately; every table is one at the same address - auto hasField = [](const void* table, flatbuffers::voffset_t field) { return static_cast(table)->CheckField(field); }; + auto hasField = [](const void* table, flatbuffers::voffset_t field) { + return static_cast(table)->CheckField(field); + }; TEST_ASSERT_TRUE(hasField(root->rf(), Serialization::Configuration::RFConfig::VT_KEEPALIVE_ENABLED)); TEST_ASSERT_TRUE(hasField(root->ota_update(), Serialization::Configuration::OtaUpdateConfig::VT_IS_ENABLED)); } From 97ee7e1b9ec46600fb1b1aadc69f436bdfcd37e9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 30 Oct 2026 20:12:57 +0100 Subject: [PATCH 03/23] fix(config): keep stored config intact on failed moves, loads and saves - Copy the schema marker into cfg last, so an interrupted move is redone instead of trusted. - Stay on the shared nvs partition when the copy into cfg fails. - A section that fails to load runs on defaults without erasing its namespaces (WiFi networks, E-Stop latch). - After a failed load or partial save, a section's next save rewrites every key. - Load a store marked by a newer schema instead of refilling it. - Renumber JSON credentials with id 0 or a duplicate id so none are lost on flash. - Normalize the E-Stop config in SetEStopEnabled. --- components/config/include/config/Section.h | 24 +++++++++---- components/config/src/Store.cpp | 36 ++++++++++++------- components/core/src/wifi/WiFiConfig.cpp | 30 ++++++++++++++++ .../device_control/src/estop/EStopConfig.cpp | 1 + 4 files changed, 71 insertions(+), 20 deletions(-) diff --git a/components/config/include/config/Section.h b/components/config/include/config/Section.h index 5fc57222f..21d5cfb5c 100644 --- a/components/config/include/config/Section.h +++ b/components/config/include/config/Section.h @@ -19,8 +19,9 @@ // needs to know T's fields or keys; this framework only sees the section through SectionBase. // // The section caches its value in RAM: readers copy it out under a short lock and never wait on flash. Writers are -// serialized across every section (one write lock), save what changed, and only then publish the new value, so a -// failed write leaves the running value identical to what is on flash. +// serialized across every section (one write lock), save what changed, and only then publish the new value. NVS writes +// land key by key, so a failed save can leave some of them on flash; the section then no longer trusts its cache as a +// picture of flash, and its next save rewrites every key. A section that failed to load is treated the same way. // // Every section links itself into one registry when it is constructed (static initialization), which is how the // framework loads all of them at boot, renders the whole config as JSON (jsonconfig), imports one, and resets them @@ -129,7 +130,7 @@ namespace OpenShock::Config { /// @brief Read-modify-write that no other writer can interleave with. `mutate` edits a copy of the current value /// and returns whether it changed anything; a changed copy is saved (the keys that differ) and only then /// published. It runs under the write lock: keep it to plain field updates. - /// @return False only when saving failed, in which case the value is unchanged. + /// @return False only when saving failed, in which case the running value is unchanged. bool Update(Util::FunctionRef mutate) { ScopedLock lock__(&Internal::WriteLock()); @@ -137,7 +138,7 @@ namespace OpenShock::Config { if (!mutate(updated)) { return true; } - if (!save(updated, &m_value)) { + if (!save(updated)) { return false; } publish(std::move(updated)); @@ -151,6 +152,7 @@ namespace OpenShock::Config { if (!ok) { loaded.ToDefault(); } + m_unsynced = !ok; publish(std::move(loaded)); return ok; } @@ -163,6 +165,7 @@ namespace OpenShock::Config { if (partition != nullptr) { ok = eraseNamespaces(partition) && defaults.SaveNvs(partition, nullptr); } + m_unsynced = !ok; publish(std::move(defaults)); // What the hub runs with either way; a failed save only means it isn't kept return ok; } @@ -194,7 +197,8 @@ namespace OpenShock::Config { if (!m_staged) { return true; } - bool ok = partition == nullptr || m_staged->SaveNvs(partition, full ? nullptr : &m_value); + bool ok = partition == nullptr || m_staged->SaveNvs(partition, full || m_unsynced ? nullptr : &m_value); + m_unsynced = !ok; if (ok) { publish(std::move(*m_staged)); } @@ -211,10 +215,15 @@ namespace OpenShock::Config { } private: - bool save(const T& value, const T* previous) const + // Only the keys that differ from the cache, or every key when flash may not match it + bool save(const T& value) { const char* partition = Internal::Partition(); - return partition == nullptr || value.SaveNvs(partition, previous); + if (partition == nullptr) { + return true; + } + m_unsynced = !value.SaveNvs(partition, m_unsynced ? nullptr : &m_value); + return !m_unsynced; } void publish(T&& value) @@ -229,6 +238,7 @@ namespace OpenShock::Config { mutable SimpleMutex m_dataMutex; T m_value; std::optional m_staged; // Only touched under the write lock + bool m_unsynced = false; // Flash may differ from m_value, so the next save writes every key. Write lock only. }; namespace Internal { diff --git a/components/config/src/Store.cpp b/components/config/src/Store.cpp index d9e67b026..b193faf67 100644 --- a/components/config/src/Store.cpp +++ b/components/config/src/Store.cpp @@ -48,6 +48,7 @@ const char* Config::StorageLabel() #define CONFIG_LOCK_WRITE() ScopedLock lock__(&Config::Internal::WriteLock()) +// A store marked by a newer firmware (a rollback) counts as filled: it is loaded best-effort, never refilled. static bool hasSchema(const char* partition) { auto handle = Config::Nvs::Handle::Open(partition, kRootNamespace, false); @@ -55,7 +56,13 @@ static bool hasSchema(const char* partition) return false; } uint8_t schema = 0; - return handle->GetU8(kSchemaKey, schema) == Config::Nvs::Result::Found && schema == kSchemaVersion; + if (handle->GetU8(kSchemaKey, schema) != Config::Nvs::Result::Found || schema < kSchemaVersion) { + return false; + } + if (schema > kSchemaVersion) { + OS_LOGW(TAG, "The config in %s has schema %u, newer than this firmware's %u; loading what this firmware knows", partition, schema, kSchemaVersion); + } + return true; } static bool writeSchema(const char* partition) @@ -144,26 +151,28 @@ static const char* openStorage() } // First boot on a 2.x layout after the table rewrite: what the 1.9.9 pivot kept in the shared `nvs` is copied into -// `cfg` key by key, compared, and only then deleted from the shared partition. Idempotent: once `cfg` has a schema -// the shared copy is only ever cleaned up. -static void moveSharedIntoCfg() +// `cfg` key by key, compared, and only then deleted from the shared partition. The schema marker (`root`) is copied +// last, so a power cut mid-copy leaves `cfg` unmarked and the copy is redone. Idempotent: once `cfg` has a schema the +// shared copy is only ever cleaned up. Returns the partition the config now lives in: `cfg`, or the shared `nvs` when +// the copy failed. +static const char* moveSharedIntoCfg() { if (!nvsPartitionExists(kPartitionShared) || !initNvsPartition(kPartitionShared) || !hasSchema(kPartitionShared)) { - return; + return kPartitionCfg; } - const auto namespaces = allNamespaces(); + const auto namespaces = allNamespaces(); // kRootNamespace first, so walked backwards if (!hasSchema(kPartitionCfg)) { bool ok = true; - for (const char* ns : namespaces) { - ok = ok && Config::Nvs::CopyNamespace(kPartitionShared, kPartitionCfg, ns) && Config::Nvs::NamespacesEqual(kPartitionShared, kPartitionCfg, ns); + for (std::size_t i = namespaces.size(); ok && i-- > 0;) { + ok = Config::Nvs::CopyNamespace(kPartitionShared, kPartitionCfg, namespaces[i]) && Config::Nvs::NamespacesEqual(kPartitionShared, kPartitionCfg, namespaces[i]); } if (!ok) { OS_LOGE(TAG, "Failed to copy the config into cfg; it stays in the shared nvs partition"); for (const char* ns : namespaces) { (void)Config::Nvs::EraseNamespace(kPartitionCfg, ns); } - return; + return kPartitionShared; } OS_LOGI(TAG, "Moved the config from the shared nvs partition into cfg"); } @@ -175,6 +184,7 @@ static void moveSharedIntoCfg() if (erased) { OS_LOGI(TAG, "Removed the config copy from the shared nvs partition"); } + return kPartitionCfg; } // The config file a 1.x hub left behind, as a jsonconfig document. Nullopt when the hub has none. @@ -279,7 +289,7 @@ void Config::Init() s_partition = openStorage(); if (s_partition != nullptr && std::strcmp(s_partition, kPartitionCfg) == 0) { - moveSharedIntoCfg(); + s_partition = moveSharedIntoCfg(); } if (s_partition == nullptr || !hasSchema(s_partition)) { @@ -289,9 +299,9 @@ void Config::Init() for (auto* section = SectionBase::First(); section != nullptr; section = section->Next()) { if (!section->Load(s_partition)) { - // One unreadable section costs that section only - OS_LOGE(TAG, "Failed to load the %s config from %s, rewriting it with the defaults", section->Info().name, s_partition); - section->Reset(s_partition); + // The section runs on its defaults, but flash is left alone: erasing it would take every key with it (all WiFi + // networks, the E-Stop latch). Its next save rewrites every key. + OS_LOGE(TAG, "Failed to load the %s config from %s, running it on the defaults", section->Info().name, s_partition); } } OS_LOGI(TAG, "Loaded config from %s", s_partition); diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index d7f86e2aa..463900330 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -98,6 +98,36 @@ bool WiFiConfig::FromJSON(JSON::JsonView json) Parse::JsonArray(credentialsList, credentialsListJson); + // Each credential is stored under its id, so 0 (what a missing id parses to) and a repeated id would be lost on + // flash. Those get the lowest free id instead; one that finds none is dropped. + std::bitset<256> usedIds; + usedIds[0] = true; + for (const auto& creds : credentialsList) { + usedIds[creds.id] = true; + } + std::bitset<256> seenIds; + for (auto it = credentialsList.begin(); it != credentialsList.end();) { + if (it->id != 0 && !seenIds[it->id]) { + seenIds[it->id] = true; + ++it; + continue; + } + std::size_t freeId = 1; + while (freeId < usedIds.size() && usedIds[freeId]) { + ++freeId; + } + if (freeId == usedIds.size()) { + OS_LOGW(TAG, "Dropping credential %u: no free id left", it->id); + it = credentialsList.erase(it); + continue; + } + OS_LOGW(TAG, "Credential %u renumbered to %zu", it->id, freeId); + it->id = static_cast(freeId); + usedIds[freeId] = true; + seenIds[freeId] = true; + ++it; + } + return true; } diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp index 9cd046162..aae2fc9a7 100644 --- a/components/device_control/src/estop/EStopConfig.cpp +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -161,6 +161,7 @@ bool OpenShock::Config::SetEStopEnabled(bool enabled) { return s_section.Update([&](EStopConfig& estop) { estop.enabled = enabled; + estop.Normalize(); return true; }); } From 8418f6cc1caae5bdd7321511ad7c76ce00eee805 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 30 Oct 2026 20:19:02 +0100 Subject: [PATCH 04/23] fix(config): never erase a cfg partition written in a newer NVS format A newer NVS format on cfg means a rollback; erasing it would lose the config the newer firmware still expects. The store now runs on defaults in RAM instead, and never falls back to the shared nvs while cfg exists. --- components/config/src/Store.cpp | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/components/config/src/Store.cpp b/components/config/src/Store.cpp index b193faf67..c014476c7 100644 --- a/components/config/src/Store.cpp +++ b/components/config/src/Store.cpp @@ -83,12 +83,16 @@ static std::vector allNamespaces() return namespaces; } -// Brings up `label` as an NVS partition. A partition whose pages are from another NVS version or are all full is -// erased and reformatted: that is the contract of the shared `nvs` (everything in it regenerates), and `cfg` only -// ever holds what this firmware wrote. -static bool initNvsPartition(const char* label) +// Brings up `label` as an NVS partition. A partition with no free page is damaged or resized and is erased and +// reformatted. One written in a newer NVS format is only erased when `eraseNewer`: the shared `nvs` regenerates +// everything in it, but `cfg` in a newer format is a rollback, and the firmware rolled forward to must still find it. +static bool initNvsPartition(const char* label, bool eraseNewer) { esp_err_t err = nvs_flash_init_partition(label); + if (err == ESP_ERR_NVS_NEW_VERSION_FOUND && !eraseNewer) { + OS_LOGE(TAG, "NVS partition %s was written by a newer firmware; leaving it untouched", label); + return false; + } if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { OS_LOGW(TAG, "NVS partition %s unusable (%s), erasing and reformatting", label, esp_err_to_name(err)); if (nvs_flash_erase_partition(label) != ESP_OK) { @@ -138,11 +142,14 @@ static const char* openStorage() { if (nvsPartitionExists(kPartitionCfg)) { clearLegacyLittlefs(kPartitionCfg); + if (initNvsPartition(kPartitionCfg, false)) { + return kPartitionCfg; + } + // Never the shared nvs instead: what is saved there is discarded once cfg opens again + OS_LOGE(TAG, "The cfg partition is unusable; running on defaults in RAM, nothing will be saved"); + return nullptr; } - if (nvsPartitionExists(kPartitionCfg) && initNvsPartition(kPartitionCfg)) { - return kPartitionCfg; - } - if (nvsPartitionExists(kPartitionShared) && initNvsPartition(kPartitionShared)) { + if (nvsPartitionExists(kPartitionShared) && initNvsPartition(kPartitionShared, true)) { OS_LOGW(TAG, "No cfg partition (1.x partition layout): the config is kept in the shared nvs partition until the layout is migrated"); return kPartitionShared; } @@ -157,7 +164,7 @@ static const char* openStorage() // the copy failed. static const char* moveSharedIntoCfg() { - if (!nvsPartitionExists(kPartitionShared) || !initNvsPartition(kPartitionShared) || !hasSchema(kPartitionShared)) { + if (!nvsPartitionExists(kPartitionShared) || !initNvsPartition(kPartitionShared, true) || !hasSchema(kPartitionShared)) { return kPartitionCfg; } const auto namespaces = allNamespaces(); // kRootNamespace first, so walked backwards From e84ff806016259ab7e5facc250d631d256902f76 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 30 Oct 2026 20:19:03 +0100 Subject: [PATCH 05/23] refactor(config): move NamespaceExists and KeyCount into the host test helpers Only the host tests used them; they are now built on Handle::Open and ForEachKey. --- .../host_test/main/config_test_helpers.cpp | 16 ++++++++++++++ .../host_test/main/config_test_helpers.h | 7 ++++++ .../config/host_test/main/test_defaults.cpp | 2 +- .../config/host_test/main/test_storage.cpp | 2 +- components/config/include/config/Nvs.h | 7 ------ components/config/src/Nvs.cpp | 22 ------------------- 6 files changed, 25 insertions(+), 31 deletions(-) diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index 278c99a57..b1239fe79 100644 --- a/components/config/host_test/main/config_test_helpers.cpp +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -164,6 +164,22 @@ namespace ConfigTest { return stats.used_entries; } + bool NamespaceExists(const char* partition, const char* ns) + { + return OpenShock::Config::Nvs::Handle::Open(partition, ns, false).has_value(); + } + + std::size_t KeyCount(const char* partition, const char* ns) + { + auto handle = OpenShock::Config::Nvs::Handle::Open(partition, ns, false); + if (!handle) { + return 0; + } + std::size_t count = 0; + TEST_ASSERT_TRUE(handle->ForEachKey(NVS_TYPE_ANY, [&](const char*) { ++count; })); + return count; + } + Full Snapshot() { // Every getter must succeed; unwraps the returned optional. diff --git a/components/config/host_test/main/config_test_helpers.h b/components/config/host_test/main/config_test_helpers.h index cd295a898..5317e0075 100644 --- a/components/config/host_test/main/config_test_helpers.h +++ b/components/config/host_test/main/config_test_helpers.h @@ -120,6 +120,13 @@ namespace ConfigTest { // The number of entries the `cfg` partition has in use: unchanged across a save that changes nothing. std::size_t UsedEntries(); + // Whether `ns` exists in `partition`: it was created by a write once, and stays even when every key of it has since + // been erased. + bool NamespaceExists(const char* partition, const char* ns); + + // The number of keys in `ns` of `partition`; 0 for a namespace that does not exist. + std::size_t KeyCount(const char* partition, const char* ns); + // Reads back every section through the modules' public accessors. Full Snapshot(); diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index 88f46eee6..976a83959 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -81,7 +81,7 @@ TEST_CASE("An empty store: Init writes a default config that reloads", "[config] // Every section namespace and the schema marker are now on flash for (const char* ns : Keys::Ns::kAll) { - TEST_ASSERT_TRUE_MESSAGE(Nvs::NamespaceExists(Keys::kPartitionCfg, ns), ns); + TEST_ASSERT_TRUE_MESSAGE(NamespaceExists(Keys::kPartitionCfg, ns), ns); } TEST_ASSERT_TRUE(UsedEntries() > 0); diff --git a/components/config/host_test/main/test_storage.cpp b/components/config/host_test/main/test_storage.cpp index 7ec8311ef..26e6b5a20 100644 --- a/components/config/host_test/main/test_storage.cpp +++ b/components/config/host_test/main/test_storage.cpp @@ -57,7 +57,7 @@ TEST_CASE("A config kept in the shared nvs moves into cfg on the first boot that // The shared copy is gone: every key of every namespace of ours (NVS keeps an emptied namespace's name) TEST_ASSERT_FALSE(HasSchema(Keys::kPartitionShared)); for (const char* ns : Keys::Ns::kAll) { - TEST_ASSERT_EQUAL_size_t_MESSAGE(0, Nvs::KeyCount(Keys::kPartitionShared, ns), ns); + TEST_ASSERT_EQUAL_size_t_MESSAGE(0, KeyCount(Keys::kPartitionShared, ns), ns); } // And a later boot is a plain load from cfg diff --git a/components/config/include/config/Nvs.h b/components/config/include/config/Nvs.h index 54605dc41..bad969838 100644 --- a/components/config/include/config/Nvs.h +++ b/components/config/include/config/Nvs.h @@ -96,13 +96,6 @@ namespace OpenShock::Config::Nvs { bool Load(const Handle& handle, const char* key, int32_t& out, int32_t defaultValue); bool Load(const Handle& handle, const char* key, std::string& out, std::string_view defaultValue); - /// @brief Whether `ns` exists in `partition`: it was created by a write once, and stays even when every key of it - /// has since been erased. - [[nodiscard]] bool NamespaceExists(const char* partition, const char* ns); - - /// @brief The number of keys in `ns` of `partition`; 0 for a namespace that does not exist. - [[nodiscard]] std::size_t KeyCount(const char* partition, const char* ns); - /// @brief Erases every key of `ns` in `partition`; a namespace that does not exist counts as erased. [[nodiscard]] bool EraseNamespace(const char* partition, const char* ns); diff --git a/components/config/src/Nvs.cpp b/components/config/src/Nvs.cpp index 78f5f2615..a22709498 100644 --- a/components/config/src/Nvs.cpp +++ b/components/config/src/Nvs.cpp @@ -247,28 +247,6 @@ bool Nvs::Load(const Handle& handle, const char* key, std::string& out, std::str return false; } -bool Nvs::NamespaceExists(const char* partition, const char* ns) -{ - nvs_handle_t handle = 0; - esp_err_t err = nvs_open_from_partition(partition, ns, NVS_READONLY, &handle); - if (err == ESP_OK) { - nvs_close(handle); - return true; - } - return false; -} - -std::size_t Nvs::KeyCount(const char* partition, const char* ns) -{ - auto handle = Handle::Open(partition, ns, false); - if (!handle) { - return 0; - } - std::size_t count = 0; - handle->ForEachKey(NVS_TYPE_ANY, [&](const char*) { ++count; }); - return count; -} - bool Nvs::EraseNamespace(const char* partition, const char* ns) { nvs_handle_t handle = 0; From 2cb8c73b81e6d26e499201b905f60a5ab808059a Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 30 Oct 2026 20:22:40 +0100 Subject: [PATCH 06/23] fix(estop): read the software trigger before the active flag in IsEStopped The task sets s_estopActive and then clears s_externallyTriggered. Reading the active flag first could see it still unset and then see the trigger already cleared, reporting an active E-Stop as not stopped. --- components/device_control/src/EStopManager.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/device_control/src/EStopManager.cpp b/components/device_control/src/EStopManager.cpp index 77d36280a..9d575b820 100644 --- a/components/device_control/src/EStopManager.cpp +++ b/components/device_control/src/EStopManager.cpp @@ -382,7 +382,8 @@ bool EStopManager::SetEStopPin(gpio_num_t pin) bool EStopManager::IsEStopped() { - return s_estopActive.load() || s_externallyTriggered.load(); + // The trigger first: the task sets s_estopActive before clearing it, so a cleared trigger means the flag is set + return s_externallyTriggered.load() || s_estopActive.load(); } int64_t EStopManager::LastEStopped() From 5630da5c02298f3b50b23f74621252d930321e19 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 30 Oct 2026 20:25:36 +0100 Subject: [PATCH 07/23] refactor(config): constrain sections with a SectionValue concept Every config type now meets one compile-time contract instead of a comment. The default constructor is the only definition of the defaults (ToDefault is gone), and Normalize() is required and run by Section on every value it takes in (load, update, import, reset), so no setter can skip the validation. EStopConfig drops its own Normalize calls. --- .../captiveportal/CaptivePortalConfig.h | 5 +- .../src/captiveportal/CaptivePortalConfig.cpp | 9 +--- .../host_test/main/config_test_helpers.cpp | 11 ----- .../host_test/main/config_test_helpers.h | 1 - .../config/host_test/main/test_defaults.cpp | 7 --- components/config/include/config/Section.h | 46 +++++++++++++++---- components/core/include/BackendConfig.h | 5 +- components/core/include/OtaUpdateConfig.h | 5 +- components/core/include/wifi/WiFiConfig.h | 5 +- components/core/src/BackendConfig.cpp | 10 +--- components/core/src/OtaUpdateConfig.cpp | 18 +------- components/core/src/wifi/WiFiConfig.cpp | 9 +--- .../include/estop/EStopConfig.h | 6 +-- .../device_control/include/radio/RFConfig.h | 5 +- .../device_control/src/estop/EStopConfig.cpp | 21 ++------- .../device_control/src/radio/RFConfig.cpp | 10 +--- .../serial_console/SerialInputConfig.h | 5 +- .../serial_console/src/SerialInputConfig.cpp | 9 +--- 18 files changed, 72 insertions(+), 115 deletions(-) diff --git a/components/captive_portal/include/captiveportal/CaptivePortalConfig.h b/components/captive_portal/include/captiveportal/CaptivePortalConfig.h index e36362cbc..297d042b9 100644 --- a/components/captive_portal/include/captiveportal/CaptivePortalConfig.h +++ b/components/captive_portal/include/captiveportal/CaptivePortalConfig.h @@ -14,12 +14,13 @@ namespace OpenShock::JSON { namespace OpenShock::Config { struct CaptivePortalConfig { - CaptivePortalConfig(); + CaptivePortalConfig(); // The defaults: the one place they are defined CaptivePortalConfig(bool alwaysEnabled); bool alwaysEnabled; - void ToDefault(); + /// @brief No rules to apply yet. The section runs it on every value it takes in. + void Normalize() { } [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; diff --git a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp index f442ab366..487e0066a 100644 --- a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp +++ b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp @@ -34,11 +34,6 @@ CaptivePortalConfig::CaptivePortalConfig(bool alwaysEnabled) { } -void CaptivePortalConfig::ToDefault() -{ - alwaysEnabled = false; -} - flatbuffers::Offset CaptivePortalConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const { return Serialization::Configuration::CreateCaptivePortalConfig(builder, alwaysEnabled); @@ -48,7 +43,7 @@ bool CaptivePortalConfig::FromJSON(JSON::JsonView json) { if (!json.valid()) { OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); + *this = CaptivePortalConfig(); return true; } @@ -73,7 +68,7 @@ bool CaptivePortalConfig::LoadNvs(const char* partition) { auto handle = Nvs::Handle::Open(partition, kNamespace, false); if (!handle) { - ToDefault(); + *this = CaptivePortalConfig(); return true; } return Nvs::Load(*handle, kKeyAlways, alwaysEnabled, false); diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index b1239fe79..f0ed5d437 100644 --- a/components/config/host_test/main/config_test_helpers.cpp +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -15,17 +15,6 @@ using namespace OpenShock; namespace ConfigTest { - void Full::ToDefault() - { - rf.ToDefault(); - wifi.ToDefault(); - captivePortal.ToDefault(); - backend.ToDefault(); - serialInput.ToDefault(); - otaUpdate.ToDefault(); - estop.ToDefault(); - } - bool Full::SaveNvs(const char* partition) const { if (!rf.SaveNvs(partition, nullptr) || !wifi.SaveNvs(partition, nullptr) || !captivePortal.SaveNvs(partition, nullptr) || !backend.SaveNvs(partition, nullptr) || !serialInput.SaveNvs(partition, nullptr) || !otaUpdate.SaveNvs(partition, nullptr) diff --git a/components/config/host_test/main/config_test_helpers.h b/components/config/host_test/main/config_test_helpers.h index 5317e0075..3cde30dce 100644 --- a/components/config/host_test/main/config_test_helpers.h +++ b/components/config/host_test/main/config_test_helpers.h @@ -81,7 +81,6 @@ namespace ConfigTest { OpenShock::Config::OtaUpdateConfig otaUpdate; OpenShock::Config::EStopConfig estop; - void ToDefault(); // Every section's keys, plus the store's schema marker, so the store loads rather than fills it bool SaveNvs(const char* partition) const; // As the jsonconfig document renders it diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index 976a83959..749d67250 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -66,13 +66,6 @@ TEST_CASE("Default values come from Kconfig and the board descriptor", "[config] TEST_ASSERT_FALSE(config.estop.enabled); } -TEST_CASE("ToDefault resets every section to the constructed defaults", "[config][defaults]") -{ - Full config = MakeSampleConfig(); - config.ToDefault(); - AssertConfigEqual(Full(), config); -} - TEST_CASE("An empty store: Init writes a default config that reloads", "[config][defaults]") { InitDefault(); diff --git a/components/config/include/config/Section.h b/components/config/include/config/Section.h index 21d5cfb5c..d8e12a959 100644 --- a/components/config/include/config/Section.h +++ b/components/config/include/config/Section.h @@ -2,10 +2,12 @@ // A config section: one module's settings, owned and defined by that module, kept in NVS through this framework. // -// The module defines a value type T and one Section object for it (a namespace-scope static in its own .cpp): +// The module defines a value type T meeting SectionValue (below) and one Section object for it (a namespace-scope +// static in its own .cpp): // // struct RFConfig { -// void ToDefault(); +// RFConfig(); // the defaults, defined nowhere else +// void Normalize(); // validation, run on every value taken in // bool LoadNvs(const char* partition); // missing keys are defaults // bool SaveNvs(const char* partition, const RFConfig* previous) const; // writes only what differs // bool FromJSON(JSON::JsonView json); // invalid view: defaults @@ -33,6 +35,7 @@ #include +#include #include #include #include @@ -85,6 +88,19 @@ namespace OpenShock::Config { SectionBase* m_next; }; + /// @brief What a section's value type provides; no base class, checked where its Section is defined. A default + /// constructed value is the defaults. Normalize() is the type's validation: the section runs it on every value + /// it takes in (load, update, import, reset), so no setter can skip it. + template + concept SectionValue = std::default_initializable && std::copyable && requires(T& value, const T& constValue, const char* partition, json_gen_str_t* gen, flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) { + { value.Normalize() } -> std::same_as; + { value.LoadNvs(partition) } -> std::same_as; + { constValue.SaveNvs(partition, &constValue) } -> std::same_as; + { &T::FromJSON } -> std::same_as; // JsonView may be incomplete here + { constValue.ToJSON(gen, "name", withSensitiveData) } -> std::same_as; + { constValue.ToFlatbuffers(builder, withSensitiveData).o } -> std::convertible_to; + }; + namespace Internal { /// @brief Serializes every writer of every section, the store's own operations included. SimpleMutex& WriteLock(); @@ -92,7 +108,7 @@ namespace OpenShock::Config { const char* Partition(); } // namespace Internal - template + template class Section final : public SectionBase { public: explicit Section(const SectionInfo& info) @@ -138,6 +154,7 @@ namespace OpenShock::Config { if (!mutate(updated)) { return true; } + updated.Normalize(); if (!save(updated)) { return false; } @@ -148,9 +165,10 @@ namespace OpenShock::Config { bool Load(const char* partition) override { T loaded; - bool ok = loaded.LoadNvs(partition); + bool ok = loadNvs(loaded, partition); if (!ok) { - loaded.ToDefault(); + loaded = T(); + loaded.Normalize(); } m_unsynced = !ok; publish(std::move(loaded)); @@ -160,7 +178,7 @@ namespace OpenShock::Config { bool Reset(const char* partition) override { T defaults; - defaults.ToDefault(); + defaults.Normalize(); bool ok = true; if (partition != nullptr) { ok = eraseNamespaces(partition) && defaults.SaveNvs(partition, nullptr); @@ -173,7 +191,7 @@ namespace OpenShock::Config { bool VerifyStored(const char* partition) const override { T stored; - return stored.LoadNvs(partition) && sameValue(stored, Get()); + return loadNvs(stored, partition) && sameValue(stored, Get()); } void ToJSON(json_gen_str_t* gen, bool withSensitiveData) const override @@ -188,6 +206,7 @@ namespace OpenShock::Config { if (!staged.FromJSON(json)) { return false; } + staged.Normalize(); m_staged.emplace(std::move(staged)); return true; } @@ -215,6 +234,15 @@ namespace OpenShock::Config { } private: + static bool loadNvs(T& value, const char* partition) + { + if (!value.LoadNvs(partition)) { + return false; + } + value.Normalize(); + return true; + } + // Only the keys that differ from the cache, or every key when flash may not match it bool save(const T& value) { @@ -247,13 +275,13 @@ namespace OpenShock::Config { bool SameJson(Util::FunctionRef renderA, Util::FunctionRef renderB); } // namespace Internal - template + template bool Section::eraseNamespaces(const char* partition) const { return Internal::EraseNamespaces(partition, Info().namespaces); } - template + template bool Section::sameValue(const T& a, const T& b) { return Internal::SameJson([&](json_gen_str_t* gen) { a.ToJSON(gen, nullptr, true); }, [&](json_gen_str_t* gen) { b.ToJSON(gen, nullptr, true); }); diff --git a/components/core/include/BackendConfig.h b/components/core/include/BackendConfig.h index 8b29ff3d8..425b2b675 100644 --- a/components/core/include/BackendConfig.h +++ b/components/core/include/BackendConfig.h @@ -16,13 +16,14 @@ namespace OpenShock::JSON { namespace OpenShock::Config { struct BackendConfig { - BackendConfig(); + BackendConfig(); // The defaults: the one place they are defined BackendConfig(std::string_view domain, std::string_view authToken); std::string domain; std::string authToken; - void ToDefault(); + /// @brief No rules to apply yet. The section runs it on every value it takes in. + void Normalize() { } [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; diff --git a/components/core/include/OtaUpdateConfig.h b/components/core/include/OtaUpdateConfig.h index 970cded22..b45a5d285 100644 --- a/components/core/include/OtaUpdateConfig.h +++ b/components/core/include/OtaUpdateConfig.h @@ -24,7 +24,7 @@ namespace OpenShock::JSON { namespace OpenShock::Config { struct OtaUpdateConfig { - OtaUpdateConfig(); + OtaUpdateConfig(); // The defaults: the one place they are defined OtaUpdateConfig( bool isEnabled, std::string cdnDomain, OtaUpdateChannel updateChannel, bool checkOnStartup, bool checkPeriodically, uint16_t checkInterval, bool allowBackendManagement, bool requireManualApproval, int32_t updateId, OtaUpdateStep updateStep ); @@ -40,7 +40,8 @@ namespace OpenShock::Config { int32_t updateId; OtaUpdateStep updateStep; - void ToDefault(); + /// @brief No rules to apply yet. The section runs it on every value it takes in. + void Normalize() { } [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h index ad89f317c..b327fd089 100644 --- a/components/core/include/wifi/WiFiConfig.h +++ b/components/core/include/wifi/WiFiConfig.h @@ -18,14 +18,15 @@ namespace OpenShock::Config { struct WiFiConfig { - WiFiConfig(); + WiFiConfig(); // The defaults: the one place they are defined WiFiConfig(std::string_view accessPointSSID, std::string_view hostname, const std::vector& credentialsList); std::string accessPointSSID; std::string hostname; std::vector credentialsList; // in id order once loaded from NVS - void ToDefault(); + /// @brief No rules to apply yet. The section runs it on every value it takes in. + void Normalize() { } bool LoadNvs(const char* partition); bool SaveNvs(const char* partition, const WiFiConfig* previous) const; diff --git a/components/core/src/BackendConfig.cpp b/components/core/src/BackendConfig.cpp index 256551d7b..30ebb6b68 100644 --- a/components/core/src/BackendConfig.cpp +++ b/components/core/src/BackendConfig.cpp @@ -37,12 +37,6 @@ BackendConfig::BackendConfig(std::string_view domain, std::string_view authToken { } -void BackendConfig::ToDefault() -{ - domain = CONFIG_OPENSHOCK_API_DOMAIN; - authToken.clear(); -} - flatbuffers::Offset BackendConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const { auto domainOffset = builder.CreateString(domain); @@ -61,7 +55,7 @@ bool BackendConfig::FromJSON(JSON::JsonView json) { if (!json.valid()) { OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); + *this = BackendConfig(); return true; } @@ -92,7 +86,7 @@ bool BackendConfig::LoadNvs(const char* partition) { auto handle = Nvs::Handle::Open(partition, kNamespace, false); if (!handle) { - ToDefault(); + *this = BackendConfig(); return true; } return Nvs::Load(*handle, kKeyDomain, domain, CONFIG_OPENSHOCK_API_DOMAIN) && Nvs::Load(*handle, kKeyToken, authToken, ""); diff --git a/components/core/src/OtaUpdateConfig.cpp b/components/core/src/OtaUpdateConfig.cpp index 4b5df8142..7815bcd26 100644 --- a/components/core/src/OtaUpdateConfig.cpp +++ b/components/core/src/OtaUpdateConfig.cpp @@ -67,20 +67,6 @@ OtaUpdateConfig::OtaUpdateConfig( { } -void OtaUpdateConfig::ToDefault() -{ - isEnabled = true; - cdnDomain = CONFIG_OPENSHOCK_FW_CDN_DOMAIN; - updateChannel = OtaUpdateChannel::Stable; - checkOnStartup = false; - checkPeriodically = false; - checkInterval = 30; // 30 minutes - allowBackendManagement = true; - requireManualApproval = false; - updateId = 0; - updateStep = OtaUpdateStep::None; -} - flatbuffers::Offset OtaUpdateConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const { namespace Fbs = Serialization::Configuration; @@ -109,7 +95,7 @@ bool OtaUpdateConfig::FromJSON(JSON::JsonView json) { if (!json.valid()) { OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); + *this = OtaUpdateConfig(); return true; } @@ -156,7 +142,7 @@ bool OtaUpdateConfig::LoadNvs(const char* partition) { auto handle = Nvs::Handle::Open(partition, kNamespace, false); if (!handle) { - ToDefault(); + *this = OtaUpdateConfig(); return true; } diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index 463900330..5bfc9e47c 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -49,13 +49,6 @@ WiFiConfig::WiFiConfig(std::string_view accessPointSSID, std::string_view hostna { } -void WiFiConfig::ToDefault() -{ - accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; - hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; - credentialsList.clear(); -} - flatbuffers::Offset WiFiConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const { std::vector> fbsCredentialsList; @@ -72,7 +65,7 @@ bool WiFiConfig::FromJSON(JSON::JsonView json) { if (!json.valid()) { OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); + *this = WiFiConfig(); return true; } diff --git a/components/device_control/include/estop/EStopConfig.h b/components/device_control/include/estop/EStopConfig.h index ad31d2952..ed5a7befb 100644 --- a/components/device_control/include/estop/EStopConfig.h +++ b/components/device_control/include/estop/EStopConfig.h @@ -23,15 +23,13 @@ namespace OpenShock::JSON { namespace OpenShock::Config { // The schema's `active` and `latching` fields are not modelled: nothing reads them. struct EStopConfig { - EStopConfig(); + EStopConfig(); // The defaults: the one place they are defined EStopConfig(bool enabled, gpio_num_t gpioPin); bool enabled; gpio_num_t gpioPin; - void ToDefault(); - - /// @brief Disables the E-Stop when its pin can't be used as an input, so every input path stores the same thing. + /// @brief Disables the E-Stop when its pin can't be used as an input. The section runs it on every value it takes in. void Normalize(); bool LoadNvs(const char* partition); diff --git a/components/device_control/include/radio/RFConfig.h b/components/device_control/include/radio/RFConfig.h index 9d833b53b..1100d764d 100644 --- a/components/device_control/include/radio/RFConfig.h +++ b/components/device_control/include/radio/RFConfig.h @@ -16,13 +16,14 @@ namespace OpenShock::JSON { namespace OpenShock::Config { struct RFConfig { - RFConfig(); + RFConfig(); // The defaults: the one place they are defined RFConfig(gpio_num_t txPin, bool keepAliveEnabled); gpio_num_t txPin; bool keepAliveEnabled; - void ToDefault(); + /// @brief No rules to apply yet. The section runs it on every value it takes in. + void Normalize() { } bool LoadNvs(const char* partition); bool SaveNvs(const char* partition, const RFConfig* previous) const; diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp index aae2fc9a7..0c28b5c17 100644 --- a/components/device_control/src/estop/EStopConfig.cpp +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -40,12 +40,6 @@ EStopConfig::EStopConfig(bool enabled, gpio_num_t gpioPin) { } -void EStopConfig::ToDefault() -{ - enabled = OpenShock::IsValidInputPin(Board::Get().estopGpio); - gpioPin = static_cast(Board::Get().estopGpio); -} - void EStopConfig::Normalize() { // An E-Stop on a pin that can't be read as an input would never trigger; store it as disabled on every path. @@ -59,7 +53,7 @@ bool EStopConfig::LoadNvs(const char* partition) { auto handle = Nvs::Handle::Open(partition, kNamespace, false); if (!handle) { - ToDefault(); + *this = EStopConfig(); return true; } @@ -72,15 +66,11 @@ bool EStopConfig::LoadNvs(const char* partition) auto pin = Parse::GpioNum(storedPin); if (!pin) { OS_LOGW(TAG, "Invalid E-Stop GPIO pin %d, using default", storedPin); - ToDefault(); + *this = EStopConfig(); return true; } gpioPin = *pin; - if (!Nvs::Load(*handle, kKeyEnabled, enabled, OpenShock::IsValidInputPin(gpioPin))) { - return false; - } - Normalize(); - return true; + return Nvs::Load(*handle, kKeyEnabled, enabled, OpenShock::IsValidInputPin(gpioPin)); } bool EStopConfig::SaveNvs(const char* partition, const EStopConfig* previous) const @@ -97,7 +87,7 @@ bool EStopConfig::SaveNvs(const char* partition, const EStopConfig* previous) co bool EStopConfig::FromJSON(JSON::JsonView json) { if (!json.valid()) { - ToDefault(); + *this = EStopConfig(); return true; } @@ -118,7 +108,6 @@ bool EStopConfig::FromJSON(JSON::JsonView json) } else { enabled = OpenShock::IsValidInputPin(gpioPin); } - Normalize(); return true; } @@ -147,7 +136,6 @@ bool OpenShock::Config::SetEStopConfig(const EStopConfig& config) { return s_section.Update([&](EStopConfig& estop) { estop = config; - estop.Normalize(); return true; }); } @@ -161,7 +149,6 @@ bool OpenShock::Config::SetEStopEnabled(bool enabled) { return s_section.Update([&](EStopConfig& estop) { estop.enabled = enabled; - estop.Normalize(); return true; }); } diff --git a/components/device_control/src/radio/RFConfig.cpp b/components/device_control/src/radio/RFConfig.cpp index 30c3168a2..676c5bd1a 100644 --- a/components/device_control/src/radio/RFConfig.cpp +++ b/components/device_control/src/radio/RFConfig.cpp @@ -38,17 +38,11 @@ RFConfig::RFConfig(gpio_num_t txPin, bool keepAliveEnabled) { } -void RFConfig::ToDefault() -{ - txPin = static_cast(Board::Get().rfTxGpio); - keepAliveEnabled = true; -} - bool RFConfig::LoadNvs(const char* partition) { auto handle = Nvs::Handle::Open(partition, kNamespace, false); if (!handle) { - ToDefault(); + *this = RFConfig(); return true; } @@ -80,7 +74,7 @@ bool RFConfig::SaveNvs(const char* partition, const RFConfig* previous) const bool RFConfig::FromJSON(JSON::JsonView json) { if (!json.valid()) { - ToDefault(); + *this = RFConfig(); return true; } diff --git a/components/serial_console/include/serial_console/SerialInputConfig.h b/components/serial_console/include/serial_console/SerialInputConfig.h index 5031fae60..08189bb02 100644 --- a/components/serial_console/include/serial_console/SerialInputConfig.h +++ b/components/serial_console/include/serial_console/SerialInputConfig.h @@ -14,12 +14,13 @@ namespace OpenShock::JSON { namespace OpenShock::Config { struct SerialInputConfig { - SerialInputConfig(); + SerialInputConfig(); // The defaults: the one place they are defined SerialInputConfig(bool echoEnabled); bool echoEnabled; - void ToDefault(); + /// @brief No rules to apply yet. The section runs it on every value it takes in. + void Normalize() { } [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; diff --git a/components/serial_console/src/SerialInputConfig.cpp b/components/serial_console/src/SerialInputConfig.cpp index b3bd78816..7f2ecbf8d 100644 --- a/components/serial_console/src/SerialInputConfig.cpp +++ b/components/serial_console/src/SerialInputConfig.cpp @@ -34,11 +34,6 @@ SerialInputConfig::SerialInputConfig(bool echoEnabled) { } -void SerialInputConfig::ToDefault() -{ - echoEnabled = true; -} - flatbuffers::Offset SerialInputConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const { return Serialization::Configuration::CreateSerialInputConfig(builder, echoEnabled); @@ -48,7 +43,7 @@ bool SerialInputConfig::FromJSON(JSON::JsonView json) { if (!json.valid()) { OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); + *this = SerialInputConfig(); return true; } @@ -73,7 +68,7 @@ bool SerialInputConfig::LoadNvs(const char* partition) { auto handle = Nvs::Handle::Open(partition, kNamespace, false); if (!handle) { - ToDefault(); + *this = SerialInputConfig(); return true; } return Nvs::Load(*handle, kKeyEcho, echoEnabled, true); From 7838a409ce07c4959cf254128cae92183a2064e0 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 30 Oct 2026 20:39:57 +0100 Subject: [PATCH 08/23] refactor(estop): move the persisted E-Stop latch out of the config store branch The latch (LoadEStopLatched/SaveEStopLatched, its restore in EStopManager::Init and the task's writes) moves to its own branch, feat/estop-latch, which reverts this commit on top. --- .../config/host_test/main/CMakeLists.txt | 1 - .../host_test/main/config_test_helpers.h | 1 - .../host_test/main/test_estop_latch.cpp | 104 ------------------ components/config/src/Store.cpp | 2 +- .../include/estop/EStopConfig.h | 19 ---- .../include/estop/EStopManager.h | 3 - .../device_control/src/EStopManager.cpp | 65 +---------- .../device_control/src/estop/EStopConfig.cpp | 48 -------- docs/STORAGE.md | 8 -- 9 files changed, 2 insertions(+), 249 deletions(-) delete mode 100644 components/config/host_test/main/test_estop_latch.cpp diff --git a/components/config/host_test/main/CMakeLists.txt b/components/config/host_test/main/CMakeLists.txt index 792a55fd8..bc807fff7 100644 --- a/components/config/host_test/main/CMakeLists.txt +++ b/components/config/host_test/main/CMakeLists.txt @@ -14,7 +14,6 @@ idf_component_register( "test_legacy.cpp" # the 1.x config file (legacy_fixtures.h) imported on first boot "test_corrupt.cpp" # truncated / garbage / tampered input "test_storage.cpp" # store selection, the move from the shared nvs into cfg, the registry - "test_estop_latch.cpp" # the E-Stop latch kept beside the estop section # The framework "${_components}/config/src/Nvs.cpp" "${_components}/config/src/Parse.cpp" diff --git a/components/config/host_test/main/config_test_helpers.h b/components/config/host_test/main/config_test_helpers.h index 3cde30dce..db0636db0 100644 --- a/components/config/host_test/main/config_test_helpers.h +++ b/components/config/host_test/main/config_test_helpers.h @@ -63,7 +63,6 @@ namespace ConfigTest { namespace Estop { inline constexpr const char* kEnabled = "enabled"; inline constexpr const char* kGpio = "gpio"; - inline constexpr const char* kLatched = "latched"; } // namespace Estop namespace Ota { inline constexpr const char* kUpdateId = "update_id"; diff --git a/components/config/host_test/main/test_estop_latch.cpp b/components/config/host_test/main/test_estop_latch.cpp deleted file mode 100644 index b45fff1a9..000000000 --- a/components/config/host_test/main/test_estop_latch.cpp +++ /dev/null @@ -1,104 +0,0 @@ -// The E-Stop latch: state the estop section's module keeps in the section's namespace, outside the section's value. -// A fresh hub starts usable, a stored latch survives a reboot and the move into cfg, and only a factory reset (which -// erases the section) drops it. -#include - -#include "unity.h" - -#include "config_test_helpers.h" - -#include "config/Section.h" - -#include - -#include - -using namespace OpenShock; -using namespace ConfigTest; -namespace Keys = ConfigTest::Keys; -namespace Nvs = OpenShock::Config::Nvs; - -TEST_CASE("A fresh hub is not latched", "[config][estop]") -{ - InitDefault(); - TEST_ASSERT_FALSE(Config::LoadEStopLatched()); -} - -TEST_CASE("The E-Stop latch survives a reboot until it is released", "[config][estop]") -{ - InitWith(MakeSampleConfig()); - TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Saved); - Reload(); - TEST_ASSERT_TRUE(Config::LoadEStopLatched()); - AssertConfigEqual(MakeSampleConfig(), Snapshot()); // The latch is not part of the section's value - - TEST_ASSERT_TRUE(Config::SaveEStopLatched(false) == Config::LatchSave::Saved); - Reload(); - TEST_ASSERT_FALSE(Config::LoadEStopLatched()); -} - -TEST_CASE("Saving the estop section keeps the latch, and jsonconfig never shows it", "[config][estop]") -{ - InitWith(MakeSampleConfig()); - TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Saved); - TEST_ASSERT_TRUE(Config::SetEStopEnabled(false)); - TEST_ASSERT_TRUE(Config::SetEStopEnabled(true)); - TEST_ASSERT_TRUE(Config::SaveFromJSON(Config::GetAsJSON(true))); - Reload(); - TEST_ASSERT_TRUE(Config::LoadEStopLatched()); - TEST_ASSERT_TRUE(Config::GetAsJSON(true).find("latch") == std::string::npos); -} - -TEST_CASE("Factory reset releases the E-Stop latch", "[config][estop]") -{ - InitWith(MakeSampleConfig()); - TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Saved); - Config::FactoryReset(); - TEST_ASSERT_FALSE(Config::LoadEStopLatched()); - Reload(); - TEST_ASSERT_FALSE(Config::LoadEStopLatched()); -} - -TEST_CASE("Saving the latch never waits for another config writer", "[config][estop]") -{ - InitWith(MakeSampleConfig()); - { - // The write lock is a plain mutex, so taking it again from the holder fails like it would from the E-Stop task - ScopedLock lock__(&Config::Internal::WriteLock()); - TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Busy); - } - TEST_ASSERT_FALSE(Config::LoadEStopLatched()); // Nothing was written - TEST_ASSERT_TRUE(Config::SaveEStopLatched(true) == Config::LatchSave::Saved); - TEST_ASSERT_TRUE(Config::LoadEStopLatched()); -} - -TEST_CASE("A latch stored in the shared nvs moves into cfg with the estop section", "[config][estop][storage]") -{ - WipeStore(); - TEST_ASSERT_EQUAL(ESP_OK, nvs_flash_init_partition(Keys::kPartitionShared)); - TEST_ASSERT_TRUE(MakeSampleConfig().SaveNvs(Keys::kPartitionShared)); - { - auto estop = Nvs::Handle::Open(Keys::kPartitionShared, Keys::Ns::kEstop, true); - TEST_ASSERT_TRUE(estop.has_value()); - TEST_ASSERT_TRUE(estop->SetU8(Keys::Estop::kLatched, 1)); - TEST_ASSERT_TRUE(estop->Commit()); - } - - Config::Init(); - TEST_ASSERT_EQUAL_STRING(Keys::kPartitionCfg, Config::StorageLabel()); - TEST_ASSERT_TRUE(Config::LoadEStopLatched()); -} - -TEST_CASE("A latch stored with another type reads as not latched", "[config][estop]") -{ - InitWith(MakeSampleConfig()); - { - // NVS looks keys up by name and type, so this is a missing key, not a read error - auto estop = Nvs::Handle::Open(Keys::kPartitionCfg, Keys::Ns::kEstop, true); - TEST_ASSERT_TRUE(estop.has_value()); - TEST_ASSERT_TRUE(estop->SetStr(Keys::Estop::kLatched, "yes")); - TEST_ASSERT_TRUE(estop->Commit()); - } - Reload(); - TEST_ASSERT_FALSE(Config::LoadEStopLatched()); -} diff --git a/components/config/src/Store.cpp b/components/config/src/Store.cpp index c014476c7..4df5a1928 100644 --- a/components/config/src/Store.cpp +++ b/components/config/src/Store.cpp @@ -307,7 +307,7 @@ void Config::Init() for (auto* section = SectionBase::First(); section != nullptr; section = section->Next()) { if (!section->Load(s_partition)) { // The section runs on its defaults, but flash is left alone: erasing it would take every key with it (all WiFi - // networks, the E-Stop latch). Its next save rewrites every key. + // networks). Its next save rewrites every key. OS_LOGE(TAG, "Failed to load the %s config from %s, running it on the defaults", section->Info().name, s_partition); } } diff --git a/components/device_control/include/estop/EStopConfig.h b/components/device_control/include/estop/EStopConfig.h index ed5a7befb..f1ade06e9 100644 --- a/components/device_control/include/estop/EStopConfig.h +++ b/components/device_control/include/estop/EStopConfig.h @@ -2,11 +2,6 @@ // The `estop` config section: whether the E-Stop input is used and on which pin. Owned by device_control and kept in // NVS through the config framework (config/Section.h); NVS namespace "estop", keys enabled (u8) and gpio (i8). -// -// The same namespace holds the E-Stop latch, `latched` (u8): whether the E-Stop was active when it was last saved, so -// a reboot or power cut does not release it. It is state, not config: not part of the section's value, never in -// jsonconfig or the wire form. Keeping it in the section's namespace moves it with the store; a factory reset (which -// erases the section's namespaces) clears it. #include "config/Section.h" #include "serialization/_fbs/HubConfig_generated.h" @@ -49,18 +44,4 @@ namespace OpenShock::Config { bool SetEStopEnabled(bool enabled); [[nodiscard]] std::optional GetEStopGpioPin(); bool SetEStopGpioPin(gpio_num_t gpioPin); - - /// @brief Whether the stored E-Stop latch is set. A missing key, or a hub whose config only lives in RAM, reads as - /// not latched (a fresh hub must start usable); a read error reads as latched. - [[nodiscard]] bool LoadEStopLatched(); - - enum class LatchSave : uint8_t { - Saved, // on flash (or nothing to keep: the config only lives in RAM) - Busy, // another config writer holds the write lock; nothing was written, try again later - Failed, // the write failed - }; - - /// @brief Stores the E-Stop latch. Never waits for another config writer (the layout migration holds the write lock - /// across closing NVS), so the E-Stop task can call it between samples. Never call it from an ISR. - [[nodiscard]] LatchSave SaveEStopLatched(bool latched); } // namespace OpenShock::Config diff --git a/components/device_control/include/estop/EStopManager.h b/components/device_control/include/estop/EStopManager.h index 8180f28a2..16fc76889 100644 --- a/components/device_control/include/estop/EStopManager.h +++ b/components/device_control/include/estop/EStopManager.h @@ -7,9 +7,6 @@ #include namespace OpenShock::EStopManager { - /// @brief Restores an E-Stop latched before the restart (the `latched` key of the estop section, estop/EStopConfig.h) - /// and starts sampling the input. Call before anything can transmit. While the task runs, every activation and - /// every clear is written to that key, so a reboot or power cut never releases an active E-Stop. [[nodiscard]] bool Init(); // Both apply the change to the running E-Stop and then persist it to the config (like CommandHandler::SetRfTxPin), // so callers don't have to keep the two in sync. They refuse while the E-Stop is active. diff --git a/components/device_control/src/EStopManager.cpp b/components/device_control/src/EStopManager.cpp index 9d575b820..3f662fec8 100644 --- a/components/device_control/src/EStopManager.cpp +++ b/components/device_control/src/EStopManager.cpp @@ -18,10 +18,8 @@ const char* const TAG = "EStopManager"; #include #include -#include #include #include -#include using namespace OpenShock; @@ -42,13 +40,6 @@ static std::atomic s_externallyTriggered = false; static bool s_estopInitialized = false; -// The E-Stop latch as last written to (or read from) the config store; nullopt when that is not known. Owned by Init -// until the task starts, then by the task: one task runs at a time, and start and stop are serialized by s_estopMutex. -static std::optional s_latchStored = std::nullopt; - -// How long to wait before writing the latch again after a write failed (a busy store is retried on the next sample). -const int64_t k_latchRetryInterval = 1000; - static void estopmgr_publishState(EStopState state, EStopState& lastState, TickType_t timeout = pdMS_TO_TICKS(750)) { if (state == lastState) { @@ -67,36 +58,13 @@ static void estopmgr_publishState(EStopState state, EStopState& lastState, TickT static void estopmgr_latchWithoutTask(); -// Stores the latch when it differs from what is stored. Returns whether it touched flash, which takes the task off its -// fixed sample rate for the length of the write. -static bool estopmgr_storeLatch(bool latched, int64_t now, int64_t& nextAttempt) -{ - if (s_latchStored == latched || now < nextAttempt) { - return false; - } - - switch (Config::SaveEStopLatched(latched)) { - case Config::LatchSave::Saved: - s_latchStored = latched; - return true; - case Config::LatchSave::Busy: - return false; // Another config writer: try again on the next sample - case Config::LatchSave::Failed: - default: - OS_LOGE(TAG, "Failed to store the E-Stop latch (%s), retrying", latched ? "latched" : "released"); - nextAttempt = now + k_latchRetryInterval; - return true; - } -} - // Samples the estop at a fixed rate and updates internal state + events static void estopmgr_managerTask(gpio_num_t estopPin) { EStopStateMachine machine; EStopState lastPublishedState = EStopState::Idle; - // Carry over an E-Stop latched while no task was running (software trigger, or the latch stored before a reboot); - // it must be cleared with the button. + // Carry over an E-Stop latched while no task was running (software trigger); it must be cleared with the button. int64_t latchedAt = s_estopActivatedAt.load(std::memory_order_relaxed); if (latchedAt != 0) { machine.Trigger(latchedAt); @@ -107,8 +75,6 @@ static void estopmgr_managerTask(gpio_num_t estopPin) // Fed every 5 ms sample, so only a real hang (not a slow post or log) can trip it. TaskUtils::TaskWatchdog watchdog; - int64_t nextLatchAttempt = 0; - // Check if killing manager was requested, continue looping otherwise TickType_t lastWake = xTaskGetTickCount(); while (!s_estopTask.stopRequested()) { @@ -138,14 +104,6 @@ static void estopmgr_managerTask(gpio_num_t estopPin) } estopmgr_publishState(machine.state(), lastPublishedState); - - // Only once the state is mirrored and published: the E-Stop is in effect before its latch reaches flash, and a - // slow write never delays it. - if (estopmgr_storeLatch(machine.activatedAt() != 0, now, nextLatchAttempt)) { - // Resume the fixed rate from here instead of catching up with back-to-back samples, which would shorten the - // debounce window. - lastWake = xTaskGetTickCount(); - } } // An active E-Stop survives the stop: s_estopActivatedAt stays set, and the next task takes it over (or, with no @@ -264,33 +222,12 @@ bool EStopManager::Init() return false; } - // Read before anything can transmit: CommandHandler and the RF transmitter start after this. - bool latched = OpenShock::Config::LoadEStopLatched(); - s_latchStored = latched; - if (!cfg->enabled) { - if (latched) { - // Without an input there is no button to clear it, so it would stop the hub for good. Drop it. - OS_LOGW(TAG, "E-Stop was latched, but the E-Stop is disabled; releasing the latch"); - if (OpenShock::Config::SaveEStopLatched(false) == OpenShock::Config::LatchSave::Saved) { - s_latchStored = false; - } - } return true; } OpenShock::ScopedLock lock__(&s_estopMutex); - if (latched) { - // Active from here on, through IsEStopped(); the task takes it over and it is cleared with the button. Even if the - // input can't be set up below, it stays active until a reboot that can. - OS_LOGW(TAG, "E-Stop was latched before the restart, keeping it active until it is cleared with the button"); - s_estopActivatedAt.store(std::max(OpenShock::millis(), 1)); - s_estopActive.store(true); - EStopState lastState = EStopState::Idle; - estopmgr_publishState(EStopState::Active, lastState); - } - if (!estopmgr_configurePin(cfg->gpioPin)) { return false; } diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp index 0c28b5c17..8b0e1eb5c 100644 --- a/components/device_control/src/estop/EStopConfig.cpp +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -18,7 +18,6 @@ namespace Fbs = OpenShock::Serialization::Configuration; static constexpr const char* kNamespace = "estop"; static constexpr const char* kKeyEnabled = "enabled"; static constexpr const char* kKeyGpio = "gpio"; -static constexpr const char* kKeyLatched = "latched"; static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "estop", .order = 6, .hubConfigField = Fbs::HubConfig::VT_ESTOP, .namespaces = kNamespaces}); @@ -170,50 +169,3 @@ bool OpenShock::Config::SetEStopGpioPin(gpio_num_t gpioPin) return true; }); } - -bool OpenShock::Config::LoadEStopLatched() -{ - ScopedLock lock__(&Internal::WriteLock()); // The partition can't change or close under the read - - const char* partition = Internal::Partition(); - if (partition == nullptr) { - return false; - } - - auto handle = Nvs::Handle::Open(partition, kNamespace, false); - if (!handle) { - return false; // No estop namespace yet: never latched - } - - uint8_t latched = 0; - switch (handle->GetU8(kKeyLatched, latched)) { - case Nvs::Result::Found: - return latched != 0; - case Nvs::Result::Missing: - return false; - case Nvs::Result::Error: - default: - OS_LOGE(TAG, "Failed to read the E-Stop latch, treating it as latched"); - return true; - } -} - -LatchSave OpenShock::Config::SaveEStopLatched(bool latched) -{ - ScopedTryLock lock__(&Internal::WriteLock(), 0); - if (!lock__.isLocked()) { - return LatchSave::Busy; - } - - const char* partition = Internal::Partition(); - if (partition == nullptr) { - return LatchSave::Saved; - } - - auto handle = Nvs::Handle::Open(partition, kNamespace, true); - if (!handle || !handle->SetU8(kKeyLatched, latched ? 1 : 0) || !handle->Commit()) { - return LatchSave::Failed; - } - - return LatchSave::Saved; -} diff --git a/docs/STORAGE.md b/docs/STORAGE.md index b3d7102ee..91cde8f2b 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -39,14 +39,6 @@ always kept free for compaction) it holds the whole config with some 32 long WiF | `otaUpdate` | core (`OtaUpdateConfig.h`) | `ota` | | `estop` | device_control (`estop/EStopConfig.h`) | `estop` | -The `estop` namespace also holds the E-Stop latch, `latched` (u8), which is state rather than config: it is not in -`jsonconfig` or the wire form. The E-Stop task writes it when the E-Stop activates and when it is cleared with the -button, after the change is already in effect. At boot it is read before anything can transmit, and a set latch keeps the -E-Stop active until it is cleared with the button. A missing key reads as not latched, so a fresh hub starts usable, and -a read error reads as latched. A latch on a hub whose E-Stop is disabled is dropped, since no button could clear it. -A factory reset clears it. A latch set while another config write holds the store (the migration's table write, for -one) reaches flash only once that write is done. - The store's own namespace is `root`, with `schema` (u8, 1) marking a filled store. Namespace and key names are at most 15 characters and are permanent once hubs hold them: renaming one is a migration, not an edit. A missing key reads as its default and an unknown key is ignored, so adding or removing a field never changes the schema. From 02ed74c29a30a5a898e076fb4ac99fc5b6679083 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 30 Oct 2026 20:42:03 +0100 Subject: [PATCH 09/23] revert(estop): move the IsEStopped read order fix to develop The race is in develop as well and has nothing to do with the config store; it now lands on develop directly. This reverts commit 2cb8c73b. --- components/device_control/src/EStopManager.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/device_control/src/EStopManager.cpp b/components/device_control/src/EStopManager.cpp index 3f662fec8..e0238bd22 100644 --- a/components/device_control/src/EStopManager.cpp +++ b/components/device_control/src/EStopManager.cpp @@ -319,8 +319,7 @@ bool EStopManager::SetEStopPin(gpio_num_t pin) bool EStopManager::IsEStopped() { - // The trigger first: the task sets s_estopActive before clearing it, so a cleared trigger means the flag is set - return s_externallyTriggered.load() || s_estopActive.load(); + return s_estopActive.load() || s_externallyTriggered.load(); } int64_t EStopManager::LastEStopped() From 961513ae03da08323e079ef5d3952ebaf7f19899 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 30 Oct 2026 20:48:45 +0100 Subject: [PATCH 10/23] chore: add the change file for the NVS config store --- .changes/config-nvs.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changes/config-nvs.md diff --git a/.changes/config-nvs.md b/.changes/config-nvs.md new file mode 100644 index 000000000..9af293e15 --- /dev/null +++ b/.changes/config-nvs.md @@ -0,0 +1,20 @@ +--- +kind: changed +--- +Keep the configuration in NVS instead of a littlefs file + +## Release Note +Settings are now stored in NVS + +The hub keeps its settings in the chip's NVS key-value store instead of a single config file, and saves each setting on its own when it changes. Your WiFi networks, account link and other settings from 1.x are imported automatically on the first boot after updating. + +- Each module owns its section of the config (`config/Section.h`); every section type meets the `SectionValue` concept, with its defaults defined in one place and its validation run on every value the section takes in +- On the 2.x partition table the store is the `cfg` partition (the 1.x littlefs `config` partition retyped in place); on the 1.x table it lives in the shared `nvs` partition until the layout is migrated, and then moves into `cfg` +- An empty store is filled once from the 1.x config file, which is never modified, otherwise from the defaults +- WiFi credentials are stored one per key; the WiFi driver's own NVS store is compiled out +- A section that can't be read runs on its defaults without erasing what is stored, and the save after a failed one rewrites every key +- A store written by newer firmware is loaded instead of reset, and a `cfg` partition in a newer NVS format is never erased +- `Config::MigrateToCfgPartition` retypes the `config` partition as `cfg`; nothing calls it yet + +## Notices +- warning: **1.x firmware does not see settings changed after this update.** Downgrading to 1.x brings back the settings the hub had before updating. Re-enter any WiFi networks or pairing you changed since. From 4cbc822d9931ee350674b3b1a507b74415837b53 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 04:50:34 +0100 Subject: [PATCH 11/23] fix(config): keep stored networks and the first fill safe on partial failures A WiFi credential blob that does not read is dropped on its own instead of failing the section: a failed load ran the section on an empty list, and its next save swept every stored credential that was not in it. The shared nvs copy is erased root-last, like it is copied, so a power cut mid-cleanup leaves the schema marker and the next boot finishes the job instead of stranding the credentials in the shared partition. fillStore marks the store filled only when every section reset and read back as written, so a first fill (1.x import included) cut short by a write failure is redone on the next boot. The change file and STORAGE.md say the 1.x downgrade promise holds only until the partition migration runs, and that an erased shared nvs on the 1.x layout re-imports the 1.x settings. --- .changes/config-nvs.md | 6 ++-- .../config/host_test/main/test_roundtrip.cpp | 30 +++++++++++++++++++ .../config/host_test/main/test_storage.cpp | 18 +++++++++++ components/config/src/Store.cpp | 20 +++++++++---- components/core/src/wifi/WiFiConfig.cpp | 9 +++--- docs/STORAGE.md | 10 +++++-- 6 files changed, 78 insertions(+), 15 deletions(-) diff --git a/.changes/config-nvs.md b/.changes/config-nvs.md index 9af293e15..82000191c 100644 --- a/.changes/config-nvs.md +++ b/.changes/config-nvs.md @@ -12,9 +12,11 @@ The hub keeps its settings in the chip's NVS key-value store instead of a single - On the 2.x partition table the store is the `cfg` partition (the 1.x littlefs `config` partition retyped in place); on the 1.x table it lives in the shared `nvs` partition until the layout is migrated, and then moves into `cfg` - An empty store is filled once from the 1.x config file, which is never modified, otherwise from the defaults - WiFi credentials are stored one per key; the WiFi driver's own NVS store is compiled out -- A section that can't be read runs on its defaults without erasing what is stored, and the save after a failed one rewrites every key +- A section that can't be read runs on its defaults without erasing what is stored, and the save after a failed one rewrites every key; a single WiFi credential that doesn't read is dropped on its own, so the rest still load +- The store is marked filled only once every section reads back from flash as written; a first fill cut short is redone on the next boot - A store written by newer firmware is loaded instead of reset, and a `cfg` partition in a newer NVS format is never erased - `Config::MigrateToCfgPartition` retypes the `config` partition as `cfg`; nothing calls it yet ## Notices -- warning: **1.x firmware does not see settings changed after this update.** Downgrading to 1.x brings back the settings the hub had before updating. Re-enter any WiFi networks or pairing you changed since. +- warning: **1.x firmware does not see settings changed after this update.** Downgrading to 1.x brings back the settings the hub had before updating, as long as the partition layout has not been migrated yet. Re-enter any WiFi networks or pairing you changed since. +- warning: Until the partition layout is migrated the settings live in the chip's shared NVS partition. If that partition has to be erased (it fills up, or a newer firmware wrote it in a format this one cannot read), the hub imports the 1.x settings again and anything changed since the update is lost. diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 4c5655a68..0878f5d47 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -117,6 +117,36 @@ TEST_CASE("Credentials are enumerated from their keys, never counted", "[config] TEST_ASSERT_EQUAL_STRING("Max", (*creds)[2].ssid.c_str()); } +TEST_CASE("One credential that does not read is dropped; the others load and survive the next save", "[config][roundtrip]") +{ + // A credential blob NVS hands back unreadable must not fail the wifi load: a failed load runs the section on an + // empty list, and the next save sweeps every stored credential that is not in it, so every network would be lost. + const Full sample = MakeSampleConfig(); + InitWith(sample); + { + auto wifi = Config::Nvs::Handle::Open(Keys::kPartitionCfg, Keys::Ns::kWifi, true); + TEST_ASSERT_TRUE(wifi.has_value()); + const uint8_t garbage[] = {0xFF, 0x02, 0x00}; // unknown blob version, id 2 + TEST_ASSERT_TRUE(wifi->SetBlob("2", garbage)); + TEST_ASSERT_TRUE(wifi->Commit()); + } + Reload(); + + Full expected = sample; + expected.wifi.credentialsList.erase(expected.wifi.credentialsList.begin() + 1); // Guest, id 2 + AssertConfigEqual(expected, Snapshot()); + + // The section is in sync with flash: a changed-only save adds one key and leaves the readable credentials alone + TEST_ASSERT_EQUAL_UINT8(2, Config::AddWiFiCredentials("Cafe", "latte123", WIFI_AUTH_WPA2_PSK)); + Reload(); + auto creds = Config::GetWiFiCredentials(); + TEST_ASSERT_TRUE(creds.has_value()); + TEST_ASSERT_EQUAL_size_t(3, creds->size()); + TEST_ASSERT_EQUAL_UINT8(1, (*creds)[0].id); + TEST_ASSERT_EQUAL_STRING("Cafe", (*creds)[1].ssid.c_str()); + TEST_ASSERT_EQUAL_UINT8(7, (*creds)[2].id); +} + TEST_CASE("GetRaw writes fields that equal their schema default", "[config][roundtrip]") { // 1.5.x reads a missing is_enabled/keepalive_enabled as false, so omitting them breaks loading the dump there. diff --git a/components/config/host_test/main/test_storage.cpp b/components/config/host_test/main/test_storage.cpp index 26e6b5a20..6b28f90fa 100644 --- a/components/config/host_test/main/test_storage.cpp +++ b/components/config/host_test/main/test_storage.cpp @@ -119,6 +119,24 @@ TEST_CASE("A config in cfg wins over a stale copy in the shared nvs, which is cl TEST_ASSERT_FALSE(HasSchema(Keys::kPartitionShared)); } +TEST_CASE("A cleanup of the shared copy cut short is finished on the next boot", "[config][storage]") +{ + // The shared copy is erased section by section with `root` last: a power cut mid-way leaves the schema marker, + // which is what makes the next boot look at the shared partition again. Here the cut came after two sections. + InitWith(MakeSampleConfig()); + StoreInShared(MakeSampleConfig()); + TEST_ASSERT_TRUE(Nvs::EraseNamespace(Keys::kPartitionShared, Keys::Ns::kOta)); + TEST_ASSERT_TRUE(Nvs::EraseNamespace(Keys::kPartitionShared, Keys::Ns::kSerial)); + TEST_ASSERT_TRUE(HasSchema(Keys::kPartitionShared)); + + Reload(); + AssertConfigEqual(MakeSampleConfig(), Snapshot()); + TEST_ASSERT_FALSE(HasSchema(Keys::kPartitionShared)); + for (const char* ns : Keys::Ns::kAll) { + TEST_ASSERT_EQUAL_size_t_MESSAGE(0, KeyCount(Keys::kPartitionShared, ns), ns); + } +} + TEST_CASE("The move is idempotent and verified", "[config][storage]") { WipeStore(); diff --git a/components/config/src/Store.cpp b/components/config/src/Store.cpp index 4df5a1928..931ba5cf3 100644 --- a/components/config/src/Store.cpp +++ b/components/config/src/Store.cpp @@ -184,9 +184,11 @@ static const char* moveSharedIntoCfg() OS_LOGI(TAG, "Moved the config from the shared nvs partition into cfg"); } + // Walked backwards like the copy, so `root` goes last: a power cut mid-erase leaves the schema marker, and the + // cleanup is redone on the next boot instead of stranding the rest (WiFi passwords included) in the shared nvs bool erased = true; - for (const char* ns : namespaces) { - erased &= Config::Nvs::EraseNamespace(kPartitionShared, ns); + for (std::size_t i = namespaces.size(); i-- > 0;) { + erased &= Config::Nvs::EraseNamespace(kPartitionShared, namespaces[i]); } if (erased) { OS_LOGI(TAG, "Removed the config copy from the shared nvs partition"); @@ -257,10 +259,13 @@ static bool applyDocument(JSON::JsonView root, bool full) return ok; } -// Requires the write lock. Fills an unmarked store: the 1.x file if there is one, else the defaults. +// Requires the write lock. Fills an unmarked store: the 1.x file if there is one, else the defaults. The store is +// only marked once every section is on flash as the hub runs it; a write that failed leaves it unmarked, so the fill +// (the 1.x import included) is redone on the next boot. static void fillStore() { - bool filled = false; + bool filled = false; + bool written = true; if (auto legacy = readLegacyConfig()) { JSON::JsonDocument doc; if (doc.parse(*legacy) && applyDocument(doc.root(), true)) { @@ -272,7 +277,7 @@ static void fillStore() if (!filled) { OS_LOGW(TAG, "No stored config, writing the defaults"); for (auto* section = Config::SectionBase::First(); section != nullptr; section = section->Next()) { - section->Reset(s_partition); + written &= section->Reset(s_partition); } } @@ -283,8 +288,13 @@ static void fillStore() for (auto* section = Config::SectionBase::First(); section != nullptr; section = section->Next()) { if (!section->VerifyStored(s_partition)) { OS_LOGE(TAG, "The %s config does not read back as written to %s", section->Info().name, s_partition); + written = false; } } + if (!written) { + OS_LOGE(TAG, "The config store is incomplete; it will be filled again on the next boot"); + return; + } if (!writeSchema(s_partition)) { OS_LOGE(TAG, "Failed to mark the config store; it will be filled again on the next boot"); } diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index 5bfc9e47c..d1d23f8d9 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -188,13 +188,12 @@ bool WiFiConfig::LoadNvs(const char* partition) OS_LOGW(TAG, "Ignoring credential under key %s", key.c_str()); continue; } + // One credential that does not read is dropped, not the section: a failed load runs the section on an empty list, + // and its next save then sweeps every stored credential that is not in that list std::vector blob; - if (handle->GetBlob(key.c_str(), blob) != Nvs::Result::Found) { - return false; - } WiFiCredentials creds; - if (!creds.FromBlob(blob) || creds.id != *id) { - OS_LOGW(TAG, "Dropping malformed credential %s", key.c_str()); + if (handle->GetBlob(key.c_str(), blob) != Nvs::Result::Found || !creds.FromBlob(blob) || creds.id != *id) { + OS_LOGW(TAG, "Dropping unreadable credential %s", key.c_str()); continue; } credentialsList.push_back(std::move(creds)); diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 91cde8f2b..97b0b3c31 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -54,11 +54,14 @@ Where the store is: - On the 1.x table, which has no `cfg`, the same namespaces in the shared `nvs` partition. The first boot that finds `cfg` copies them over key by key, compares the copy, and deletes the shared one. - An empty store is filled once: from the 1.x littlefs config file (`/config` on the 1.x `config` partition, read - only, so a rollback to 1.x still finds it) when there is one, else from every section's defaults. + only, so a rollback to 1.x still finds it until the config partition migration below has run) when there is one, + else from every section's defaults. The store is marked filled only once every section reads back from flash as + written; a fill that failed part-way is redone on the next boot. - Without any usable NVS, the hub runs on defaults in RAM and logs it. The shared `nvs` belongs to ESP-IDF (PHY calibration cache) and may be erased at any time; nothing of OpenShock's is -kept there once `cfg` exists. The WiFi driver's own NVS store is compiled out (`CONFIG_ESP_WIFI_NVS_ENABLED=n`). +kept there once `cfg` exists. Until then an erased shared `nvs` is an empty store: the hub imports the 1.x file again +and loses what changed since the update. The WiFi driver's own NVS store is compiled out (`CONFIG_ESP_WIFI_NVS_ENABLED=n`). `sysinfo` on the serial console prints where the config is. ## The config partition migration @@ -73,7 +76,8 @@ that pipelines the storage migrations decides when it runs. 3. Config writes are held, the WiFi driver's stale `nvs.net80211` store is dropped, NVS is closed, and the table sector is erased and written through the OTA path and read back. A write that will not stick puts the old table back; the hub never restarts without a table that parses. -4. The hub restarts; `Store` erases the littlefs left in `cfg` and moves the config in. +4. The hub restarts; `Store` erases the littlefs left in `cfg` (the 1.x config file with it: from here a downgrade to + 1.x starts from defaults) and moves the config in. ## Portal assets From cb4ac5e3fa41504b5c9eaf853bec5867d5a49217 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 06:17:58 +0100 Subject: [PATCH 12/23] fix(config): tell a missing namespace from a failed open, and validate what every path takes in Nvs::Handle::Open returned an empty optional both for a namespace that was never written and for an open that failed, so every section's LoadNvs reported a failed open as a successful load of the defaults. The section then trusted those defaults as what flash holds: a later save of the same value skipped the key, and the stored value came back on the next boot. The store's schema check had the same blind spot and could refill, or clean up the shared copy, on a read it could not make. - Add Nvs::Handle::OpenRead, a read-only open that returns Found / Missing / Error, and use it in every LoadNvs and in the store's schema check; an error is a failed load, and the store neither fills nor moves anything when it cannot read a marker - Reject a jsonconfig document whose alwaysEnabled or echoEnabled is present but not a boolean, instead of storing the default over the running value - Normalize a zero OTA check interval and an invalid hostname to their defaults, so NVS, JSON and the setters are held to the rule the captive portal already enforces - Correct the section contract in docs/STORAGE.md: a default constructor and Normalize, not ToDefault --- .changes/config-nvs.md | 3 + .../src/captiveportal/CaptivePortalConfig.cpp | 26 +++++++-- .../config/host_test/main/test_corrupt.cpp | 57 +++++++++++++++++++ components/config/include/config/Nvs.h | 6 ++ components/config/src/Nvs.cpp | 16 ++++++ components/config/src/Store.cpp | 44 +++++++++++--- components/core/include/OtaUpdateConfig.h | 5 +- components/core/include/wifi/WiFiConfig.h | 5 +- components/core/src/BackendConfig.cpp | 13 +++-- components/core/src/OtaUpdateConfig.cpp | 29 +++++++--- components/core/src/wifi/WiFiConfig.cpp | 36 +++++++++--- .../device_control/src/estop/EStopConfig.cpp | 13 +++-- .../device_control/src/radio/RFConfig.cpp | 13 +++-- .../serial_console/src/SerialInputConfig.cpp | 26 +++++++-- docs/STORAGE.md | 10 +++- 15 files changed, 249 insertions(+), 53 deletions(-) diff --git a/.changes/config-nvs.md b/.changes/config-nvs.md index 82000191c..c0fcf65c1 100644 --- a/.changes/config-nvs.md +++ b/.changes/config-nvs.md @@ -15,6 +15,9 @@ The hub keeps its settings in the chip's NVS key-value store instead of a single - A section that can't be read runs on its defaults without erasing what is stored, and the save after a failed one rewrites every key; a single WiFi credential that doesn't read is dropped on its own, so the rest still load - The store is marked filled only once every section reads back from flash as written; a first fill cut short is redone on the next boot - A store written by newer firmware is loaded instead of reset, and a `cfg` partition in a newer NVS format is never erased +- A section whose NVS namespace fails to open is a failed load, not a load of the defaults: the defaults are only taken when the namespace was never written. The store likewise never fills or cleans up when its own marker cannot be read +- A boolean field of the wrong type in a `jsonconfig set` document rejects the document instead of storing the default +- An OTA check interval of zero and a hostname esp_netif would refuse become their defaults on every path (NVS, JSON and the setters), not only through the captive portal - `Config::MigrateToCfgPartition` retypes the `config` partition as `cfg`; nothing calls it yet ## Notices diff --git a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp index 487e0066a..465ef377c 100644 --- a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp +++ b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp @@ -52,7 +52,18 @@ bool CaptivePortalConfig::FromJSON(JSON::JsonView json) return false; } - alwaysEnabled = json["alwaysEnabled"].tryGetBool().value_or(false); + // A field that is there but is not a bool is a bad document, not a default: accepting it would store `false` over + // whatever the hub has. + if (JSON::JsonView field = json["alwaysEnabled"]; field.valid()) { + auto parsed = field.tryGetBool(); + if (!parsed) { + OS_LOGE(TAG, "alwaysEnabled is not a boolean"); + return false; + } + alwaysEnabled = *parsed; + } else { + alwaysEnabled = false; + } return true; } @@ -66,10 +77,15 @@ void CaptivePortalConfig::ToJSON(json_gen_str_t* gen, const char* name, bool wit bool CaptivePortalConfig::LoadNvs(const char* partition) { - auto handle = Nvs::Handle::Open(partition, kNamespace, false); - if (!handle) { - *this = CaptivePortalConfig(); - return true; + std::optional handle; + switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { + case Nvs::Result::Error: + return false; + case Nvs::Result::Missing: + *this = CaptivePortalConfig(); + return true; + case Nvs::Result::Found: + break; } return Nvs::Load(*handle, kKeyAlways, alwaysEnabled, false); } diff --git a/components/config/host_test/main/test_corrupt.cpp b/components/config/host_test/main/test_corrupt.cpp index 612396690..a8248ba32 100644 --- a/components/config/host_test/main/test_corrupt.cpp +++ b/components/config/host_test/main/test_corrupt.cpp @@ -278,6 +278,63 @@ TEST_CASE("An unknown OTA channel in JSON falls back to the default, not the run TEST_ASSERT_EQUAL_UINT8(std::to_underlying(OtaUpdateStep::None), std::to_underlying(loaded.otaUpdate.updateStep)); } +TEST_CASE("A boolean field of the wrong type rejects the document instead of storing the default", "[config][corrupt][json]") +{ + Full sample = MakeSampleConfig(); + sample.captivePortal.alwaysEnabled = true; + sample.serialInput.echoEnabled = false; + InitWith(sample); + const std::size_t before = UsedEntries(); + + TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"captivePortal":{"alwaysEnabled":"false"}})")); + TEST_ASSERT_FALSE(Config::SaveFromJSON(R"({"serialInput":{"echoEnabled":1}})")); + TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); + AssertConfigEqual(sample, Snapshot()); + + // A missing field is still its default + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"captivePortal":{},"serialInput":{}})")); + TEST_ASSERT_FALSE(Snapshot().captivePortal.alwaysEnabled); + TEST_ASSERT_TRUE(Snapshot().serialInput.echoEnabled); +} + +TEST_CASE("A zero OTA check interval is normalized to the default on every path", "[config][corrupt][json]") +{ + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"otaUpdate":{"checkPeriodically":true,"checkInterval":0}})")); + TEST_ASSERT_EQUAL_UINT16(30, Snapshot().otaUpdate.checkInterval); + + TEST_ASSERT_TRUE(Config::UpdateOtaUpdateConfig([](Config::OtaUpdateConfig& cfg) { + cfg.checkInterval = 0; + return true; + })); + TEST_ASSERT_EQUAL_UINT16(30, Snapshot().otaUpdate.checkInterval); + Reload(); + TEST_ASSERT_EQUAL_UINT16(30, Snapshot().otaUpdate.checkInterval); +} + +TEST_CASE("An invalid hostname is normalized to the default on every path", "[config][corrupt][json]") +{ + InitDefault(); + for (const char* doc : + { + R"({"wifi":{"hostname":"","credentials":[]}})", + R"({"wifi":{"hostname":"-hub","credentials":[]}})", + R"({"wifi":{"hostname":"my hub","credentials":[]}})", + R"({"wifi":{"hostname":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","credentials":[]}})", + }) + { + TEST_ASSERT_TRUE_MESSAGE(Config::SaveFromJSON(doc), doc); + TEST_ASSERT_EQUAL_STRING_MESSAGE(CONFIG_OPENSHOCK_FW_HOSTNAME, Snapshot().wifi.hostname.c_str(), doc); + } + + TEST_ASSERT_TRUE(Config::SetWiFiHostname("hub_1")); + TEST_ASSERT_EQUAL_STRING(CONFIG_OPENSHOCK_FW_HOSTNAME, Snapshot().wifi.hostname.c_str()); + TEST_ASSERT_TRUE(Config::SetWiFiHostname("hub-1")); + TEST_ASSERT_EQUAL_STRING("hub-1", Snapshot().wifi.hostname.c_str()); + Reload(); + TEST_ASSERT_EQUAL_STRING("hub-1", Snapshot().wifi.hostname.c_str()); +} + TEST_CASE("Schema fields the firmware does not model are dropped on import", "[config][corrupt]") { // estop.active / estop.latching (and wifi AP/LAN fields) exist in the 1.x schema but nothing in the firmware diff --git a/components/config/include/config/Nvs.h b/components/config/include/config/Nvs.h index bad969838..fb2d9cffa 100644 --- a/components/config/include/config/Nvs.h +++ b/components/config/include/config/Nvs.h @@ -31,8 +31,14 @@ namespace OpenShock::Config::Nvs { public: /// @brief Opens `ns` in `partition`. Read-only opens of a namespace that does not exist yet fail with nullopt /// and no error logged: that is what a fresh store looks like, and every read then yields Missing. + /// A load must not use this: it cannot tell that case from a failed open (use OpenRead). [[nodiscard]] static std::optional Open(const char* partition, const char* ns, bool write); + /// @brief Opens `ns` in `partition` read-only for a load. Found: `out` holds the handle. Missing: the namespace + /// was never written, `out` is empty and every key is its default. Error: the open failed and the load + /// must fail with it, since defaults would be taken for what is on flash. + [[nodiscard]] static Result OpenRead(const char* partition, const char* ns, std::optional& out); + Handle(Handle&& other) noexcept; Handle& operator=(Handle&& other) noexcept; Handle(const Handle&) = delete; diff --git a/components/config/src/Nvs.cpp b/components/config/src/Nvs.cpp index a22709498..84d7a723f 100644 --- a/components/config/src/Nvs.cpp +++ b/components/config/src/Nvs.cpp @@ -25,6 +25,22 @@ std::optional Nvs::Handle::Open(const char* partition, const char* return std::nullopt; } +Nvs::Result Nvs::Handle::OpenRead(const char* partition, const char* ns, std::optional& out) +{ + out.reset(); + nvs_handle_t handle = 0; + esp_err_t err = nvs_open_from_partition(partition, ns, NVS_READONLY, &handle); + if (err == ESP_OK) { + out = Handle(handle); + return Result::Found; + } + if (err == ESP_ERR_NVS_NOT_FOUND) { + return Result::Missing; + } + OS_LOGE(TAG, "Failed to open %s/%s: %s", partition, ns, esp_err_to_name(err)); + return Result::Error; +} + Nvs::Handle::Handle(Handle&& other) noexcept : m_handle(std::exchange(other.m_handle, 0)) { diff --git a/components/config/src/Store.cpp b/components/config/src/Store.cpp index 931ba5cf3..fffe1d934 100644 --- a/components/config/src/Store.cpp +++ b/components/config/src/Store.cpp @@ -17,6 +17,7 @@ const char* const TAG = "Config"; #include #include +#include #include #include @@ -48,15 +49,29 @@ const char* Config::StorageLabel() #define CONFIG_LOCK_WRITE() ScopedLock lock__(&Config::Internal::WriteLock()) +// Whether `partition` holds a filled store: true, false, or nullopt when NVS would not say (a failed open or read). // A store marked by a newer firmware (a rollback) counts as filled: it is loaded best-effort, never refilled. -static bool hasSchema(const char* partition) +static std::optional hasSchema(const char* partition) { - auto handle = Config::Nvs::Handle::Open(partition, kRootNamespace, false); - if (!handle) { - return false; + std::optional handle; + switch (Config::Nvs::Handle::OpenRead(partition, kRootNamespace, handle)) { + case Config::Nvs::Result::Error: + return std::nullopt; + case Config::Nvs::Result::Missing: + return false; + case Config::Nvs::Result::Found: + break; } uint8_t schema = 0; - if (handle->GetU8(kSchemaKey, schema) != Config::Nvs::Result::Found || schema < kSchemaVersion) { + switch (handle->GetU8(kSchemaKey, schema)) { + case Config::Nvs::Result::Error: + return std::nullopt; + case Config::Nvs::Result::Missing: + return false; + case Config::Nvs::Result::Found: + break; + } + if (schema < kSchemaVersion) { return false; } if (schema > kSchemaVersion) { @@ -164,12 +179,23 @@ static const char* openStorage() // the copy failed. static const char* moveSharedIntoCfg() { - if (!nvsPartitionExists(kPartitionShared) || !initNvsPartition(kPartitionShared, true) || !hasSchema(kPartitionShared)) { + if (!nvsPartitionExists(kPartitionShared) || !initNvsPartition(kPartitionShared, true)) { + return kPartitionCfg; + } + // Nothing is moved or erased on a read that failed: a shared copy that cannot be read is left for the next boot, and + // so is one whose cfg cannot be checked, since erasing it then could erase the only copy + const std::optional sharedFilled = hasSchema(kPartitionShared); + const std::optional cfgFilled = hasSchema(kPartitionCfg); + if (!sharedFilled.has_value() || !cfgFilled.has_value()) { + OS_LOGE(TAG, "Could not tell whether the config lives in the shared nvs partition; leaving both partitions as they are"); + return kPartitionCfg; + } + if (!*sharedFilled) { return kPartitionCfg; } const auto namespaces = allNamespaces(); // kRootNamespace first, so walked backwards - if (!hasSchema(kPartitionCfg)) { + if (!*cfgFilled) { bool ok = true; for (std::size_t i = namespaces.size(); ok && i-- > 0;) { ok = Config::Nvs::CopyNamespace(kPartitionShared, kPartitionCfg, namespaces[i]) && Config::Nvs::NamespacesEqual(kPartitionShared, kPartitionCfg, namespaces[i]); @@ -309,7 +335,9 @@ void Config::Init() s_partition = moveSharedIntoCfg(); } - if (s_partition == nullptr || !hasSchema(s_partition)) { + // A store whose marker cannot be read is loaded as if marked: filling it would write the defaults over whatever is + // there, while a failed load leaves flash alone and runs the section on its defaults + if (s_partition == nullptr || hasSchema(s_partition) == false) { fillStore(); return; } diff --git a/components/core/include/OtaUpdateConfig.h b/components/core/include/OtaUpdateConfig.h index b45a5d285..9bfce3fa9 100644 --- a/components/core/include/OtaUpdateConfig.h +++ b/components/core/include/OtaUpdateConfig.h @@ -40,8 +40,9 @@ namespace OpenShock::Config { int32_t updateId; OtaUpdateStep updateStep; - /// @brief No rules to apply yet. The section runs it on every value it takes in. - void Normalize() { } + /// @brief The section runs it on every value it takes in: a zero check interval would check on every wake-up + /// of the update task, so it becomes the default (the captive portal refuses it, JSON and NVS do not). + void Normalize(); [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h index b327fd089..83ab48bdd 100644 --- a/components/core/include/wifi/WiFiConfig.h +++ b/components/core/include/wifi/WiFiConfig.h @@ -25,8 +25,9 @@ namespace OpenShock::Config { std::string hostname; std::vector credentialsList; // in id order once loaded from NVS - /// @brief No rules to apply yet. The section runs it on every value it takes in. - void Normalize() { } + /// @brief The section runs it on every value it takes in: a hostname esp_netif would refuse (empty, over 32 + /// characters, or not letters, digits and hyphens) becomes the default, whichever way it came in. + void Normalize(); bool LoadNvs(const char* partition); bool SaveNvs(const char* partition, const WiFiConfig* previous) const; diff --git a/components/core/src/BackendConfig.cpp b/components/core/src/BackendConfig.cpp index 30ebb6b68..13378cadc 100644 --- a/components/core/src/BackendConfig.cpp +++ b/components/core/src/BackendConfig.cpp @@ -84,10 +84,15 @@ void BackendConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensi bool BackendConfig::LoadNvs(const char* partition) { - auto handle = Nvs::Handle::Open(partition, kNamespace, false); - if (!handle) { - *this = BackendConfig(); - return true; + std::optional handle; + switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { + case Nvs::Result::Error: + return false; + case Nvs::Result::Missing: + *this = BackendConfig(); + return true; + case Nvs::Result::Found: + break; } return Nvs::Load(*handle, kKeyDomain, domain, CONFIG_OPENSHOCK_API_DOMAIN) && Nvs::Load(*handle, kKeyToken, authToken, ""); } diff --git a/components/core/src/OtaUpdateConfig.cpp b/components/core/src/OtaUpdateConfig.cpp index 7815bcd26..e52f4127f 100644 --- a/components/core/src/OtaUpdateConfig.cpp +++ b/components/core/src/OtaUpdateConfig.cpp @@ -27,6 +27,8 @@ static constexpr const char* kKeyManual = "manual"; static constexpr const char* kKeyUpdateId = "update_id"; static constexpr const char* kKeyStep = "step"; +static constexpr uint16_t kDefaultCheckInterval = 30; // minutes + static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "otaUpdate", .order = 5, .hubConfigField = Fbs::HubConfig::VT_OTA_UPDATE, .namespaces = kNamespaces}); @@ -43,7 +45,7 @@ OtaUpdateConfig::OtaUpdateConfig() , updateChannel(OtaUpdateChannel::Stable) , checkOnStartup(false) , checkPeriodically(false) - , checkInterval(30) + , checkInterval(kDefaultCheckInterval) , allowBackendManagement(true) , requireManualApproval(false) , updateId(0) @@ -112,7 +114,7 @@ bool OtaUpdateConfig::FromJSON(JSON::JsonView json) checkOnStartup = json["checkOnStartup"].tryGetBool().value_or(false); checkPeriodically = json["checkPeriodically"].tryGetBool().value_or(false); - checkInterval = json["checkInterval"].tryGetU16().value_or(30); + checkInterval = json["checkInterval"].tryGetU16().value_or(kDefaultCheckInterval); allowBackendManagement = json["allowBackendManagement"].tryGetBool().value_or(true); requireManualApproval = json["requireManualApproval"].tryGetBool().value_or(false); updateId = json["updateId"].tryGetI32().value_or(0); @@ -138,18 +140,31 @@ void OtaUpdateConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSen JSON::objEnd(gen, name); } +void OtaUpdateConfig::Normalize() +{ + if (checkInterval == 0) { + OS_LOGW(TAG, "A zero update check interval is not allowed, using the default"); + checkInterval = kDefaultCheckInterval; + } +} + bool OtaUpdateConfig::LoadNvs(const char* partition) { - auto handle = Nvs::Handle::Open(partition, kNamespace, false); - if (!handle) { - *this = OtaUpdateConfig(); - return true; + std::optional handle; + switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { + case Nvs::Result::Error: + return false; + case Nvs::Result::Missing: + *this = OtaUpdateConfig(); + return true; + case Nvs::Result::Found: + break; } uint8_t channel = std::to_underlying(OtaUpdateChannel::Stable); uint8_t step = std::to_underlying(OtaUpdateStep::None); bool ok = Nvs::Load(*handle, kKeyEnabled, isEnabled, true) && Nvs::Load(*handle, kKeyDomain, cdnDomain, CONFIG_OPENSHOCK_FW_CDN_DOMAIN) && Nvs::Load(*handle, kKeyChannel, channel, channel) && Nvs::Load(*handle, kKeyCheckBoot, checkOnStartup, false) - && Nvs::Load(*handle, kKeyCheckPeriod, checkPeriodically, false) && Nvs::Load(*handle, kKeyCheckMin, checkInterval, static_cast(30)) && Nvs::Load(*handle, kKeyBackendMgmt, allowBackendManagement, true) + && Nvs::Load(*handle, kKeyCheckPeriod, checkPeriodically, false) && Nvs::Load(*handle, kKeyCheckMin, checkInterval, kDefaultCheckInterval) && Nvs::Load(*handle, kKeyBackendMgmt, allowBackendManagement, true) && Nvs::Load(*handle, kKeyManual, requireManualApproval, false) && Nvs::Load(*handle, kKeyUpdateId, updateId, 0) && Nvs::Load(*handle, kKeyStep, step, step); if (!ok) { return false; diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index d1d23f8d9..7abb0a410 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -159,21 +159,39 @@ namespace { } } // namespace +void WiFiConfig::Normalize() +{ + if (!HostnameUtils::IsValid(hostname)) { + OS_LOGW(TAG, "Hostname \"%s\" is not valid, using the default", hostname.c_str()); + hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; + } +} + bool WiFiConfig::LoadNvs(const char* partition) { - if (auto handle = Nvs::Handle::Open(partition, kNamespaceCfg, false)) { - if (!Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { + std::optional handle; + switch (Nvs::Handle::OpenRead(partition, kNamespaceCfg, handle)) { + case Nvs::Result::Error: return false; - } - } else { - accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; - hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; + case Nvs::Result::Missing: + accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; + hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; + break; + case Nvs::Result::Found: + if (!Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { + return false; + } + break; } credentialsList.clear(); - auto handle = Nvs::Handle::Open(partition, kNamespaceCreds, false); - if (!handle) { - return true; // No credential was ever stored + switch (Nvs::Handle::OpenRead(partition, kNamespaceCreds, handle)) { + case Nvs::Result::Error: + return false; + case Nvs::Result::Missing: + return true; // No credential was ever stored + case Nvs::Result::Found: + break; } // Enumerated, never counted: the keys are the ids. Collected first, since reading while iterating is not allowed. diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp index 8b0e1eb5c..7aa7b1dfa 100644 --- a/components/device_control/src/estop/EStopConfig.cpp +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -50,10 +50,15 @@ void EStopConfig::Normalize() bool EStopConfig::LoadNvs(const char* partition) { - auto handle = Nvs::Handle::Open(partition, kNamespace, false); - if (!handle) { - *this = EStopConfig(); - return true; + std::optional handle; + switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { + case Nvs::Result::Error: + return false; + case Nvs::Result::Missing: + *this = EStopConfig(); + return true; + case Nvs::Result::Found: + break; } const int8_t boardPin = static_cast(Board::Get().estopGpio); diff --git a/components/device_control/src/radio/RFConfig.cpp b/components/device_control/src/radio/RFConfig.cpp index 676c5bd1a..887908ae8 100644 --- a/components/device_control/src/radio/RFConfig.cpp +++ b/components/device_control/src/radio/RFConfig.cpp @@ -40,10 +40,15 @@ RFConfig::RFConfig(gpio_num_t txPin, bool keepAliveEnabled) bool RFConfig::LoadNvs(const char* partition) { - auto handle = Nvs::Handle::Open(partition, kNamespace, false); - if (!handle) { - *this = RFConfig(); - return true; + std::optional handle; + switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { + case Nvs::Result::Error: + return false; + case Nvs::Result::Missing: + *this = RFConfig(); + return true; + case Nvs::Result::Found: + break; } const int8_t boardPin = static_cast(Board::Get().rfTxGpio); diff --git a/components/serial_console/src/SerialInputConfig.cpp b/components/serial_console/src/SerialInputConfig.cpp index 7f2ecbf8d..b7fff3d7c 100644 --- a/components/serial_console/src/SerialInputConfig.cpp +++ b/components/serial_console/src/SerialInputConfig.cpp @@ -52,7 +52,18 @@ bool SerialInputConfig::FromJSON(JSON::JsonView json) return false; } - echoEnabled = json["echoEnabled"].tryGetBool().value_or(true); + // A field that is there but is not a bool is a bad document, not a default: accepting it would store the default + // over whatever the hub has. + if (JSON::JsonView field = json["echoEnabled"]; field.valid()) { + auto parsed = field.tryGetBool(); + if (!parsed) { + OS_LOGE(TAG, "echoEnabled is not a boolean"); + return false; + } + echoEnabled = *parsed; + } else { + echoEnabled = true; + } return true; } @@ -66,10 +77,15 @@ void SerialInputConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withS bool SerialInputConfig::LoadNvs(const char* partition) { - auto handle = Nvs::Handle::Open(partition, kNamespace, false); - if (!handle) { - *this = SerialInputConfig(); - return true; + std::optional handle; + switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { + case Nvs::Result::Error: + return false; + case Nvs::Result::Missing: + *this = SerialInputConfig(); + return true; + case Nvs::Result::Found: + break; } return Nvs::Load(*handle, kKeyEcho, echoEnabled, true); } diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 97b0b3c31..0fc212a07 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -43,9 +43,13 @@ The store's own namespace is `root`, with `schema` (u8, 1) marking a filled stor most 15 characters and are permanent once hubs hold them: renaming one is a migration, not an edit. A missing key reads as its default and an unknown key is ignored, so adding or removing a field never changes the schema. -To add a section, define a value type with `ToDefault`, `LoadNvs`, `SaveNvs`, `FromJSON`, `ToJSON` and -`ToFlatbuffers`, and one `Config::Section` for it in the module (`config/Section.h` has the contract). It joins -`jsonconfig`, `rawconfig`, the local protocol's Ready message and factory reset through the section registry. +To add a section, define a value type whose default constructor is the defaults, with `Normalize` (its validation, +run on every value the section takes in), `LoadNvs`, `SaveNvs`, `FromJSON`, `ToJSON` and `ToFlatbuffers`, and one +`Config::Section` for it in the module (the `SectionValue` concept in `config/Section.h` is the contract). It +joins `jsonconfig`, `rawconfig`, the local protocol's Ready message and factory reset through the section registry. +A load must tell a namespace that was never written (defaults) from one that failed to open (a failed load) with +`Nvs::Handle::OpenRead`; a failed load runs the section on its defaults without trusting flash, a defaults load is +taken as what flash holds. Where the store is: From 41beaef1635c8f148a0321bf559922d07c2f4b8a Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 06:36:10 +0100 Subject: [PATCH 13/23] refactor(config): one open-for-load, one saver and one partition init for every section Every LoadNvs opened its namespace with the same ten-line switch on OpenRead, and every SaveNvs spelled `previous != nullptr ? &previous->field : nullptr` once per key, with hand-built temporaries for the members stored under another type. The namespace erase, copy and compare in Nvs.cpp opened raw nvs handles and closed them by hand beside the RAII Handle, and main.cpp repeated the store's erase-and-retry partition init. - Nvs::Handle::OpenForLoad: a namespace never written is an empty handle on which every key reads as Missing, so a load is `if (!handle) return false;` and the per-key defaults; a failed open is nullopt - Nvs::Saver names each member once and skips the ones equal to the previous value; the Set* overloads lose their `previous` parameter - EraseNamespace, CopyNamespace and NamespacesEqual run on Handle; the migration drops the WiFi driver store through EraseNamespace - Nvs::InitPartition is the one erase-and-retry init, used by main.cpp and the store - Section: defaults() in one place, the forwarders inline, one Internal block, StorageLabel instead of a second accessor for the same pointer; SetEStopConfig uses Section::Set like its siblings --- .../src/captiveportal/CaptivePortalConfig.cpp | 14 +- components/config/include/config/Nvs.h | 111 +++++++++++++--- components/config/include/config/Section.h | 49 +++---- components/config/src/Migration.cpp | 11 +- components/config/src/Nvs.cpp | 121 +++++++++--------- components/config/src/Store.cpp | 56 +------- components/core/src/BackendConfig.cpp | 14 +- components/core/src/OtaUpdateConfig.cpp | 34 +++-- components/core/src/wifi/WiFiConfig.cpp | 28 +--- .../device_control/src/estop/EStopConfig.cpp | 21 +-- .../device_control/src/radio/RFConfig.cpp | 16 +-- .../serial_console/src/SerialInputConfig.cpp | 14 +- docs/STORAGE.md | 6 +- main/main.cpp | 15 +-- 14 files changed, 231 insertions(+), 279 deletions(-) diff --git a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp index 465ef377c..87536c3f5 100644 --- a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp +++ b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp @@ -77,15 +77,9 @@ void CaptivePortalConfig::ToJSON(json_gen_str_t* gen, const char* name, bool wit bool CaptivePortalConfig::LoadNvs(const char* partition) { - std::optional handle; - switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { - case Nvs::Result::Error: - return false; - case Nvs::Result::Missing: - *this = CaptivePortalConfig(); - return true; - case Nvs::Result::Found: - break; + auto handle = Nvs::Handle::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; } return Nvs::Load(*handle, kKeyAlways, alwaysEnabled, false); } @@ -96,7 +90,7 @@ bool CaptivePortalConfig::SaveNvs(const char* partition, const CaptivePortalConf if (!handle) { return false; } - return handle->SetBool(kKeyAlways, alwaysEnabled, previous != nullptr ? &previous->alwaysEnabled : nullptr) && handle->Commit(); + return Nvs::Saver(*handle, *this, previous).Set(kKeyAlways, &CaptivePortalConfig::alwaysEnabled).Commit(); } std::optional OpenShock::Config::GetCaptivePortalConfig() diff --git a/components/config/include/config/Nvs.h b/components/config/include/config/Nvs.h index fb2d9cffa..08618da42 100644 --- a/components/config/include/config/Nvs.h +++ b/components/config/include/config/Nvs.h @@ -3,11 +3,12 @@ // Typed access to one NVS namespace: what every module's config section uses for its LoadNvs / SaveNvs. The // partition is the one the store picked (Config::StorageLabel(), handed to LoadNvs/SaveNvs), never hardcoded. // -// Reads are tri-state (Found / Missing / Error): a missing key is a default, an error fails the load. Writes -// take the previous value so a save touches only keys whose value changed: NVS is append-only with -// copy-on-write pages, so every write costs an entry until the page is compacted, and a field that did not -// change costs nothing. NVS guarantees atomicity per key, not across keys: anything that must change together -// belongs in one blob. +// Reads are tri-state (Found / Missing / Error): a missing key is a default, an error fails the load. A load opens +// its namespace with OpenForLoad: a namespace never written is an empty handle on which every key is Missing, so a +// fresh store reads as the defaults, while an open that failed is nullopt and fails the load. Writes go through +// Saver, which skips the keys whose value did not change: NVS is append-only with copy-on-write pages, so every write +// costs an entry until the page is compacted, and a field that did not change costs nothing. NVS guarantees atomicity +// per key, not across keys: anything that must change together belongs in one blob. // // Namespace and key names are at most 15 characters and are effectively permanent once devices hold them. @@ -18,6 +19,7 @@ #include #include #include +#include #include namespace OpenShock::Config::Nvs { @@ -27,17 +29,22 @@ namespace OpenShock::Config::Nvs { Error, }; + /// @brief Brings up `label` as an NVS partition. A partition with no free page is damaged or resized and is erased + /// and reformatted. One written in a newer NVS format is only erased when `eraseNewer`: the shared `nvs` + /// regenerates everything in it, but `cfg` in a newer format is a rollback, and the firmware rolled forward + /// to must still find it. False when the partition cannot be used. + [[nodiscard]] bool InitPartition(const char* label, bool eraseNewer); + class Handle { public: - /// @brief Opens `ns` in `partition`. Read-only opens of a namespace that does not exist yet fail with nullopt - /// and no error logged: that is what a fresh store looks like, and every read then yields Missing. - /// A load must not use this: it cannot tell that case from a failed open (use OpenRead). + /// @brief Opens `ns` in `partition`. A read-only open of a namespace that does not exist yet fails with nullopt + /// and no error logged; a load cannot tell that from a failed open, so a load uses OpenForLoad instead. [[nodiscard]] static std::optional Open(const char* partition, const char* ns, bool write); - /// @brief Opens `ns` in `partition` read-only for a load. Found: `out` holds the handle. Missing: the namespace - /// was never written, `out` is empty and every key is its default. Error: the open failed and the load - /// must fail with it, since defaults would be taken for what is on flash. - [[nodiscard]] static Result OpenRead(const char* partition, const char* ns, std::optional& out); + /// @brief Opens `ns` in `partition` read-only for a load. A namespace that was never written is an empty handle + /// (Exists() false) on which every read is Missing: every key is its default. Nullopt only when the open + /// failed, and the load must fail with it, since defaults would be taken for what is on flash. + [[nodiscard]] static std::optional OpenForLoad(const char* partition, const char* ns); Handle(Handle&& other) noexcept; Handle& operator=(Handle&& other) noexcept; @@ -45,6 +52,9 @@ namespace OpenShock::Config::Nvs { Handle& operator=(const Handle&) = delete; ~Handle(); + /// @brief False for the empty handle OpenForLoad gives a namespace that was never written. + [[nodiscard]] bool Exists() const { return m_handle != 0; } + /// @brief The raw handle, for the untyped key copy in Nvs.cpp; sections never need it. [[nodiscard]] nvs_handle_t get() const { return m_handle; } [[nodiscard]] Result GetU8(const char* key, uint8_t& out) const; @@ -54,14 +64,12 @@ namespace OpenShock::Config::Nvs { [[nodiscard]] Result GetStr(const char* key, std::string& out) const; [[nodiscard]] Result GetBlob(const char* key, std::vector& out) const; - // `previous` is the value on flash as the caller last saw it (null when unknown): the write is skipped when - // it equals `value`. - [[nodiscard]] bool SetU8(const char* key, uint8_t value, const uint8_t* previous = nullptr); - [[nodiscard]] bool SetBool(const char* key, bool value, const bool* previous = nullptr); - [[nodiscard]] bool SetI8(const char* key, int8_t value, const int8_t* previous = nullptr); - [[nodiscard]] bool SetU16(const char* key, uint16_t value, const uint16_t* previous = nullptr); - [[nodiscard]] bool SetI32(const char* key, int32_t value, const int32_t* previous = nullptr); - [[nodiscard]] bool SetStr(const char* key, std::string_view value, const std::string* previous = nullptr); + [[nodiscard]] bool SetU8(const char* key, uint8_t value); + [[nodiscard]] bool SetBool(const char* key, bool value); + [[nodiscard]] bool SetI8(const char* key, int8_t value); + [[nodiscard]] bool SetU16(const char* key, uint16_t value); + [[nodiscard]] bool SetI32(const char* key, int32_t value); + [[nodiscard]] bool SetStr(const char* key, std::string_view value); [[nodiscard]] bool SetBlob(const char* key, std::span value); /// @brief Removes `key`; a key that is not there counts as removed. @@ -73,6 +81,9 @@ namespace OpenShock::Config::Nvs { template bool ForEachKey(nvs_type_t type, Fn&& fn) const { + if (!Exists()) { + return true; + } nvs_iterator_t it = nullptr; esp_err_t err = nvs_entry_find_in_handle(m_handle, type, &it); while (err == ESP_OK) { @@ -102,6 +113,66 @@ namespace OpenShock::Config::Nvs { bool Load(const Handle& handle, const char* key, int32_t& out, int32_t defaultValue); bool Load(const Handle& handle, const char* key, std::string& out, std::string_view defaultValue); + /// @brief Writes a section value's keys, member by member, skipping those equal to `previous` (the value on flash + /// as the caller last saw it; null when unknown, so every key is written). A member stored under another + /// type (an enum as u8, a gpio_num_t as i8) names it: `Set(key, &T::channel)`. One failed write + /// fails the chain; Commit() reports it. + template + class Saver { + public: + Saver(Handle& handle, const T& value, const T* previous) + : m_handle(handle) + , m_value(value) + , m_previous(previous) + , m_ok(true) + { + } + + template + Saver& Set(const char* key, M T::*member) + { + if (!m_ok) { + return *this; + } + if (m_previous != nullptr && m_previous->*member == m_value.*member) { + return *this; + } + if constexpr (std::is_void_v) { + m_ok = (m_handle.*pick())(key, m_value.*member); + } else { + m_ok = (m_handle.*pick())(key, static_cast(m_value.*member)); + } + return *this; + } + + [[nodiscard]] bool Commit() { return m_ok && m_handle.Commit(); } + + private: + template + static constexpr auto pick() + { + if constexpr (std::is_same_v) { + return &Handle::SetBool; + } else if constexpr (std::is_same_v) { + return &Handle::SetU8; + } else if constexpr (std::is_same_v) { + return &Handle::SetI8; + } else if constexpr (std::is_same_v) { + return &Handle::SetU16; + } else if constexpr (std::is_same_v) { + return &Handle::SetI32; + } else { + static_assert(std::is_same_v, "no NVS type for this member; name the stored type: Set(key, &T::member)"); + return &Handle::SetStr; + } + } + + Handle& m_handle; + const T& m_value; + const T* m_previous; + bool m_ok; + }; + /// @brief Erases every key of `ns` in `partition`; a namespace that does not exist counts as erased. [[nodiscard]] bool EraseNamespace(const char* partition, const char* ns); diff --git a/components/config/include/config/Section.h b/components/config/include/config/Section.h index d8e12a959..511589366 100644 --- a/components/config/include/config/Section.h +++ b/components/config/include/config/Section.h @@ -29,6 +29,7 @@ // framework loads all of them at boot, renders the whole config as JSON (jsonconfig), imports one, and resets them // (factory reset). Sections never die; the registry is a constant-initialized intrusive list. +#include "config/Store.h" #include "OpenShock.h" #include "SimpleMutex.h" #include "util/FunctionRef.h" @@ -104,8 +105,9 @@ namespace OpenShock::Config { namespace Internal { /// @brief Serializes every writer of every section, the store's own operations included. SimpleMutex& WriteLock(); - /// @brief The partition the store uses ("cfg"; "nvs" on the 1.x layout), or null when the config only lives in RAM. - const char* Partition(); + bool EraseNamespaces(const char* partition, std::span namespaces); + /// @brief Whether two values render the same JSON, secrets included: the complete, canonical comparison. + bool SameJson(Util::FunctionRef renderA, Util::FunctionRef renderB); } // namespace Internal template @@ -167,8 +169,7 @@ namespace OpenShock::Config { T loaded; bool ok = loadNvs(loaded, partition); if (!ok) { - loaded = T(); - loaded.Normalize(); + loaded = defaults(); } m_unsynced = !ok; publish(std::move(loaded)); @@ -177,14 +178,13 @@ namespace OpenShock::Config { bool Reset(const char* partition) override { - T defaults; - defaults.Normalize(); + T value = defaults(); bool ok = true; if (partition != nullptr) { - ok = eraseNamespaces(partition) && defaults.SaveNvs(partition, nullptr); + ok = Internal::EraseNamespaces(partition, Info().namespaces) && value.SaveNvs(partition, nullptr); } m_unsynced = !ok; - publish(std::move(defaults)); // What the hub runs with either way; a failed save only means it isn't kept + publish(std::move(value)); // What the hub runs with either way; a failed save only means it isn't kept return ok; } @@ -234,6 +234,14 @@ namespace OpenShock::Config { } private: + /// @brief The defaults as the section runs them: default constructed, then normalized like every other value. + static T defaults() + { + T value; + value.Normalize(); + return value; + } + static bool loadNvs(T& value, const char* partition) { if (!value.LoadNvs(partition)) { @@ -246,7 +254,7 @@ namespace OpenShock::Config { // Only the keys that differ from the cache, or every key when flash may not match it bool save(const T& value) { - const char* partition = Internal::Partition(); + const char* partition = StorageLabel(); if (partition == nullptr) { return true; } @@ -260,8 +268,10 @@ namespace OpenShock::Config { m_value = std::move(value); } - bool eraseNamespaces(const char* partition) const; - static bool sameValue(const T& a, const T& b); + static bool sameValue(const T& a, const T& b) + { + return Internal::SameJson([&](json_gen_str_t* gen) { a.ToJSON(gen, nullptr, true); }, [&](json_gen_str_t* gen) { b.ToJSON(gen, nullptr, true); }); + } mutable SimpleMutex m_dataMutex; T m_value; @@ -269,21 +279,4 @@ namespace OpenShock::Config { bool m_unsynced = false; // Flash may differ from m_value, so the next save writes every key. Write lock only. }; - namespace Internal { - bool EraseNamespaces(const char* partition, std::span namespaces); - /// @brief Whether two values render the same JSON, secrets included: the complete, canonical comparison. - bool SameJson(Util::FunctionRef renderA, Util::FunctionRef renderB); - } // namespace Internal - - template - bool Section::eraseNamespaces(const char* partition) const - { - return Internal::EraseNamespaces(partition, Info().namespaces); - } - - template - bool Section::sameValue(const T& a, const T& b) - { - return Internal::SameJson([&](json_gen_str_t* gen) { a.ToJSON(gen, nullptr, true); }, [&](json_gen_str_t* gen) { b.ToJSON(gen, nullptr, true); }); - } } // namespace OpenShock::Config diff --git a/components/config/src/Migration.cpp b/components/config/src/Migration.cpp index 699040fcf..6a3dfd820 100644 --- a/components/config/src/Migration.cpp +++ b/components/config/src/Migration.cpp @@ -4,6 +4,7 @@ const char* const TAG = "ConfigMigration"; +#include "config/Nvs.h" #include "config/Section.h" #include "config/Store.h" #include "Logging.h" @@ -13,7 +14,6 @@ const char* const TAG = "ConfigMigration"; #include #include -#include #include #include @@ -29,14 +29,9 @@ namespace { void dropWifiDriverStore() { - nvs_handle_t handle = 0; - if (nvs_open_from_partition("nvs", kWifiDriverNamespace, NVS_READWRITE, &handle) != ESP_OK) { - return; // Not there: never written, or already dropped + if (Config::Nvs::EraseNamespace("nvs", kWifiDriverNamespace)) { + OS_LOGI(TAG, "Dropped the WiFi driver's stale NVS store (%s), if it had one", kWifiDriverNamespace); } - if (nvs_erase_all(handle) == ESP_OK && nvs_commit(handle) == ESP_OK) { - OS_LOGI(TAG, "Dropped the WiFi driver's stale NVS store (%s)", kWifiDriverNamespace); - } - nvs_close(handle); } } // namespace diff --git a/components/config/src/Nvs.cpp b/components/config/src/Nvs.cpp index 84d7a723f..a3cef16ad 100644 --- a/components/config/src/Nvs.cpp +++ b/components/config/src/Nvs.cpp @@ -4,6 +4,8 @@ const char* const TAG = "Config::Nvs"; #include "Logging.h" +#include + #include #include #include @@ -11,6 +13,28 @@ const char* const TAG = "Config::Nvs"; using namespace OpenShock::Config; +bool Nvs::InitPartition(const char* label, bool eraseNewer) +{ + esp_err_t err = nvs_flash_init_partition(label); + if (err == ESP_ERR_NVS_NEW_VERSION_FOUND && !eraseNewer) { + OS_LOGE(TAG, "NVS partition %s was written by a newer firmware; leaving it untouched", label); + return false; + } + if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + OS_LOGW(TAG, "NVS partition %s unusable (%s), erasing and reformatting", label, esp_err_to_name(err)); + if (nvs_flash_erase_partition(label) != ESP_OK) { + OS_LOGE(TAG, "Failed to erase NVS partition %s", label); + return false; + } + err = nvs_flash_init_partition(label); + } + if (err != ESP_OK) { + OS_LOGE(TAG, "Failed to initialize NVS partition %s: %s", label, esp_err_to_name(err)); + return false; + } + return true; +} + std::optional Nvs::Handle::Open(const char* partition, const char* ns, bool write) { nvs_handle_t handle = 0; @@ -25,20 +49,18 @@ std::optional Nvs::Handle::Open(const char* partition, const char* return std::nullopt; } -Nvs::Result Nvs::Handle::OpenRead(const char* partition, const char* ns, std::optional& out) +std::optional Nvs::Handle::OpenForLoad(const char* partition, const char* ns) { - out.reset(); nvs_handle_t handle = 0; esp_err_t err = nvs_open_from_partition(partition, ns, NVS_READONLY, &handle); if (err == ESP_OK) { - out = Handle(handle); - return Result::Found; + return Handle(handle); } if (err == ESP_ERR_NVS_NOT_FOUND) { - return Result::Missing; + return Handle(0); // The namespace was never written: every key is a default } OS_LOGE(TAG, "Failed to open %s/%s: %s", partition, ns, esp_err_to_name(err)); - return Result::Error; + return std::nullopt; } Nvs::Handle::Handle(Handle&& other) noexcept @@ -89,23 +111,28 @@ namespace { Nvs::Result Nvs::Handle::GetU8(const char* key, uint8_t& out) const { + if (!Exists()) return Result::Missing; return toResult(nvs_get_u8(m_handle, key, &out), key); } Nvs::Result Nvs::Handle::GetI8(const char* key, int8_t& out) const { + if (!Exists()) return Result::Missing; return toResult(nvs_get_i8(m_handle, key, &out), key); } Nvs::Result Nvs::Handle::GetU16(const char* key, uint16_t& out) const { + if (!Exists()) return Result::Missing; return toResult(nvs_get_u16(m_handle, key, &out), key); } Nvs::Result Nvs::Handle::GetI32(const char* key, int32_t& out) const { + if (!Exists()) return Result::Missing; return toResult(nvs_get_i32(m_handle, key, &out), key); } Nvs::Result Nvs::Handle::GetStr(const char* key, std::string& out) const { + if (!Exists()) return Result::Missing; size_t length = 0; Result result = toResult(nvs_get_str(m_handle, key, nullptr, &length), key); if (result != Result::Found) { @@ -123,6 +150,7 @@ Nvs::Result Nvs::Handle::GetStr(const char* key, std::string& out) const Nvs::Result Nvs::Handle::GetBlob(const char* key, std::vector& out) const { + if (!Exists()) return Result::Missing; size_t length = 0; Result result = toResult(nvs_get_blob(m_handle, key, nullptr, &length), key); if (result != Result::Found) { @@ -139,34 +167,28 @@ Nvs::Result Nvs::Handle::GetBlob(const char* key, std::vector& out) con return Result::Found; } -bool Nvs::Handle::SetU8(const char* key, uint8_t value, const uint8_t* previous) +bool Nvs::Handle::SetU8(const char* key, uint8_t value) { - if (previous != nullptr && *previous == value) return true; return checkWrite(nvs_set_u8(m_handle, key, value), key); } -bool Nvs::Handle::SetBool(const char* key, bool value, const bool* previous) +bool Nvs::Handle::SetBool(const char* key, bool value) { - if (previous != nullptr && *previous == value) return true; return checkWrite(nvs_set_u8(m_handle, key, value ? 1 : 0), key); } -bool Nvs::Handle::SetI8(const char* key, int8_t value, const int8_t* previous) +bool Nvs::Handle::SetI8(const char* key, int8_t value) { - if (previous != nullptr && *previous == value) return true; return checkWrite(nvs_set_i8(m_handle, key, value), key); } -bool Nvs::Handle::SetU16(const char* key, uint16_t value, const uint16_t* previous) +bool Nvs::Handle::SetU16(const char* key, uint16_t value) { - if (previous != nullptr && *previous == value) return true; return checkWrite(nvs_set_u16(m_handle, key, value), key); } -bool Nvs::Handle::SetI32(const char* key, int32_t value, const int32_t* previous) +bool Nvs::Handle::SetI32(const char* key, int32_t value) { - if (previous != nullptr && *previous == value) return true; return checkWrite(nvs_set_i32(m_handle, key, value), key); } -bool Nvs::Handle::SetStr(const char* key, std::string_view value, const std::string* previous) +bool Nvs::Handle::SetStr(const char* key, std::string_view value) { - if (previous != nullptr && *previous == value) return true; // nvs_set_str wants a C string: a value with an embedded NUL would be silently cut, so refuse it if (value.find('\0') != std::string_view::npos) { OS_LOGE(TAG, "Refusing to store %s: embedded NUL", key); @@ -265,25 +287,17 @@ bool Nvs::Load(const Handle& handle, const char* key, std::string& out, std::str bool Nvs::EraseNamespace(const char* partition, const char* ns) { - nvs_handle_t handle = 0; - esp_err_t err = nvs_open_from_partition(partition, ns, NVS_READWRITE, &handle); - if (err == ESP_ERR_NVS_NOT_FOUND) { - return true; - } - if (err != ESP_OK) { - OS_LOGE(TAG, "Failed to open %s/%s for erase: %s", partition, ns, esp_err_to_name(err)); + // Looked up read-only first: a read-write open creates the namespace, which one that is not there must not do + auto existing = Handle::OpenForLoad(partition, ns); + if (!existing) { return false; } - err = nvs_erase_all(handle); - if (err == ESP_OK) { - err = nvs_commit(handle); - } - nvs_close(handle); - if (err != ESP_OK) { - OS_LOGE(TAG, "Failed to erase %s/%s: %s", partition, ns, esp_err_to_name(err)); - return false; + if (!existing->Exists()) { + return true; } - return true; + existing.reset(); + auto handle = Handle::Open(partition, ns, true); + return handle && handle->EraseAll() && handle->Commit(); } namespace { @@ -418,26 +432,18 @@ bool Nvs::CopyNamespace(const char* from, const char* to, const char* ns) return true; } - nvs_handle_t src = 0, dst = 0; - if (nvs_open_from_partition(from, ns, NVS_READONLY, &src) != ESP_OK) { - return false; - } - if (nvs_open_from_partition(to, ns, NVS_READWRITE, &dst) != ESP_OK) { - nvs_close(src); + auto src = Handle::Open(from, ns, false); + auto dst = Handle::Open(to, ns, true); + if (!src || !dst) { return false; } - bool ok = true; for (const auto& [key, type] : keys) { RawEntry entry; - if (!readRaw(src, key.c_str(), type, entry) || !writeRaw(dst, key.c_str(), entry)) { - ok = false; - break; + if (!readRaw(src->get(), key.c_str(), type, entry) || !writeRaw(dst->get(), key.c_str(), entry)) { + return false; } } - ok = ok && checkWrite(nvs_commit(dst), "commit"); - nvs_close(dst); - nvs_close(src); - return ok; + return dst->Commit(); } bool Nvs::NamespacesEqual(const char* a, const char* b, const char* ns) @@ -455,23 +461,16 @@ bool Nvs::NamespacesEqual(const char* a, const char* b, const char* ns) return true; } - nvs_handle_t ha = 0, hb = 0; - if (nvs_open_from_partition(a, ns, NVS_READONLY, &ha) != ESP_OK) { + auto ha = Handle::Open(a, ns, false); + auto hb = Handle::Open(b, ns, false); + if (!ha || !hb) { return false; } - if (nvs_open_from_partition(b, ns, NVS_READONLY, &hb) != ESP_OK) { - nvs_close(ha); - return false; - } - bool equal = true; for (const auto& [key, type] : keysA) { RawEntry ea, eb; - if (!readRaw(ha, key.c_str(), type, ea) || !readRaw(hb, key.c_str(), type, eb) || ea.data != eb.data) { - equal = false; - break; + if (!readRaw(ha->get(), key.c_str(), type, ea) || !readRaw(hb->get(), key.c_str(), type, eb) || ea.data != eb.data) { + return false; } } - nvs_close(hb); - nvs_close(ha); - return equal; + return true; } diff --git a/components/config/src/Store.cpp b/components/config/src/Store.cpp index fffe1d934..0d4cd74b2 100644 --- a/components/config/src/Store.cpp +++ b/components/config/src/Store.cpp @@ -14,7 +14,6 @@ const char* const TAG = "Config"; #include "json/Json.h" #include -#include #include #include @@ -37,11 +36,6 @@ static constexpr const char* kLegacyFilePath = "/config"; // Written by Init() alone, under the write lock, before anything else reads a section. static const char* s_partition = nullptr; -const char* Config::Internal::Partition() -{ - return s_partition; -} - const char* Config::StorageLabel() { return s_partition; @@ -53,23 +47,10 @@ const char* Config::StorageLabel() // A store marked by a newer firmware (a rollback) counts as filled: it is loaded best-effort, never refilled. static std::optional hasSchema(const char* partition) { - std::optional handle; - switch (Config::Nvs::Handle::OpenRead(partition, kRootNamespace, handle)) { - case Config::Nvs::Result::Error: - return std::nullopt; - case Config::Nvs::Result::Missing: - return false; - case Config::Nvs::Result::Found: - break; - } + auto handle = Config::Nvs::Handle::OpenForLoad(partition, kRootNamespace); uint8_t schema = 0; - switch (handle->GetU8(kSchemaKey, schema)) { - case Config::Nvs::Result::Error: - return std::nullopt; - case Config::Nvs::Result::Missing: - return false; - case Config::Nvs::Result::Found: - break; + if (!handle || !Config::Nvs::Load(*handle, kSchemaKey, schema, 0)) { + return std::nullopt; } if (schema < kSchemaVersion) { return false; @@ -98,31 +79,6 @@ static std::vector allNamespaces() return namespaces; } -// Brings up `label` as an NVS partition. A partition with no free page is damaged or resized and is erased and -// reformatted. One written in a newer NVS format is only erased when `eraseNewer`: the shared `nvs` regenerates -// everything in it, but `cfg` in a newer format is a rollback, and the firmware rolled forward to must still find it. -static bool initNvsPartition(const char* label, bool eraseNewer) -{ - esp_err_t err = nvs_flash_init_partition(label); - if (err == ESP_ERR_NVS_NEW_VERSION_FOUND && !eraseNewer) { - OS_LOGE(TAG, "NVS partition %s was written by a newer firmware; leaving it untouched", label); - return false; - } - if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { - OS_LOGW(TAG, "NVS partition %s unusable (%s), erasing and reformatting", label, esp_err_to_name(err)); - if (nvs_flash_erase_partition(label) != ESP_OK) { - OS_LOGE(TAG, "Failed to erase NVS partition %s", label); - return false; - } - err = nvs_flash_init_partition(label); - } - if (err != ESP_OK) { - OS_LOGE(TAG, "Failed to initialize NVS partition %s: %s", label, esp_err_to_name(err)); - return false; - } - return true; -} - static bool nvsPartitionExists(const char* label) { return esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_NVS, label) != nullptr; @@ -157,14 +113,14 @@ static const char* openStorage() { if (nvsPartitionExists(kPartitionCfg)) { clearLegacyLittlefs(kPartitionCfg); - if (initNvsPartition(kPartitionCfg, false)) { + if (Config::Nvs::InitPartition(kPartitionCfg, false)) { return kPartitionCfg; } // Never the shared nvs instead: what is saved there is discarded once cfg opens again OS_LOGE(TAG, "The cfg partition is unusable; running on defaults in RAM, nothing will be saved"); return nullptr; } - if (nvsPartitionExists(kPartitionShared) && initNvsPartition(kPartitionShared, true)) { + if (nvsPartitionExists(kPartitionShared) && Config::Nvs::InitPartition(kPartitionShared, true)) { OS_LOGW(TAG, "No cfg partition (1.x partition layout): the config is kept in the shared nvs partition until the layout is migrated"); return kPartitionShared; } @@ -179,7 +135,7 @@ static const char* openStorage() // the copy failed. static const char* moveSharedIntoCfg() { - if (!nvsPartitionExists(kPartitionShared) || !initNvsPartition(kPartitionShared, true)) { + if (!nvsPartitionExists(kPartitionShared) || !Config::Nvs::InitPartition(kPartitionShared, true)) { return kPartitionCfg; } // Nothing is moved or erased on a read that failed: a shared copy that cannot be read is left for the next boot, and diff --git a/components/core/src/BackendConfig.cpp b/components/core/src/BackendConfig.cpp index 13378cadc..833173065 100644 --- a/components/core/src/BackendConfig.cpp +++ b/components/core/src/BackendConfig.cpp @@ -84,15 +84,9 @@ void BackendConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withSensi bool BackendConfig::LoadNvs(const char* partition) { - std::optional handle; - switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { - case Nvs::Result::Error: - return false; - case Nvs::Result::Missing: - *this = BackendConfig(); - return true; - case Nvs::Result::Found: - break; + auto handle = Nvs::Handle::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; } return Nvs::Load(*handle, kKeyDomain, domain, CONFIG_OPENSHOCK_API_DOMAIN) && Nvs::Load(*handle, kKeyToken, authToken, ""); } @@ -103,7 +97,7 @@ bool BackendConfig::SaveNvs(const char* partition, const BackendConfig* previous if (!handle) { return false; } - return handle->SetStr(kKeyDomain, domain, previous != nullptr ? &previous->domain : nullptr) && handle->SetStr(kKeyToken, authToken, previous != nullptr ? &previous->authToken : nullptr) && handle->Commit(); + return Nvs::Saver(*handle, *this, previous).Set(kKeyDomain, &BackendConfig::domain).Set(kKeyToken, &BackendConfig::authToken).Commit(); } std::optional OpenShock::Config::GetBackendConfig() diff --git a/components/core/src/OtaUpdateConfig.cpp b/components/core/src/OtaUpdateConfig.cpp index e52f4127f..bcd19b322 100644 --- a/components/core/src/OtaUpdateConfig.cpp +++ b/components/core/src/OtaUpdateConfig.cpp @@ -150,15 +150,9 @@ void OtaUpdateConfig::Normalize() bool OtaUpdateConfig::LoadNvs(const char* partition) { - std::optional handle; - switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { - case Nvs::Result::Error: - return false; - case Nvs::Result::Missing: - *this = OtaUpdateConfig(); - return true; - case Nvs::Result::Found: - break; + auto handle = Nvs::Handle::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; } uint8_t channel = std::to_underlying(OtaUpdateChannel::Stable); @@ -181,16 +175,18 @@ bool OtaUpdateConfig::SaveNvs(const char* partition, const OtaUpdateConfig* prev if (!handle) { return false; } - const uint8_t channel = std::to_underlying(updateChannel); - const uint8_t step = std::to_underlying(updateStep); - const uint8_t prevChannel = previous != nullptr ? std::to_underlying(previous->updateChannel) : 0; - const uint8_t prevStep = previous != nullptr ? std::to_underlying(previous->updateStep) : 0; - const bool p = previous != nullptr; - return handle->SetBool(kKeyEnabled, isEnabled, p ? &previous->isEnabled : nullptr) && handle->SetStr(kKeyDomain, cdnDomain, p ? &previous->cdnDomain : nullptr) && handle->SetU8(kKeyChannel, channel, p ? &prevChannel : nullptr) - && handle->SetBool(kKeyCheckBoot, checkOnStartup, p ? &previous->checkOnStartup : nullptr) && handle->SetBool(kKeyCheckPeriod, checkPeriodically, p ? &previous->checkPeriodically : nullptr) - && handle->SetU16(kKeyCheckMin, checkInterval, p ? &previous->checkInterval : nullptr) && handle->SetBool(kKeyBackendMgmt, allowBackendManagement, p ? &previous->allowBackendManagement : nullptr) - && handle->SetBool(kKeyManual, requireManualApproval, p ? &previous->requireManualApproval : nullptr) && handle->SetI32(kKeyUpdateId, updateId, p ? &previous->updateId : nullptr) && handle->SetU8(kKeyStep, step, p ? &prevStep : nullptr) - && handle->Commit(); + return Nvs::Saver(*handle, *this, previous) + .Set(kKeyEnabled, &OtaUpdateConfig::isEnabled) + .Set(kKeyDomain, &OtaUpdateConfig::cdnDomain) + .Set(kKeyChannel, &OtaUpdateConfig::updateChannel) + .Set(kKeyCheckBoot, &OtaUpdateConfig::checkOnStartup) + .Set(kKeyCheckPeriod, &OtaUpdateConfig::checkPeriodically) + .Set(kKeyCheckMin, &OtaUpdateConfig::checkInterval) + .Set(kKeyBackendMgmt, &OtaUpdateConfig::allowBackendManagement) + .Set(kKeyManual, &OtaUpdateConfig::requireManualApproval) + .Set(kKeyUpdateId, &OtaUpdateConfig::updateId) + .Set(kKeyStep, &OtaUpdateConfig::updateStep) + .Commit(); } std::optional OpenShock::Config::GetOtaUpdateConfig() diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index 7abb0a410..0a69716f8 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -169,29 +169,15 @@ void WiFiConfig::Normalize() bool WiFiConfig::LoadNvs(const char* partition) { - std::optional handle; - switch (Nvs::Handle::OpenRead(partition, kNamespaceCfg, handle)) { - case Nvs::Result::Error: - return false; - case Nvs::Result::Missing: - accessPointSSID = CONFIG_OPENSHOCK_FW_AP_PREFIX; - hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; - break; - case Nvs::Result::Found: - if (!Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { - return false; - } - break; + auto handle = Nvs::Handle::OpenForLoad(partition, kNamespaceCfg); + if (!handle || !Nvs::Load(*handle, kKeyApSsid, accessPointSSID, CONFIG_OPENSHOCK_FW_AP_PREFIX) || !Nvs::Load(*handle, kKeyHostname, hostname, CONFIG_OPENSHOCK_FW_HOSTNAME)) { + return false; } credentialsList.clear(); - switch (Nvs::Handle::OpenRead(partition, kNamespaceCreds, handle)) { - case Nvs::Result::Error: - return false; - case Nvs::Result::Missing: - return true; // No credential was ever stored - case Nvs::Result::Found: - break; + handle = Nvs::Handle::OpenForLoad(partition, kNamespaceCreds); + if (!handle) { + return false; } // Enumerated, never counted: the keys are the ids. Collected first, since reading while iterating is not allowed. @@ -227,7 +213,7 @@ bool WiFiConfig::SaveNvs(const char* partition, const WiFiConfig* previous) cons if (!handle) { return false; } - if (!handle->SetStr(kKeyApSsid, accessPointSSID, previous != nullptr ? &previous->accessPointSSID : nullptr) || !handle->SetStr(kKeyHostname, hostname, previous != nullptr ? &previous->hostname : nullptr) || !handle->Commit()) { + if (!Nvs::Saver(*handle, *this, previous).Set(kKeyApSsid, &WiFiConfig::accessPointSSID).Set(kKeyHostname, &WiFiConfig::hostname).Commit()) { return false; } } diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp index 7aa7b1dfa..aaee50b9d 100644 --- a/components/device_control/src/estop/EStopConfig.cpp +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -50,15 +50,9 @@ void EStopConfig::Normalize() bool EStopConfig::LoadNvs(const char* partition) { - std::optional handle; - switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { - case Nvs::Result::Error: - return false; - case Nvs::Result::Missing: - *this = EStopConfig(); - return true; - case Nvs::Result::Found: - break; + auto handle = Nvs::Handle::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; } const int8_t boardPin = static_cast(Board::Get().estopGpio); @@ -83,9 +77,7 @@ bool EStopConfig::SaveNvs(const char* partition, const EStopConfig* previous) co if (!handle) { return false; } - const int8_t pin = static_cast(gpioPin); - const int8_t prevPin = previous != nullptr ? static_cast(previous->gpioPin) : 0; - return handle->SetBool(kKeyEnabled, enabled, previous != nullptr ? &previous->enabled : nullptr) && handle->SetI8(kKeyGpio, pin, previous != nullptr ? &prevPin : nullptr) && handle->Commit(); + return Nvs::Saver(*handle, *this, previous).Set(kKeyEnabled, &EStopConfig::enabled).Set(kKeyGpio, &EStopConfig::gpioPin).Commit(); } bool EStopConfig::FromJSON(JSON::JsonView json) @@ -138,10 +130,7 @@ std::optional OpenShock::Config::GetEStopConfig() bool OpenShock::Config::SetEStopConfig(const EStopConfig& config) { - return s_section.Update([&](EStopConfig& estop) { - estop = config; - return true; - }); + return s_section.Set(config); } std::optional OpenShock::Config::GetEStopEnabled() diff --git a/components/device_control/src/radio/RFConfig.cpp b/components/device_control/src/radio/RFConfig.cpp index 887908ae8..07363cf61 100644 --- a/components/device_control/src/radio/RFConfig.cpp +++ b/components/device_control/src/radio/RFConfig.cpp @@ -40,15 +40,9 @@ RFConfig::RFConfig(gpio_num_t txPin, bool keepAliveEnabled) bool RFConfig::LoadNvs(const char* partition) { - std::optional handle; - switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { - case Nvs::Result::Error: - return false; - case Nvs::Result::Missing: - *this = RFConfig(); - return true; - case Nvs::Result::Found: - break; + auto handle = Nvs::Handle::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; } const int8_t boardPin = static_cast(Board::Get().rfTxGpio); @@ -71,9 +65,7 @@ bool RFConfig::SaveNvs(const char* partition, const RFConfig* previous) const if (!handle) { return false; } - const int8_t pin = static_cast(txPin); - const int8_t prevPin = previous != nullptr ? static_cast(previous->txPin) : 0; - return handle->SetI8(kKeyTxPin, pin, previous != nullptr ? &prevPin : nullptr) && handle->SetBool(kKeyKeepAlive, keepAliveEnabled, previous != nullptr ? &previous->keepAliveEnabled : nullptr) && handle->Commit(); + return Nvs::Saver(*handle, *this, previous).Set(kKeyTxPin, &RFConfig::txPin).Set(kKeyKeepAlive, &RFConfig::keepAliveEnabled).Commit(); } bool RFConfig::FromJSON(JSON::JsonView json) diff --git a/components/serial_console/src/SerialInputConfig.cpp b/components/serial_console/src/SerialInputConfig.cpp index b7fff3d7c..9a6c306f4 100644 --- a/components/serial_console/src/SerialInputConfig.cpp +++ b/components/serial_console/src/SerialInputConfig.cpp @@ -77,15 +77,9 @@ void SerialInputConfig::ToJSON(json_gen_str_t* gen, const char* name, bool withS bool SerialInputConfig::LoadNvs(const char* partition) { - std::optional handle; - switch (Nvs::Handle::OpenRead(partition, kNamespace, handle)) { - case Nvs::Result::Error: - return false; - case Nvs::Result::Missing: - *this = SerialInputConfig(); - return true; - case Nvs::Result::Found: - break; + auto handle = Nvs::Handle::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; } return Nvs::Load(*handle, kKeyEcho, echoEnabled, true); } @@ -96,7 +90,7 @@ bool SerialInputConfig::SaveNvs(const char* partition, const SerialInputConfig* if (!handle) { return false; } - return handle->SetBool(kKeyEcho, echoEnabled, previous != nullptr ? &previous->echoEnabled : nullptr) && handle->Commit(); + return Nvs::Saver(*handle, *this, previous).Set(kKeyEcho, &SerialInputConfig::echoEnabled).Commit(); } std::optional OpenShock::Config::GetSerialInputConfig() diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 0fc212a07..db2e39148 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -47,9 +47,9 @@ To add a section, define a value type whose default constructor is the defaults, run on every value the section takes in), `LoadNvs`, `SaveNvs`, `FromJSON`, `ToJSON` and `ToFlatbuffers`, and one `Config::Section` for it in the module (the `SectionValue` concept in `config/Section.h` is the contract). It joins `jsonconfig`, `rawconfig`, the local protocol's Ready message and factory reset through the section registry. -A load must tell a namespace that was never written (defaults) from one that failed to open (a failed load) with -`Nvs::Handle::OpenRead`; a failed load runs the section on its defaults without trusting flash, a defaults load is -taken as what flash holds. +A load opens its namespace with `Nvs::Handle::OpenForLoad`: a namespace never written reads every key as its +default, an open that failed fails the load, and a failed load runs the section on its defaults without trusting +flash. A save goes through `Nvs::Saver`, which writes only the members that differ from the previous value. Where the store is: diff --git a/main/main.cpp b/main/main.cpp index efadfc685..e84853a26 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -6,6 +6,7 @@ const char* const TAG = "main"; #include "captiveportal/Manager.h" #include "Chipset.h" #include "CommandHandler.h" +#include "config/Nvs.h" #include "config/Store.h" #include "estop/EStopManager.h" #include "events/Events.h" @@ -110,17 +111,9 @@ extern "C" void app_main() // WiFi persists calibration/config in the default NVS partition, so NVS must be // initialized before esp_wifi_init() (or any other NVS user) runs. Arduino used // to do this implicitly during startup; under the IDF entry point we do it - // explicitly. If the partition is from an incompatible/older layout, reformat it. - esp_err_t nvsResult = nvs_flash_init(); - if (nvsResult == ESP_ERR_NVS_NO_FREE_PAGES || nvsResult == ESP_ERR_NVS_NEW_VERSION_FOUND) { - OS_LOGW(TAG, "NVS partition unusable (%s), erasing and reformatting", esp_err_to_name(nvsResult)); - if (nvs_flash_erase() != ESP_OK) { - OS_PANIC(TAG, "Failed to erase NVS partition"); - } - nvsResult = nvs_flash_init(); - } - if (nvsResult != ESP_OK) { - OS_PANIC(TAG, "Failed to initialize NVS: %s", esp_err_to_name(nvsResult)); + // explicitly. A damaged or newer-format partition is reformatted: everything in it regenerates. + if (!OpenShock::Config::Nvs::InitPartition(NVS_DEFAULT_PART_NAME, true)) { + OS_PANIC(TAG, "Failed to initialize NVS"); } // Who we are. The descriptor is stamped into the image per board (BoardDescriptor.h); everything that needs a From 0e286cc1cd7eff2670ac6cbd63e200dcbf4b9fca Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 06:43:25 +0100 Subject: [PATCH 14/23] refactor(config): one JSON prologue and one strict boolean parser for every section Every FromJSON opened with the same null-or-not-an-object check, and two of the eight had drifted (no warning on a null section). The "present but not a boolean refuses the document" rule was hand-written three times while RF and OTA still coerced a wrongly typed boolean to its default, storing that over the running value. - Parse::ObjectOrDefault is the prologue; Parse::JsonBool the boolean field, used for every boolean of every section, so the rule is the same everywhere - WiFiCredentials resets with its default constructor like the sections (ToDefault is gone), compares with a defaulted operator==, and drops an include it never used - Both BSSID writers render through HexUtils::ToHex instead of a nibble loop --- .../src/captiveportal/CaptivePortalConfig.cpp | 22 ++------------ components/config/include/config/Parse.h | 21 ++++++++++++++ components/config/src/Parse.cpp | 29 +++++++++++++++++++ components/config/src/Wire.cpp | 12 ++------ .../core/include/wifi/WiFiCredentials.h | 4 +-- components/core/src/BackendConfig.cpp | 11 ++----- components/core/src/OtaUpdateConfig.cpp | 27 +++++++---------- components/core/src/wifi/WiFiConfig.cpp | 18 ++---------- components/core/src/wifi/WiFiCredentials.cpp | 28 +++--------------- .../device_control/src/estop/EStopConfig.cpp | 21 +++----------- .../device_control/src/radio/RFConfig.cpp | 14 ++++----- .../serial_console/src/SerialInputConfig.cpp | 22 ++------------ 12 files changed, 88 insertions(+), 141 deletions(-) diff --git a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp index 87536c3f5..f22c3e033 100644 --- a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp +++ b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp @@ -41,30 +41,14 @@ flatbuffers::Offset namespace OpenShock::Config::Parse { + namespace Internal { + std::optional CheckObject(JSON::JsonView json, const char* tag); + } + + /// @brief The start of every section's FromJSON. A null view means the section was given no value: `self` becomes + /// the defaults and the result is true. Anything but an object is refused: false. An object is nullopt, and + /// the fields are parsed. `if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) return *done;` + template + [[nodiscard]] std::optional ObjectOrDefault(T& self, JSON::JsonView json, const char* tag) + { + std::optional done = Internal::CheckObject(json, tag); + if (done == true) { + self = T(); + } + return done; + } + + /// @brief A boolean field. Absent: `out` is `defaultVal` and the result is true. Present but not a boolean: false, + /// the document is refused, since taking the default for it would store that over what the hub has. + bool JsonBool(bool& out, JSON::JsonView json, std::string_view name, bool defaultVal, const char* tag); + // Accepts GPIO_NUM_NC (-1, "no pin") or any GPIO number valid on this chip; used for every stored/imported pin. [[nodiscard]] std::optional GpioNum(int32_t intVal); diff --git a/components/config/src/Parse.cpp b/components/config/src/Parse.cpp index f86cc8f00..6e2af1a24 100644 --- a/components/config/src/Parse.cpp +++ b/components/config/src/Parse.cpp @@ -9,6 +9,35 @@ const char* const TAG = "Config::Parse"; using namespace OpenShock; +std::optional Config::Parse::Internal::CheckObject(JSON::JsonView json, const char* tag) +{ + if (!json.valid()) { + OS_LOGW(tag, "Config is null, setting to default"); + return true; + } + if (!json.isObject()) { + OS_LOGE(tag, "json is not an object"); + return false; + } + return std::nullopt; +} + +bool Config::Parse::JsonBool(bool& out, JSON::JsonView json, std::string_view name, bool defaultVal, const char* tag) +{ + JSON::JsonView field = json[name]; + if (!field.valid()) { + out = defaultVal; + return true; + } + auto parsed = field.tryGetBool(); + if (!parsed) { + OS_LOGE(tag, "%.*s is not a boolean", static_cast(name.size()), name.data()); + return false; + } + out = *parsed; + return true; +} + std::optional Config::Parse::GpioNum(int32_t intVal) { if (intVal == GPIO_NUM_NC) { diff --git a/components/config/src/Wire.cpp b/components/config/src/Wire.cpp index 4d551f5f7..4225e3d36 100644 --- a/components/config/src/Wire.cpp +++ b/components/config/src/Wire.cpp @@ -9,11 +9,13 @@ const char* const TAG = "Config::Wire"; #include "config/Store.h" #include "Logging.h" #include "serialization/VerifiedRoot.h" +#include "util/HexUtils.h" #include "json/Json.h" #include #include +#include #include #include @@ -154,15 +156,7 @@ namespace { setString(gen, "password", creds->password()); json_gen_obj_set_int(gen, "authMode", std::to_underlying(creds->auth_mode())); if (const auto* bssid = creds->bssid()) { - static constexpr char kHex[] = "0123456789ABCDEF"; - char hex[13]; - for (int i = 0; i < 6; ++i) { - const uint8_t b = bssid->bytes()->Get(i); - hex[i * 2] = kHex[b >> 4]; - hex[i * 2 + 1] = kHex[b & 0x0F]; - } - hex[12] = '\0'; - JSON::objSetString(gen, "bssid", hex); + JSON::objSetString(gen, "bssid", HexUtils::ToHex(std::span(bssid->bytes()->data(), 6)).data()); } json_gen_end_object(gen); } diff --git a/components/core/include/wifi/WiFiCredentials.h b/components/core/include/wifi/WiFiCredentials.h index 5da4e7cb9..c3db60dbf 100644 --- a/components/core/include/wifi/WiFiCredentials.h +++ b/components/core/include/wifi/WiFiCredentials.h @@ -5,7 +5,6 @@ #include #include -#include #include #include @@ -31,14 +30,13 @@ namespace OpenShock::Config { std::array bssid; // Pinned BSSID, all zeros = no pin bool HasPinnedBSSID() const; + bool operator==(const WiFiCredentials&) const = default; /// @brief The credential as the blob stored under its id in the `wifi` NVS namespace (wifi/WiFiConfig.h). [[nodiscard]] std::vector ToBlob() const; /// @brief Decodes a blob written by ToBlob (any version it ever wrote). False on a malformed blob. bool FromBlob(std::span blob); - void ToDefault(); - [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; bool FromJSON(JSON::JsonView json); diff --git a/components/core/src/BackendConfig.cpp b/components/core/src/BackendConfig.cpp index 833173065..58cfbc895 100644 --- a/components/core/src/BackendConfig.cpp +++ b/components/core/src/BackendConfig.cpp @@ -53,15 +53,8 @@ flatbuffers::Offset Back bool BackendConfig::FromJSON(JSON::JsonView json) { - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - *this = BackendConfig(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; } domain = json["domain"].tryGetStr().value_or(CONFIG_OPENSHOCK_API_DOMAIN); diff --git a/components/core/src/OtaUpdateConfig.cpp b/components/core/src/OtaUpdateConfig.cpp index bcd19b322..7891c1252 100644 --- a/components/core/src/OtaUpdateConfig.cpp +++ b/components/core/src/OtaUpdateConfig.cpp @@ -95,30 +95,23 @@ flatbuffers::Offset Ot bool OtaUpdateConfig::FromJSON(JSON::JsonView json) { - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - *this = OtaUpdateConfig(); - return true; + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; } - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); + // Every boolean is strict (Parse::JsonBool); the numbers and strings take their default for a value of the wrong type + if ( + !Parse::JsonBool(isEnabled, json, "isEnabled", true, TAG) || !Parse::JsonBool(checkOnStartup, json, "checkOnStartup", false, TAG) || !Parse::JsonBool(checkPeriodically, json, "checkPeriodically", false, TAG) + || !Parse::JsonBool(allowBackendManagement, json, "allowBackendManagement", true, TAG) || !Parse::JsonBool(requireManualApproval, json, "requireManualApproval", false, TAG) + ) + { return false; } - isEnabled = json["isEnabled"].tryGetBool().value_or(true); - cdnDomain = json["cdnDomain"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_CDN_DOMAIN); - Parse::JsonStrParsed(updateChannel, json, "updateChannel"sv, OpenShock::TryParseOtaUpdateChannel, OpenShock::OtaUpdateChannel::Stable); - - checkOnStartup = json["checkOnStartup"].tryGetBool().value_or(false); - checkPeriodically = json["checkPeriodically"].tryGetBool().value_or(false); - checkInterval = json["checkInterval"].tryGetU16().value_or(kDefaultCheckInterval); - allowBackendManagement = json["allowBackendManagement"].tryGetBool().value_or(true); - requireManualApproval = json["requireManualApproval"].tryGetBool().value_or(false); - updateId = json["updateId"].tryGetI32().value_or(0); - + checkInterval = json["checkInterval"].tryGetU16().value_or(kDefaultCheckInterval); + updateId = json["updateId"].tryGetI32().value_or(0); Parse::JsonStrParsed(updateStep, json, "updateStep"sv, OpenShock::TryParseOtaUpdateStep, OpenShock::OtaUpdateStep::None); return true; diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index 0a69716f8..e147e6f57 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -63,15 +63,8 @@ flatbuffers::Offset WiFiCon bool WiFiConfig::FromJSON(JSON::JsonView json) { - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - *this = WiFiConfig(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; } accessPointSSID = json["accessPointSSID"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_AP_PREFIX); @@ -152,11 +145,6 @@ namespace { } return nullptr; } - - bool sameCredentials(const WiFiCredentials& a, const WiFiCredentials& b) - { - return a.id == b.id && a.ssid == b.ssid && a.password == b.password && a.authMode == b.authMode && a.bssid == b.bssid; - } } // namespace void WiFiConfig::Normalize() @@ -251,7 +239,7 @@ bool WiFiConfig::SaveNvs(const char* partition, const WiFiConfig* previous) cons for (const auto& creds : credentialsList) { const WiFiCredentials* old = previous != nullptr ? findById(previous->credentialsList, creds.id) : nullptr; - if (old != nullptr && sameCredentials(*old, creds)) { + if (old != nullptr && *old == creds) { continue; } credentialKey(creds.id, key); diff --git a/components/core/src/wifi/WiFiCredentials.cpp b/components/core/src/wifi/WiFiCredentials.cpp index 6b7218a3d..cc57c024a 100644 --- a/components/core/src/wifi/WiFiCredentials.cpp +++ b/components/core/src/wifi/WiFiCredentials.cpp @@ -2,6 +2,7 @@ const char* const TAG = "Config::WiFiCredentials"; +#include "config/Parse.h" #include "Logging.h" #include "wifi/WiFiAuthMode.h" @@ -41,15 +42,6 @@ bool WiFiCredentials::HasPinnedBSSID() const return false; } -void WiFiCredentials::ToDefault() -{ - id = 0; - ssid.clear(); - password.clear(); - authMode = WIFI_AUTH_MAX; - bssid.fill(0); -} - flatbuffers::Offset WiFiCredentials::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const { auto ssidOffset = builder.CreateString(ssid); @@ -73,15 +65,8 @@ flatbuffers::Offset Wi bool WiFiCredentials::FromJSON(JSON::JsonView json) { - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - ToDefault(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; } id = json["id"].tryGetU8().value_or(0); @@ -121,12 +106,7 @@ void WiFiCredentials::ToJSON(json_gen_str_t* gen, const char* name, bool withSen } json_gen_obj_set_int(gen, "authMode", std::to_underlying(ToFbsAuthMode(authMode))); if (HasPinnedBSSID()) { - char hex[13]; - for (std::size_t i = 0; i < bssid.size(); ++i) { - HexUtils::ToHex(bssid[i], &hex[i * 2]); - } - hex[12] = '\0'; - JSON::objSetString(gen, "bssid", hex); + JSON::objSetString(gen, "bssid", HexUtils::ToHex(std::span(bssid)).data()); } JSON::objEnd(gen, name); } diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp index aaee50b9d..d72265832 100644 --- a/components/device_control/src/estop/EStopConfig.cpp +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -82,27 +82,14 @@ bool EStopConfig::SaveNvs(const char* partition, const EStopConfig* previous) co bool EStopConfig::FromJSON(JSON::JsonView json) { - if (!json.valid()) { - *this = EStopConfig(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; } gpioPin = Parse::JsonGpioNum(json, "gpioPin").value_or(static_cast(Board::Get().estopGpio)); - if (JSON::JsonView enabledJson = json["enabled"]; enabledJson.valid()) { - auto parsed = enabledJson.tryGetBool(); - if (!parsed) { - OS_LOGE(TAG, "Failed to parse enabled"); - return false; - } - enabled = *parsed; - } else { - enabled = OpenShock::IsValidInputPin(gpioPin); + if (!Parse::JsonBool(enabled, json, "enabled", OpenShock::IsValidInputPin(gpioPin), TAG)) { + return false; } return true; diff --git a/components/device_control/src/radio/RFConfig.cpp b/components/device_control/src/radio/RFConfig.cpp index 07363cf61..0eb8a9439 100644 --- a/components/device_control/src/radio/RFConfig.cpp +++ b/components/device_control/src/radio/RFConfig.cpp @@ -70,14 +70,8 @@ bool RFConfig::SaveNvs(const char* partition, const RFConfig* previous) const bool RFConfig::FromJSON(JSON::JsonView json) { - if (!json.valid()) { - *this = RFConfig(); - return true; - } - - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); - return false; + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; } if (auto pin = Parse::JsonGpioNum(json, "txPin"); pin && *pin != GPIO_NUM_NC) { @@ -85,7 +79,9 @@ bool RFConfig::FromJSON(JSON::JsonView json) } else { txPin = static_cast(Board::Get().rfTxGpio); } - keepAliveEnabled = json["keepAliveEnabled"].tryGetBool().value_or(true); + if (!Parse::JsonBool(keepAliveEnabled, json, "keepAliveEnabled", true, TAG)) { + return false; + } return true; } diff --git a/components/serial_console/src/SerialInputConfig.cpp b/components/serial_console/src/SerialInputConfig.cpp index 9a6c306f4..9bb5a0d4c 100644 --- a/components/serial_console/src/SerialInputConfig.cpp +++ b/components/serial_console/src/SerialInputConfig.cpp @@ -41,30 +41,14 @@ flatbuffers::Offset bool SerialInputConfig::FromJSON(JSON::JsonView json) { - if (!json.valid()) { - OS_LOGW(TAG, "Config is null, setting to default"); - *this = SerialInputConfig(); - return true; + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; } - if (!json.isObject()) { - OS_LOGE(TAG, "json is not an object"); + if (!Parse::JsonBool(echoEnabled, json, "echoEnabled", true, TAG)) { return false; } - // A field that is there but is not a bool is a bad document, not a default: accepting it would store the default - // over whatever the hub has. - if (JSON::JsonView field = json["echoEnabled"]; field.valid()) { - auto parsed = field.tryGetBool(); - if (!parsed) { - OS_LOGE(TAG, "echoEnabled is not a boolean"); - return false; - } - echoEnabled = *parsed; - } else { - echoEnabled = true; - } - return true; } From 568920f17dfee34c9c022188602e6fdccd616042 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 06:46:03 +0100 Subject: [PATCH 15/23] refactor(config): plain-value accessors, shared credential helpers and one save report for the console Section::Get and Read cannot fail, yet every section's accessors wrapped the value in std::optional and every caller kept an error branch that could not run. The WiFi section hand-wrote the same id and SSID searches six times and the lowest-free-id scan three times (FromJSON, AddWiFiCredentials and the serial networks handler, each slightly differently), and the serial console mirrored the echo flag in a static that a failed save left out of step with the config. - Every Get* returns the value; the dead "failed to get" branches at twenty call sites go, and the never-called *Section() accessors with them. GetWiFiCredentials(gen) is void; HasWiFiCredentials answers the one question the captive portal asked by copying the whole list - WiFiConfig: findById / findBySsid / lowestFreeId serve every lookup; the renumber-or-drop of ids lives in Normalize, so SetWiFiCredentials accepts an id of 0 and the networks command no longer allocates ids itself; WiFiManager reads the id where it only needed the id - Serial console: ReportSaved / ReportSavedAndRestart replace eight copies of the save tail; the echo flag is read from the config section, so a failed save cannot leave the live value changed --- .../captiveportal/CaptivePortalConfig.h | 4 +- .../src/captiveportal/CaptivePortalConfig.cpp | 7 +- .../src/captiveportal/Manager.cpp | 15 +- .../host_test/main/config_test_helpers.cpp | 26 ++- .../config/host_test/main/test_corrupt.cpp | 5 +- .../config/host_test/main/test_defaults.cpp | 20 +- .../config/host_test/main/test_legacy.cpp | 13 +- .../config/host_test/main/test_roundtrip.cpp | 39 ++-- components/core/include/BackendConfig.h | 8 +- components/core/include/OtaUpdateConfig.h | 8 +- components/core/include/wifi/WiFiConfig.h | 16 +- components/core/src/BackendConfig.cpp | 11 +- components/core/src/GatewayClient.cpp | 19 +- .../core/src/GatewayConnectionManager.cpp | 16 +- components/core/src/OtaUpdateConfig.cpp | 11 +- components/core/src/http/JsonAPI.cpp | 34 +--- components/core/src/wifi/WiFiConfig.cpp | 187 ++++++++---------- components/core/src/wifi/WiFiManager.cpp | 18 +- .../host_test/main/host_fakes.cpp | 10 +- .../host_test/main/stubs/estop/EStopConfig.h | 6 +- .../host_test/main/stubs/radio/RFConfig.h | 8 +- .../include/estop/EStopConfig.h | 8 +- .../device_control/include/radio/RFConfig.h | 8 +- .../device_control/src/CommandHandler.cpp | 25 +-- .../device_control/src/EStopManager.cpp | 19 +- .../device_control/src/estop/EStopConfig.cpp | 11 +- .../device_control/src/radio/RFConfig.cpp | 11 +- components/ota/src/OtaUpdateManager.cpp | 42 +--- .../serial_console/SerialInputConfig.h | 6 +- .../serial_console/SerialInputHandler.h | 3 - .../serial_console/command_handlers/common.h | 7 + .../serial_console/src/SerialInputConfig.cpp | 9 +- .../serial_console/src/SerialInputHandler.cpp | 21 +- .../src/command_handlers/CommandEntry.cpp | 23 +++ .../src/command_handlers/authtoken.cpp | 17 +- .../src/command_handlers/domain.cpp | 22 +-- .../src/command_handlers/echo.cpp | 14 +- .../src/command_handlers/estop.cpp | 18 +- .../src/command_handlers/hostname.cpp | 18 +- .../src/command_handlers/jsonconfig.cpp | 11 +- .../src/command_handlers/keepalive.cpp | 17 +- .../src/command_handlers/networks.cpp | 40 +--- .../src/command_handlers/rawconfig.cpp | 11 +- .../src/command_handlers/rftxpin.cpp | 9 +- 44 files changed, 270 insertions(+), 581 deletions(-) diff --git a/components/captive_portal/include/captiveportal/CaptivePortalConfig.h b/components/captive_portal/include/captiveportal/CaptivePortalConfig.h index 297d042b9..8a5de62df 100644 --- a/components/captive_portal/include/captiveportal/CaptivePortalConfig.h +++ b/components/captive_portal/include/captiveportal/CaptivePortalConfig.h @@ -33,8 +33,6 @@ namespace OpenShock::Config { void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; }; - Section& CaptivePortalSection(); - - [[nodiscard]] std::optional GetCaptivePortalConfig(); + [[nodiscard]] CaptivePortalConfig GetCaptivePortalConfig(); bool SetCaptivePortalConfig(const CaptivePortalConfig& config); } // namespace OpenShock::Config diff --git a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp index f22c3e033..d3bd4f2d7 100644 --- a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp +++ b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp @@ -19,11 +19,6 @@ static constexpr const char* kKeyAlways = "always"; static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "captivePortal", .order = 2, .hubConfigField = Fbs::HubConfig::VT_CAPTIVE_PORTAL, .namespaces = kNamespaces}); -Section& OpenShock::Config::CaptivePortalSection() -{ - return s_section; -} - CaptivePortalConfig::CaptivePortalConfig() : alwaysEnabled(false) { @@ -77,7 +72,7 @@ bool CaptivePortalConfig::SaveNvs(const char* partition, const CaptivePortalConf return Nvs::Saver(*handle, *this, previous).Set(kKeyAlways, &CaptivePortalConfig::alwaysEnabled).Commit(); } -std::optional OpenShock::Config::GetCaptivePortalConfig() +CaptivePortalConfig OpenShock::Config::GetCaptivePortalConfig() { return s_section.Get(); } diff --git a/components/captive_portal/src/captiveportal/Manager.cpp b/components/captive_portal/src/captiveportal/Manager.cpp index 89bab5168..7cf9986c4 100644 --- a/components/captive_portal/src/captiveportal/Manager.cpp +++ b/components/captive_portal/src/captiveportal/Manager.cpp @@ -86,11 +86,7 @@ static void sendCommand(uint32_t command) static bool isDeviceFullyConfigured() { - auto credentialsList = Config::GetWiFiCredentials(); - if (!credentialsList || credentialsList->empty()) { - return false; - } - return Config::HasBackendAuthToken(); + return Config::HasWiFiCredentials() && Config::HasBackendAuthToken(); } static std::shared_ptr GetInstance() @@ -360,12 +356,9 @@ bool CaptivePortal::Init() auto* initialState = new ManagerState(); // Restore the persisted always-enabled setting before the hook can change it, so it survives a reboot. - if (auto config = Config::GetCaptivePortalConfig()) { - initialState->alwaysEnabled = config->alwaysEnabled; - s_alwaysEnabled = config->alwaysEnabled; - } else { - OS_LOGE(TAG, "Failed to get captive portal config"); - } + const bool alwaysEnabled = Config::GetCaptivePortalConfig().alwaysEnabled; + initialState->alwaysEnabled = alwaysEnabled; + s_alwaysEnabled = alwaysEnabled; // If device is already fully configured, set a startup grace period before opening portal if (isDeviceFullyConfigured()) { diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index f0ed5d437..e0e871a4c 100644 --- a/components/config/host_test/main/config_test_helpers.cpp +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -17,8 +17,10 @@ using namespace OpenShock; namespace ConfigTest { bool Full::SaveNvs(const char* partition) const { - if (!rf.SaveNvs(partition, nullptr) || !wifi.SaveNvs(partition, nullptr) || !captivePortal.SaveNvs(partition, nullptr) || !backend.SaveNvs(partition, nullptr) || !serialInput.SaveNvs(partition, nullptr) || !otaUpdate.SaveNvs(partition, nullptr) - || !estop.SaveNvs(partition, nullptr)) + if ( + !rf.SaveNvs(partition, nullptr) || !wifi.SaveNvs(partition, nullptr) || !captivePortal.SaveNvs(partition, nullptr) || !backend.SaveNvs(partition, nullptr) || !serialInput.SaveNvs(partition, nullptr) || !otaUpdate.SaveNvs(partition, nullptr) + || !estop.SaveNvs(partition, nullptr) + ) { return false; } @@ -171,20 +173,14 @@ namespace ConfigTest { Full Snapshot() { - // Every getter must succeed; unwraps the returned optional. - auto get = [](auto section) { - TEST_ASSERT_TRUE(section.has_value()); - return *std::move(section); - }; - Full config; - config.rf = get(Config::GetRFConfig()); - config.wifi = get(Config::GetWiFiConfig()); - config.captivePortal = get(Config::GetCaptivePortalConfig()); - config.backend = get(Config::GetBackendConfig()); - config.serialInput = get(Config::GetSerialInputConfig()); - config.otaUpdate = get(Config::GetOtaUpdateConfig()); - config.estop = get(Config::GetEStopConfig()); + config.rf = Config::GetRFConfig(); + config.wifi = Config::GetWiFiConfig(); + config.captivePortal = Config::GetCaptivePortalConfig(); + config.backend = Config::GetBackendConfig(); + config.serialInput = Config::GetSerialInputConfig(); + config.otaUpdate = Config::GetOtaUpdateConfig(); + config.estop = Config::GetEStopConfig(); return config; } diff --git a/components/config/host_test/main/test_corrupt.cpp b/components/config/host_test/main/test_corrupt.cpp index a8248ba32..245314984 100644 --- a/components/config/host_test/main/test_corrupt.cpp +++ b/components/config/host_test/main/test_corrupt.cpp @@ -375,9 +375,8 @@ TEST_CASE("Out-of-range E-Stop GPIO in a 1.x config falls back to the default", TEST_ASSERT_TRUE_MESSAGE(InitFromLegacy(builder.GetBufferPointer(), builder.GetSize()), msg); auto estopConfig = Config::GetEStopConfig(); - TEST_ASSERT_TRUE(estopConfig.has_value()); - TEST_ASSERT_EQUAL_INT_MESSAGE(Config::EStopConfig().gpioPin, estopConfig->gpioPin, msg); - TEST_ASSERT_EQUAL_MESSAGE(Config::EStopConfig().enabled, estopConfig->enabled, msg); + TEST_ASSERT_EQUAL_INT_MESSAGE(Config::EStopConfig().gpioPin, estopConfig.gpioPin, msg); + TEST_ASSERT_EQUAL_MESSAGE(Config::EStopConfig().enabled, estopConfig.enabled, msg); } } diff --git a/components/config/host_test/main/test_defaults.cpp b/components/config/host_test/main/test_defaults.cpp index 749d67250..c929e05ea 100644 --- a/components/config/host_test/main/test_defaults.cpp +++ b/components/config/host_test/main/test_defaults.cpp @@ -172,10 +172,9 @@ TEST_CASE("Malformed credential blobs are dropped, the rest kept", "[config][def Reload(); auto loaded = Config::GetWiFiCredentials(); - TEST_ASSERT_TRUE(loaded.has_value()); - TEST_ASSERT_EQUAL_size_t(2, loaded->size()); - AssertCredentialsEqual(Config::WiFiCredentials(1, "HomeNet", "hunter22"), (*loaded)[0]); - AssertCredentialsEqual(Config::WiFiCredentials(4, "Office", ""), (*loaded)[1]); + TEST_ASSERT_EQUAL_size_t(2, loaded.size()); + AssertCredentialsEqual(Config::WiFiCredentials(1, "HomeNet", "hunter22"), loaded[0]); + AssertCredentialsEqual(Config::WiFiCredentials(4, "Office", ""), loaded[1]); } TEST_CASE("Invalid stored GPIO pins fall back safely", "[config][defaults]") @@ -193,13 +192,8 @@ TEST_CASE("Invalid stored GPIO pins fall back safely", "[config][defaults]") MarkSchema(); Reload(); - auto txPin = Config::GetRFConfigTxPin(); - TEST_ASSERT_TRUE(txPin.has_value()); - TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, *txPin); - - auto estopEnabled = Config::GetEStopEnabled(); - TEST_ASSERT_TRUE(estopEnabled.has_value()); - TEST_ASSERT_FALSE(*estopEnabled); + TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, Config::GetRFConfigTxPin()); + TEST_ASSERT_FALSE(Config::GetEStopEnabled()); // Negative pin (the old "invalid" marker) also falls back. WipeStore(); @@ -210,9 +204,7 @@ TEST_CASE("Invalid stored GPIO pins fall back safely", "[config][defaults]") } MarkSchema(); Reload(); - txPin = Config::GetRFConfigTxPin(); - TEST_ASSERT_TRUE(txPin.has_value()); - TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, *txPin); + TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, Config::GetRFConfigTxPin()); } TEST_CASE("jsonconfig set with an empty object resets to defaults", "[config][defaults][json]") diff --git a/components/config/host_test/main/test_legacy.cpp b/components/config/host_test/main/test_legacy.cpp index ccc5935cc..a3dda6e2a 100644 --- a/components/config/host_test/main/test_legacy.cpp +++ b/components/config/host_test/main/test_legacy.cpp @@ -247,12 +247,9 @@ TEST_CASE("Fields from a newer 1.x schema are tolerated on import", "[config][le TEST_ASSERT_TRUE(InitFromLegacy(builder.GetBufferPointer(), builder.GetSize())); auto wifiConfig = Config::GetWiFiConfig(); - TEST_ASSERT_TRUE(wifiConfig.has_value()); - TEST_ASSERT_EQUAL_STRING("my-hub", wifiConfig->hostname.c_str()); - TEST_ASSERT_EQUAL_size_t(1, wifiConfig->credentialsList.size()); - TEST_ASSERT_EQUAL_STRING("hunter22", wifiConfig->credentialsList[0].password.c_str()); - - auto domain = Config::GetBackendDomain(); - TEST_ASSERT_TRUE(domain.has_value()); - TEST_ASSERT_EQUAL_STRING("api.example.org", domain->c_str()); + TEST_ASSERT_EQUAL_STRING("my-hub", wifiConfig.hostname.c_str()); + TEST_ASSERT_EQUAL_size_t(1, wifiConfig.credentialsList.size()); + TEST_ASSERT_EQUAL_STRING("hunter22", wifiConfig.credentialsList[0].password.c_str()); + + TEST_ASSERT_EQUAL_STRING("api.example.org", Config::GetBackendDomain().c_str()); } diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 0878f5d47..f32827622 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -45,9 +45,7 @@ TEST_CASE("Values set through the Config API survive a reload", "[config][roundt AssertConfigEqual(before, Snapshot()); TEST_ASSERT_TRUE(Config::HasBackendAuthToken()); - auto token = Config::GetBackendAuthToken(); - TEST_ASSERT_TRUE(token.has_value()); - TEST_ASSERT_EQUAL_STRING("tok_0123456789abcdef", token->c_str()); + TEST_ASSERT_EQUAL_STRING("tok_0123456789abcdef", Config::GetBackendAuthToken().c_str()); auto creds = Config::TryGetWiFiCredentialsBySSID("Guest"); TEST_ASSERT_TRUE(creds.has_value()); @@ -83,22 +81,22 @@ TEST_CASE("Removed credentials are gone after a reload and their ids can be reus Reload(); auto creds = Config::GetWiFiCredentials(); - TEST_ASSERT_TRUE(creds.has_value()); - TEST_ASSERT_EQUAL_size_t(2, creds->size()); - TEST_ASSERT_EQUAL_UINT8(1, (*creds)[0].id); - TEST_ASSERT_EQUAL_UINT8(3, (*creds)[1].id); + TEST_ASSERT_EQUAL_size_t(2, creds.size()); + TEST_ASSERT_EQUAL_UINT8(1, creds[0].id); + TEST_ASSERT_EQUAL_UINT8(3, creds[1].id); TEST_ASSERT_FALSE(Config::TryGetWiFiCredentialsByID(2).has_value()); // The freed id is handed out again, and the list order is by id, not by insertion TEST_ASSERT_EQUAL_UINT8(2, Config::AddWiFiCredentials("D", "pd", WIFI_AUTH_WPA2_PSK)); Reload(); creds = Config::GetWiFiCredentials(); - TEST_ASSERT_EQUAL_size_t(3, creds->size()); - TEST_ASSERT_EQUAL_STRING("D", (*creds)[1].ssid.c_str()); + TEST_ASSERT_EQUAL_size_t(3, creds.size()); + TEST_ASSERT_EQUAL_STRING("D", creds[1].ssid.c_str()); TEST_ASSERT_TRUE(Config::ClearWiFiCredentials()); Reload(); - TEST_ASSERT_TRUE(Config::GetWiFiCredentials()->empty()); + TEST_ASSERT_TRUE(Config::GetWiFiCredentials().empty()); + TEST_ASSERT_FALSE(Config::HasWiFiCredentials()); } TEST_CASE("Credentials are enumerated from their keys, never counted", "[config][roundtrip]") @@ -109,10 +107,9 @@ TEST_CASE("Credentials are enumerated from their keys, never counted", "[config] InitWith(config); auto creds = Config::GetWiFiCredentials(); - TEST_ASSERT_TRUE(creds.has_value()); - TEST_ASSERT_EQUAL_size_t(3, creds->size()); - TEST_ASSERT_EQUAL_UINT8(3, (*creds)[0].id); - TEST_ASSERT_EQUAL_UINT8(200, (*creds)[1].id); + TEST_ASSERT_EQUAL_size_t(3, creds.size()); + TEST_ASSERT_EQUAL_UINT8(3, creds[0].id); + TEST_ASSERT_EQUAL_UINT8(200, creds[1].id); TEST_ASSERT_EQUAL_UINT8(255, (*creds)[2].id); TEST_ASSERT_EQUAL_STRING("Max", (*creds)[2].ssid.c_str()); } @@ -140,10 +137,9 @@ TEST_CASE("One credential that does not read is dropped; the others load and sur TEST_ASSERT_EQUAL_UINT8(2, Config::AddWiFiCredentials("Cafe", "latte123", WIFI_AUTH_WPA2_PSK)); Reload(); auto creds = Config::GetWiFiCredentials(); - TEST_ASSERT_TRUE(creds.has_value()); - TEST_ASSERT_EQUAL_size_t(3, creds->size()); - TEST_ASSERT_EQUAL_UINT8(1, (*creds)[0].id); - TEST_ASSERT_EQUAL_STRING("Cafe", (*creds)[1].ssid.c_str()); + TEST_ASSERT_EQUAL_size_t(3, creds.size()); + TEST_ASSERT_EQUAL_UINT8(1, creds[0].id); + TEST_ASSERT_EQUAL_STRING("Cafe", creds[1].ssid.c_str()); TEST_ASSERT_EQUAL_UINT8(7, (*creds)[2].id); } @@ -181,7 +177,7 @@ TEST_CASE("A save writes only what changed", "[config][roundtrip]") TEST_ASSERT_TRUE(Config::SetSerialInputConfigEchoEnabled(true)); TEST_ASSERT_EQUAL_size_t(before, UsedEntries()); Reload(); - TEST_ASSERT_TRUE(*Config::GetSerialInputConfigEchoEnabled()); + TEST_ASSERT_TRUE(Config::GetSerialInputConfigEchoEnabled()); } TEST_CASE("Many max-length WiFi credentials survive a reload", "[config][roundtrip]") @@ -265,9 +261,8 @@ TEST_CASE("jsonconfig set decodes \\u escapes", "[config][roundtrip][json]") Reload(); // UTF-8 and control characters survive the store too auto wifi = Config::GetWiFiConfig(); - TEST_ASSERT_TRUE(wifi.has_value()); - TEST_ASSERT_EQUAL_STRING("hub-1", wifi->hostname.c_str()); - TEST_ASSERT_EQUAL_size_t(1, wifi->credentialsList.size()); + TEST_ASSERT_EQUAL_STRING("hub-1", wifi.hostname.c_str()); + TEST_ASSERT_EQUAL_size_t(1, wifi.credentialsList.size()); TEST_ASSERT_EQUAL_STRING("Caf\xc3\xa9 \xf0\x9f\x98\x80", wifi->credentialsList[0].ssid.c_str()); TEST_ASSERT_EQUAL_STRING("a\tb", wifi->credentialsList[0].password.c_str()); } diff --git a/components/core/include/BackendConfig.h b/components/core/include/BackendConfig.h index 425b2b675..e2e718639 100644 --- a/components/core/include/BackendConfig.h +++ b/components/core/include/BackendConfig.h @@ -36,14 +36,12 @@ namespace OpenShock::Config { void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; }; - Section& BackendSection(); - - [[nodiscard]] std::optional GetBackendConfig(); + [[nodiscard]] BackendConfig GetBackendConfig(); bool SetBackendConfig(const BackendConfig& config); - [[nodiscard]] std::optional GetBackendDomain(); + [[nodiscard]] std::string GetBackendDomain(); bool SetBackendDomain(std::string domain); bool HasBackendAuthToken(); - [[nodiscard]] std::optional GetBackendAuthToken(); + [[nodiscard]] std::string GetBackendAuthToken(); bool SetBackendAuthToken(std::string token); bool ClearBackendAuthToken(); } // namespace OpenShock::Config diff --git a/components/core/include/OtaUpdateConfig.h b/components/core/include/OtaUpdateConfig.h index 9bfce3fa9..6be5faa1c 100644 --- a/components/core/include/OtaUpdateConfig.h +++ b/components/core/include/OtaUpdateConfig.h @@ -55,18 +55,16 @@ namespace OpenShock::Config { void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; }; - Section& OtaUpdateSection(); - - [[nodiscard]] std::optional GetOtaUpdateConfig(); + [[nodiscard]] OtaUpdateConfig GetOtaUpdateConfig(); bool SetOtaUpdateConfig(const OtaUpdateConfig& config); /// @brief Read-modify-write that no other writer can interleave with. `update` edits the current config in place and /// returns whether it changed anything (nothing is saved otherwise). It runs under the config write lock: keep /// it to plain field updates. bool UpdateOtaUpdateConfig(Util::FunctionRef update); - [[nodiscard]] std::optional GetOtaUpdateId(); + [[nodiscard]] int32_t GetOtaUpdateId(); bool SetOtaUpdateId(int32_t updateId); - [[nodiscard]] std::optional GetOtaUpdateStep(); + [[nodiscard]] OtaUpdateStep GetOtaUpdateStep(); bool SetOtaUpdateStep(OtaUpdateStep updateStep); /// @brief Sets the step only if it is still `expected`, so a stale writer can't clobber an update started meanwhile. /// @return True if the step was `expected` and is now `desired` diff --git a/components/core/include/wifi/WiFiConfig.h b/components/core/include/wifi/WiFiConfig.h index 83ab48bdd..0027ee663 100644 --- a/components/core/include/wifi/WiFiConfig.h +++ b/components/core/include/wifi/WiFiConfig.h @@ -26,7 +26,8 @@ namespace OpenShock::Config { std::vector credentialsList; // in id order once loaded from NVS /// @brief The section runs it on every value it takes in: a hostname esp_netif would refuse (empty, over 32 - /// characters, or not letters, digits and hyphens) becomes the default, whichever way it came in. + /// characters, or not letters, digits and hyphens) becomes the default, and a credential with no id (0) or + /// a repeated one gets the lowest free id (or is dropped when none is left), whichever way it came in. void Normalize(); bool LoadNvs(const char* partition); @@ -38,15 +39,16 @@ namespace OpenShock::Config { [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; }; - Section& WiFiSection(); - - [[nodiscard]] std::optional GetWiFiConfig(); + [[nodiscard]] WiFiConfig GetWiFiConfig(); bool SetWiFiConfig(const WiFiConfig& config); - [[nodiscard]] std::optional> GetWiFiCredentials(); + [[nodiscard]] std::vector GetWiFiCredentials(); // Emits each stored credential as an object into an already-open JSON array // (the caller opens/closes it with json_gen_{start,end}_array). - bool GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData); + void GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData); + [[nodiscard]] bool HasWiFiCredentials(); + /// @brief Replaces every saved network. An id of 0 gets the lowest free id; a repeated id or a bad SSID/password + /// length is refused. bool SetWiFiCredentials(const std::vector& credentials); bool AnyWiFiCredentials(Util::FunctionRef predicate); @@ -58,6 +60,6 @@ namespace OpenShock::Config { bool PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]); bool RemoveWiFiCredentials(uint8_t id); bool ClearWiFiCredentials(); - [[nodiscard]] std::optional GetWiFiHostname(); + [[nodiscard]] std::string GetWiFiHostname(); bool SetWiFiHostname(std::string hostname); } // namespace OpenShock::Config diff --git a/components/core/src/BackendConfig.cpp b/components/core/src/BackendConfig.cpp index 58cfbc895..8ab287175 100644 --- a/components/core/src/BackendConfig.cpp +++ b/components/core/src/BackendConfig.cpp @@ -20,11 +20,6 @@ static constexpr const char* kKeyToken = "token"; static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "backend", .order = 3, .hubConfigField = Fbs::HubConfig::VT_BACKEND, .namespaces = kNamespaces}); -Section& OpenShock::Config::BackendSection() -{ - return s_section; -} - BackendConfig::BackendConfig() : domain(CONFIG_OPENSHOCK_API_DOMAIN) , authToken() @@ -93,7 +88,7 @@ bool BackendConfig::SaveNvs(const char* partition, const BackendConfig* previous return Nvs::Saver(*handle, *this, previous).Set(kKeyDomain, &BackendConfig::domain).Set(kKeyToken, &BackendConfig::authToken).Commit(); } -std::optional OpenShock::Config::GetBackendConfig() +BackendConfig OpenShock::Config::GetBackendConfig() { return s_section.Get(); } @@ -103,7 +98,7 @@ bool OpenShock::Config::SetBackendConfig(const BackendConfig& config) return s_section.Set(config); } -std::optional OpenShock::Config::GetBackendDomain() +std::string OpenShock::Config::GetBackendDomain() { return s_section.Read([](const BackendConfig& backend) { return backend.domain; }); } @@ -121,7 +116,7 @@ bool OpenShock::Config::HasBackendAuthToken() return s_section.Read([](const BackendConfig& backend) { return !backend.authToken.empty(); }); } -std::optional OpenShock::Config::GetBackendAuthToken() +std::string OpenShock::Config::GetBackendAuthToken() { return s_section.Read([](const BackendConfig& backend) { return backend.authToken; }); } diff --git a/components/core/src/GatewayClient.cpp b/components/core/src/GatewayClient.cpp index 47990f0e4..96c204a1a 100644 --- a/components/core/src/GatewayClient.cpp +++ b/components/core/src/GatewayClient.cpp @@ -251,22 +251,13 @@ void GatewayClient::_sendBootStatus() OS_LOGV(TAG, "Sending Gateway boot status message"); - auto updateId = Config::GetOtaUpdateId(); - if (!updateId) { - OS_LOGE(TAG, "Failed to get OTA update ID"); - return; - } - - auto updateStep = Config::GetOtaUpdateStep(); - if (!updateStep) { - OS_LOGE(TAG, "Failed to get OTA firmware boot type"); - return; - } + const int32_t updateId = Config::GetOtaUpdateId(); + const OtaUpdateStep updateStep = Config::GetOtaUpdateStep(); - s_bootStatusSent = Serialization::Gateway::SerializeBootStatusMessage(*updateId, AppHooks::OtaGetFirmwareBootType(), [this](std::span data) { return sendMessageBIN(data); }); + s_bootStatusSent = Serialization::Gateway::SerializeBootStatusMessage(updateId, AppHooks::OtaGetFirmwareBootType(), [this](std::span data) { return sendMessageBIN(data); }); - if (s_bootStatusSent && *updateStep != OpenShock::OtaUpdateStep::None) { - s_reportedOtaStep = *updateStep; + if (s_bootStatusSent && updateStep != OpenShock::OtaUpdateStep::None) { + s_reportedOtaStep = updateStep; s_otaStepResetPending = true; } } diff --git a/components/core/src/GatewayConnectionManager.cpp b/components/core/src/GatewayConnectionManager.cpp index dceab72e4..7f775e110 100644 --- a/components/core/src/GatewayConnectionManager.cpp +++ b/components/core/src/GatewayConnectionManager.cpp @@ -303,13 +303,7 @@ static bool StartConnectingToLCG() return false; } - auto authToken = Config::GetBackendAuthToken(); - if (!authToken) { - OS_LOGE(TAG, "Failed to get auth token"); - return false; - } - - auto response = HTTP::JsonAPI::AssignLcg(*authToken); + auto response = HTTP::JsonAPI::AssignLcg(Config::GetBackendAuthToken()); if (response.code == 401) { OS_LOGD(TAG, "Auth token is invalid, waiting 5 minutes before retrying"); @@ -351,13 +345,7 @@ static void InitializeClient() return; // Checked here so an auth hold-off doesn't also count as a hub info failure and stack the two backoffs } - auto authToken = Config::GetBackendAuthToken(); - if (!authToken) { - OS_LOGE(TAG, "Failed to get auth token"); - return; - } - - if (!FetchHubInfo(*authToken)) { + if (!FetchHubInfo(Config::GetBackendAuthToken())) { // Back off on failure so an unreachable backend isn't polled on every update tick. s_hubInfoBackoff.backOff(OpenShock::millis()); return; diff --git a/components/core/src/OtaUpdateConfig.cpp b/components/core/src/OtaUpdateConfig.cpp index 7891c1252..ce789933b 100644 --- a/components/core/src/OtaUpdateConfig.cpp +++ b/components/core/src/OtaUpdateConfig.cpp @@ -32,11 +32,6 @@ static constexpr uint16_t kDefaultCheckInterval = 30; // minutes static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "otaUpdate", .order = 5, .hubConfigField = Fbs::HubConfig::VT_OTA_UPDATE, .namespaces = kNamespaces}); -Section& OpenShock::Config::OtaUpdateSection() -{ - return s_section; -} - using namespace std::string_view_literals; OtaUpdateConfig::OtaUpdateConfig() @@ -182,7 +177,7 @@ bool OtaUpdateConfig::SaveNvs(const char* partition, const OtaUpdateConfig* prev .Commit(); } -std::optional OpenShock::Config::GetOtaUpdateConfig() +OtaUpdateConfig OpenShock::Config::GetOtaUpdateConfig() { return s_section.Get(); } @@ -197,7 +192,7 @@ bool OpenShock::Config::UpdateOtaUpdateConfig(Util::FunctionRef OpenShock::Config::GetOtaUpdateId() +int32_t OpenShock::Config::GetOtaUpdateId() { return s_section.Read([](const OtaUpdateConfig& ota) { return ota.updateId; }); } @@ -213,7 +208,7 @@ bool OpenShock::Config::SetOtaUpdateId(int32_t updateId) }); } -std::optional OpenShock::Config::GetOtaUpdateStep() +OpenShock::OtaUpdateStep OpenShock::Config::GetOtaUpdateStep() { return s_section.Read([](const OtaUpdateConfig& ota) { return ota.updateStep; }); } diff --git a/components/core/src/http/JsonAPI.cpp b/components/core/src/http/JsonAPI.cpp index e9731e207..50fb5d5f3 100644 --- a/components/core/src/http/JsonAPI.cpp +++ b/components/core/src/http/JsonAPI.cpp @@ -9,36 +9,28 @@ const char* const TAG = "JsonAPI"; #include "OpenShock.h" #include "StringHelpers.h" -#include #include using namespace OpenShock; // "https://" -[[nodiscard]] static std::optional tryGetBackendUri(std::string_view path) +[[nodiscard]] static std::string backendUri(std::string_view path) { - auto domain = Config::GetBackendDomain(); - if (!domain) { - return std::nullopt; - } - + const std::string domain = Config::GetBackendDomain(); std::string uri; - uri.reserve(8 + domain->size() + path.size()); + uri.reserve(8 + domain.size() + path.size()); uri = "https://"; - uri += *domain; + uri += domain; uri += path; return uri; } HTTP::Response HTTP::JsonAPI::LinkAccount(std::string_view accountLinkCode) { - auto uri = tryGetBackendUri(std::string("/1/device/pair/") + std::string(accountLinkCode)); - if (!uri) { - return {HTTP::RequestResult::InternalError, 0, {}}; - } + const std::string uri = backendUri(std::string("/1/device/pair/") + std::string(accountLinkCode)); return HTTP::GetJSON( - *uri, + uri, { {"Accept", "application/json"} }, @@ -49,13 +41,10 @@ HTTP::Response HTTP::JsonAPI::LinkA HTTP::Response HTTP::JsonAPI::GetHubInfo(std::string_view hubToken) { - auto uri = tryGetBackendUri("/1/device/self"); - if (!uri) { - return {HTTP::RequestResult::InternalError, 0, {}}; - } + const std::string uri = backendUri("/1/device/self"); return HTTP::GetJSON( - *uri, + uri, { { "Accept", "application/json"}, {"DeviceToken", std::string(hubToken)} @@ -67,13 +56,10 @@ HTTP::Response HTTP::JsonAPI::GetHubInf HTTP::Response HTTP::JsonAPI::AssignLcg(std::string_view hubToken) { - auto uri = tryGetBackendUri("/2/device/assignLCG?version=2"); - if (!uri) { - return {HTTP::RequestResult::InternalError, 0, {}}; - } + const std::string uri = backendUri("/2/device/assignLCG?version=2"); return HTTP::GetJSON( - *uri, + uri, { { "Accept", "application/json"}, {"DeviceToken", std::string(hubToken)} diff --git a/components/core/src/wifi/WiFiConfig.cpp b/components/core/src/wifi/WiFiConfig.cpp index e147e6f57..2a242ad84 100644 --- a/components/core/src/wifi/WiFiConfig.cpp +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -30,11 +30,6 @@ static constexpr const char* kKeyHostname = "hostname"; static constexpr const char* kNamespaces[] = {kNamespaceCfg, kNamespaceCreds}; static Section s_section({.name = "wifi", .order = 1, .hubConfigField = Fbs::HubConfig::VT_WIFI, .namespaces = kNamespaces}); -Section& OpenShock::Config::WiFiSection() -{ - return s_section; -} - WiFiConfig::WiFiConfig() : accessPointSSID(CONFIG_OPENSHOCK_FW_AP_PREFIX) , hostname(CONFIG_OPENSHOCK_FW_HOSTNAME) @@ -84,36 +79,6 @@ bool WiFiConfig::FromJSON(JSON::JsonView json) Parse::JsonArray(credentialsList, credentialsListJson); - // Each credential is stored under its id, so 0 (what a missing id parses to) and a repeated id would be lost on - // flash. Those get the lowest free id instead; one that finds none is dropped. - std::bitset<256> usedIds; - usedIds[0] = true; - for (const auto& creds : credentialsList) { - usedIds[creds.id] = true; - } - std::bitset<256> seenIds; - for (auto it = credentialsList.begin(); it != credentialsList.end();) { - if (it->id != 0 && !seenIds[it->id]) { - seenIds[it->id] = true; - ++it; - continue; - } - std::size_t freeId = 1; - while (freeId < usedIds.size() && usedIds[freeId]) { - ++freeId; - } - if (freeId == usedIds.size()) { - OS_LOGW(TAG, "Dropping credential %u: no free id left", it->id); - it = credentialsList.erase(it); - continue; - } - OS_LOGW(TAG, "Credential %u renumbered to %zu", it->id, freeId); - it->id = static_cast(freeId); - usedIds[freeId] = true; - seenIds[freeId] = true; - ++it; - } - return true; } @@ -138,13 +103,37 @@ namespace { snprintf(key, sizeof(key), "%u", id); } - const WiFiCredentials* findById(const std::vector& list, uint8_t id) + // The one credential with `id` / `ssid`, const or mutable with the list; null when there is none + template + auto findById(List& list, uint8_t id) -> decltype(list.data()) { - for (const auto& creds : list) { + for (auto& creds : list) { if (creds.id == id) return &creds; } return nullptr; } + + template + auto findBySsid(List& list, std::string_view ssid) -> decltype(list.data()) + { + for (auto& creds : list) { + if (creds.ssid == ssid) return &creds; + } + return nullptr; + } + + // The lowest id in 1..255 no credential in `list` holds; 0 when every id is taken + uint8_t lowestFreeId(const std::vector& list) + { + std::bitset<256> used; + for (const auto& creds : list) { + used[creds.id] = true; + } + for (std::size_t id = 1; id < used.size(); ++id) { + if (!used[id]) return static_cast(id); + } + return 0; + } } // namespace void WiFiConfig::Normalize() @@ -153,6 +142,27 @@ void WiFiConfig::Normalize() OS_LOGW(TAG, "Hostname \"%s\" is not valid, using the default", hostname.c_str()); hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; } + + // Each credential is stored under its id, so 0 (what a missing id parses to, and what a new network comes in with) + // and a repeated id would be lost on flash. Those get the lowest free id instead; one that finds none is dropped. + std::bitset<256> seenIds; + for (auto it = credentialsList.begin(); it != credentialsList.end();) { + if (it->id != 0 && !seenIds[it->id]) { + seenIds[it->id] = true; + ++it; + continue; + } + const uint8_t freeId = lowestFreeId(credentialsList); + if (freeId == 0) { + OS_LOGW(TAG, "Dropping credential %u: no free id left", it->id); + it = credentialsList.erase(it); + continue; + } + OS_LOGW(TAG, "Credential %u renumbered to %u", it->id, freeId); + it->id = freeId; + seenIds[freeId] = true; + ++it; + } } bool WiFiConfig::LoadNvs(const char* partition) @@ -264,7 +274,7 @@ static bool isValidWiFiCredentials(std::string_view ssid, std::string_view passw return true; } -std::optional OpenShock::Config::GetWiFiConfig() +WiFiConfig OpenShock::Config::GetWiFiConfig() { return s_section.Get(); } @@ -274,30 +284,31 @@ bool OpenShock::Config::SetWiFiConfig(const WiFiConfig& config) return s_section.Set(config); } -std::optional> OpenShock::Config::GetWiFiCredentials() +std::vector OpenShock::Config::GetWiFiCredentials() { return s_section.Read([](const WiFiConfig& wifi) { return wifi.credentialsList; }); } -bool OpenShock::Config::GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData) +void OpenShock::Config::GetWiFiCredentials(json_gen_str_t* gen, bool withSensitiveData) { s_section.Read([&](const WiFiConfig& wifi) { for (const auto& creds : wifi.credentialsList) { creds.ToJSON(gen, nullptr, withSensitiveData); } }); - return true; +} + +bool OpenShock::Config::HasWiFiCredentials() +{ + return s_section.Read([](const WiFiConfig& wifi) { return !wifi.credentialsList.empty(); }); } bool OpenShock::Config::SetWiFiCredentials(const std::vector& credentials) { + // An id of 0 means "pick one" and Normalize hands it the lowest free id; a repeated id is two networks under one key std::bitset<256> seenIds; for (const auto& creds : credentials) { - if (creds.id == 0) { - OS_LOGE(TAG, "Cannot set WiFi credentials: credential ID cannot be 0"); - return false; - } - if (seenIds[creds.id]) { + if (creds.id != 0 && seenIds[creds.id]) { OS_LOGE(TAG, "Cannot set WiFi credentials: duplicate credential ID %u", creds.id); return false; } @@ -318,7 +329,7 @@ bool OpenShock::Config::AnyWiFiCredentials(Util::FunctionRefbegin(), creds->end(), predicate); + return std::any_of(creds.begin(), creds.end(), predicate); } uint8_t OpenShock::Config::AddWiFiCredentials(std::string_view ssid, std::string_view password, wifi_auth_mode_t authMode) @@ -331,37 +342,16 @@ uint8_t OpenShock::Config::AddWiFiCredentials(std::string_view ssid, std::string bool stored = s_section.Update([&](WiFiConfig& wifi) { auto& list = wifi.credentialsList; - std::bitset<255> bits; - for (auto it = list.begin(); it != list.end();) { - auto& creds = *it; - - if (std::string_view(creds.ssid) == ssid) { - creds.password = password; - if (authMode != WIFI_AUTH_MAX) { - creds.authMode = authMode; - } - id = creds.id; - return true; - } - - if (creds.id == 0) { - OS_LOGW(TAG, "Found WiFi credentials with ID 0, removing"); - it = list.erase(it); - continue; + if (WiFiCredentials* existing = findBySsid(list, ssid)) { + existing->password = password; + if (authMode != WIFI_AUTH_MAX) { + existing->authMode = authMode; } - - // Mark ID as used - bits[creds.id - 1] = true; - ++it; + id = existing->id; + return true; } - // First available ID - for (std::size_t i = 0; i < bits.size(); ++i) { - if (!bits[i]) { - id = static_cast(i + 1); - break; - } - } + id = lowestFreeId(list); if (id == 0) { OS_LOGE(TAG, "Failed to add WiFi credentials: no available IDs"); return false; @@ -383,36 +373,24 @@ uint8_t OpenShock::Config::AddWiFiCredentials(std::string_view ssid, std::string std::optional OpenShock::Config::TryGetWiFiCredentialsByID(uint8_t id) { return s_section.Read([&](const WiFiConfig& wifi) -> std::optional { - for (const auto& creds : wifi.credentialsList) { - if (creds.id == id) { - return creds; - } - } - return std::nullopt; + const WiFiCredentials* creds = findById(wifi.credentialsList, id); + return creds != nullptr ? std::optional(*creds) : std::nullopt; }); } std::optional OpenShock::Config::TryGetWiFiCredentialsBySSID(const char* ssid) { return s_section.Read([&](const WiFiConfig& wifi) -> std::optional { - for (const auto& creds : wifi.credentialsList) { - if (creds.ssid == ssid) { - return creds; - } - } - return std::nullopt; + const WiFiCredentials* creds = findBySsid(wifi.credentialsList, ssid); + return creds != nullptr ? std::optional(*creds) : std::nullopt; }); } uint8_t OpenShock::Config::GetWiFiCredentialsIDbySSID(const char* ssid) { return s_section.Read([&](const WiFiConfig& wifi) -> uint8_t { - for (const auto& creds : wifi.credentialsList) { - if (creds.ssid == ssid) { - return creds.id; - } - } - return 0; + const WiFiCredentials* creds = findBySsid(wifi.credentialsList, ssid); + return creds != nullptr ? creds->id : 0; }); } @@ -420,14 +398,13 @@ bool OpenShock::Config::PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssi { bool found = false; bool saved = s_section.Update([&](WiFiConfig& wifi) { - for (auto& creds : wifi.credentialsList) { - if (creds.id == id) { - memcpy(creds.bssid.data(), bssid, 6); - found = true; - return true; - } + WiFiCredentials* creds = findById(wifi.credentialsList, id); + if (creds == nullptr) { + return false; } - return false; + memcpy(creds->bssid.data(), bssid, 6); + found = true; + return true; }); return found && saved; } @@ -436,12 +413,12 @@ bool OpenShock::Config::RemoveWiFiCredentials(uint8_t id) { bool found = false; bool saved = s_section.Update([&](WiFiConfig& wifi) { - auto& list = wifi.credentialsList; - auto it = std::find_if(list.begin(), list.end(), [id](const WiFiCredentials& creds) { return creds.id == id; }); - if (it == list.end()) { + auto& list = wifi.credentialsList; + const WiFiCredentials* creds = findById(list, id); + if (creds == nullptr) { return false; } - list.erase(it); + list.erase(list.begin() + (creds - list.data())); found = true; return true; }); @@ -456,7 +433,7 @@ bool OpenShock::Config::ClearWiFiCredentials() }); } -std::optional OpenShock::Config::GetWiFiHostname() +std::string OpenShock::Config::GetWiFiHostname() { return s_section.Read([](const WiFiConfig& wifi) { return wifi.hostname; }); } diff --git a/components/core/src/wifi/WiFiManager.cpp b/components/core/src/wifi/WiFiManager.cpp index 0ac12b093..4d8a070c2 100644 --- a/components/core/src/wifi/WiFiManager.cpp +++ b/components/core/src/wifi/WiFiManager.cpp @@ -303,9 +303,8 @@ static void evWiFiConnected(const wifi_event_sta_connected_t& info) OS_LOGW(TAG, "Connected to unscanned network \"%s\", BSSID: " BSSID_FMT, ssid, BSSID_ARG(info.bssid)); - uint8_t credentialsId = 0; - if (auto creds = Config::TryGetWiFiCredentialsBySSID(ssid)) { - credentialsId = creds->id; + const uint8_t credentialsId = Config::GetWiFiCredentialsIDbySSID(ssid); + if (credentialsId != 0) { s_connectedCredentialsID.store(credentialsId, std::memory_order_relaxed); } @@ -619,11 +618,7 @@ bool WiFiManager::Init() return false; } - auto hostname = Config::GetWiFiHostname(); - if (!hostname) { - OS_LOGE(TAG, "Failed to get WiFi hostname, reverting to default"); - hostname = CONFIG_OPENSHOCK_FW_HOSTNAME; - } + const std::string hostname = Config::GetWiFiHostname(); // Normalize keeps it valid err = esp_wifi_set_mode(WIFI_MODE_STA); if (err != ESP_OK) { @@ -637,7 +632,7 @@ bool WiFiManager::Init() return false; } - esp_netif_set_hostname(s_staNetif, hostname->c_str()); + esp_netif_set_hostname(s_staNetif, hostname.c_str()); TaskHandle_t updateTask = nullptr; if (TaskUtils::TaskCreateUniversal(wifimanagerUpdateTask, TAG, CONFIG_OPENSHOCK_WIFI_TASK_STACK_SIZE, nullptr, CONFIG_OPENSHOCK_WIFI_TASK_PRIORITY, &updateTask, 1) != pdPASS) { // TODO: Re-profile stack usage @@ -770,12 +765,11 @@ bool WiFiManager::RefreshNetworkCredentials() ScopedLock lock__(&s_networksMutex); for (auto& net : s_wifiNetworks) { - if (auto creds = Config::TryGetWiFiCredentialsBySSID(net.ssid)) { + net.credentialsID = Config::GetWiFiCredentialsIDbySSID(net.ssid); + if (net.credentialsID != 0) { OS_LOGV(TAG, "Found credentials for network %s (" BSSID_FMT ")", net.ssid, BSSID_ARG(net.bssid)); - net.credentialsID = creds->id; } else { OS_LOGV(TAG, "Failed to find credentials for network %s (" BSSID_FMT ")", net.ssid, BSSID_ARG(net.bssid)); - net.credentialsID = 0; } } diff --git a/components/device_control/host_test/main/host_fakes.cpp b/components/device_control/host_test/main/host_fakes.cpp index bbd0dfeef..654026350 100644 --- a/components/device_control/host_test/main/host_fakes.cpp +++ b/components/device_control/host_test/main/host_fakes.cpp @@ -435,12 +435,12 @@ void OpenShock::TaskUtils::StopTask(TaskHandle_t taskHandle, TaskExitFlag& exite // --- Config --- -std::optional OpenShock::Config::GetRFConfig() +OpenShock::Config::RFConfig OpenShock::Config::GetRFConfig() { return RfConfig; } -std::optional OpenShock::Config::GetRFConfigTxPin() +gpio_num_t OpenShock::Config::GetRFConfigTxPin() { return RfConfig.txPin; } @@ -451,7 +451,7 @@ bool OpenShock::Config::SetRFConfigTxPin(gpio_num_t txPin) return true; } -std::optional OpenShock::Config::GetRFConfigKeepAliveEnabled() +bool OpenShock::Config::GetRFConfigKeepAliveEnabled() { return RfConfig.keepAliveEnabled; } @@ -462,12 +462,12 @@ bool OpenShock::Config::SetRFConfigKeepAliveEnabled(bool enabled) return true; } -std::optional OpenShock::Config::GetEStopConfig() +OpenShock::Config::EStopConfig OpenShock::Config::GetEStopConfig() { return EStopCfg; } -std::optional OpenShock::Config::GetEStopGpioPin() +gpio_num_t OpenShock::Config::GetEStopGpioPin() { return EStopCfg.gpioPin; } diff --git a/components/device_control/host_test/main/stubs/estop/EStopConfig.h b/components/device_control/host_test/main/stubs/estop/EStopConfig.h index 71c27ac15..2910712a2 100644 --- a/components/device_control/host_test/main/stubs/estop/EStopConfig.h +++ b/components/device_control/host_test/main/stubs/estop/EStopConfig.h @@ -4,14 +4,12 @@ // calls, backed by a plain value in host_fakes.cpp (HostFake::EStopCfg) instead of the config framework. #include -#include - namespace OpenShock::Config { struct EStopConfig { bool enabled; gpio_num_t gpioPin; }; - [[nodiscard]] std::optional GetEStopConfig(); - [[nodiscard]] std::optional GetEStopGpioPin(); + [[nodiscard]] EStopConfig GetEStopConfig(); + [[nodiscard]] gpio_num_t GetEStopGpioPin(); } // namespace OpenShock::Config diff --git a/components/device_control/host_test/main/stubs/radio/RFConfig.h b/components/device_control/host_test/main/stubs/radio/RFConfig.h index c1528a52e..f3611f425 100644 --- a/components/device_control/host_test/main/stubs/radio/RFConfig.h +++ b/components/device_control/host_test/main/stubs/radio/RFConfig.h @@ -4,17 +4,15 @@ // RFTransmitter call, backed by a plain value in host_fakes.cpp (HostFake::RfConfig) instead of the config framework. #include -#include - namespace OpenShock::Config { struct RFConfig { gpio_num_t txPin; bool keepAliveEnabled; }; - [[nodiscard]] std::optional GetRFConfig(); - [[nodiscard]] std::optional GetRFConfigTxPin(); + [[nodiscard]] RFConfig GetRFConfig(); + [[nodiscard]] gpio_num_t GetRFConfigTxPin(); bool SetRFConfigTxPin(gpio_num_t txPin); - [[nodiscard]] std::optional GetRFConfigKeepAliveEnabled(); + [[nodiscard]] bool GetRFConfigKeepAliveEnabled(); bool SetRFConfigKeepAliveEnabled(bool enabled); } // namespace OpenShock::Config diff --git a/components/device_control/include/estop/EStopConfig.h b/components/device_control/include/estop/EStopConfig.h index f1ade06e9..7106199a6 100644 --- a/components/device_control/include/estop/EStopConfig.h +++ b/components/device_control/include/estop/EStopConfig.h @@ -36,12 +36,10 @@ namespace OpenShock::Config { [[nodiscard]] flatbuffers::Offset ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const; }; - Section& EStopSection(); - - [[nodiscard]] std::optional GetEStopConfig(); + [[nodiscard]] EStopConfig GetEStopConfig(); bool SetEStopConfig(const EStopConfig& config); - [[nodiscard]] std::optional GetEStopEnabled(); + [[nodiscard]] bool GetEStopEnabled(); bool SetEStopEnabled(bool enabled); - [[nodiscard]] std::optional GetEStopGpioPin(); + [[nodiscard]] gpio_num_t GetEStopGpioPin(); bool SetEStopGpioPin(gpio_num_t gpioPin); } // namespace OpenShock::Config diff --git a/components/device_control/include/radio/RFConfig.h b/components/device_control/include/radio/RFConfig.h index 1100d764d..4610fcf65 100644 --- a/components/device_control/include/radio/RFConfig.h +++ b/components/device_control/include/radio/RFConfig.h @@ -35,12 +35,10 @@ namespace OpenShock::Config { }; /// @brief The section itself, for whole-value reads and read-modify-writes. - Section& RFSection(); - - [[nodiscard]] std::optional GetRFConfig(); + [[nodiscard]] RFConfig GetRFConfig(); bool SetRFConfig(const RFConfig& config); - [[nodiscard]] std::optional GetRFConfigTxPin(); + [[nodiscard]] gpio_num_t GetRFConfigTxPin(); bool SetRFConfigTxPin(gpio_num_t txPin); - [[nodiscard]] std::optional GetRFConfigKeepAliveEnabled(); + [[nodiscard]] bool GetRFConfigKeepAliveEnabled(); bool SetRFConfigKeepAliveEnabled(bool enabled); } // namespace OpenShock::Config diff --git a/components/device_control/src/CommandHandler.cpp b/components/device_control/src/CommandHandler.cpp index afcd7e259..c2fe1d58a 100644 --- a/components/device_control/src/CommandHandler.cpp +++ b/components/device_control/src/CommandHandler.cpp @@ -219,18 +219,14 @@ bool CommandHandler::Init() } initialized = true; - auto rfConfig = Config::GetRFConfig(); - if (!rfConfig) { - OS_LOGE(TAG, "Failed to get RF config"); - return false; - } + const Config::RFConfig rfConfig = Config::GetRFConfig(); // Runs through E-Stops too; the task itself sends nothing while e-stopped - if (rfConfig->keepAliveEnabled) { + if (rfConfig.keepAliveEnabled) { internalSetKeepAliveEnabled(true); } - gpio_num_t txPin = rfConfig->txPin; + gpio_num_t txPin = rfConfig.txPin; if (!OpenShock::IsValidOutputPin(txPin)) { const int8_t boardTxPin = Board::Get().rfTxGpio; if (!OpenShock::IsValidOutputPin(boardTxPin)) { @@ -270,11 +266,8 @@ SetGPIOResultCode CommandHandler::SetRfTxPin(gpio_num_t txPin) // Driving the E-Stop input as the RF output would defeat the kill switch. Checked against the configured pin even // while the E-Stop is disabled, so enabling it later can't collide either. - auto estopPin = Config::GetEStopGpioPin(); - if (!estopPin) { - return SetGPIOResultCode::InternalError; - } - if (*estopPin != GPIO_NUM_NC && txPin == *estopPin) { + const gpio_num_t estopPin = Config::GetEStopGpioPin(); + if (estopPin != GPIO_NUM_NC && txPin == estopPin) { return SetGPIOResultCode::PinInUse; } @@ -332,13 +325,7 @@ gpio_num_t CommandHandler::GetRfTxPin() return transmitter->GetTxPin(); } - auto txPin = Config::GetRFConfigTxPin(); - if (!txPin) { - OS_LOGE(TAG, "Failed to get RF TX pin from config"); - return static_cast(OPENSHOCK_GPIO_INVALID); - } - - return *txPin; + return Config::GetRFConfigTxPin(); } bool CommandHandler::HandleCommand(ShockerModelType model, uint16_t shockerId, ShockerCommandType type, uint8_t intensity, uint16_t durationMs) diff --git a/components/device_control/src/EStopManager.cpp b/components/device_control/src/EStopManager.cpp index e0238bd22..57744d2b4 100644 --- a/components/device_control/src/EStopManager.cpp +++ b/components/device_control/src/EStopManager.cpp @@ -162,13 +162,7 @@ static bool estopmgr_taskStart() } if (s_estopPin == GPIO_NUM_NC) { - auto configPin = OpenShock::Config::GetEStopGpioPin(); - if (!configPin) { - OS_LOGE(TAG, "Failed to get EStop pin from config"); - return false; - } - - gpio_num_t pin = *configPin; + gpio_num_t pin = OpenShock::Config::GetEStopGpioPin(); if (pin == GPIO_NUM_NC) { OS_LOGW(TAG, "No valid pin is defined, refusing to start task"); return false; @@ -216,19 +210,14 @@ bool EStopManager::Init() } s_estopInitialized = true; - auto cfg = OpenShock::Config::GetEStopConfig(); - if (!cfg) { - OS_LOGE(TAG, "Failed to get EStop pin from config"); - return false; - } - - if (!cfg->enabled) { + const Config::EStopConfig cfg = OpenShock::Config::GetEStopConfig(); + if (!cfg.enabled) { return true; } OpenShock::ScopedLock lock__(&s_estopMutex); - if (!estopmgr_configurePin(cfg->gpioPin)) { + if (!estopmgr_configurePin(cfg.gpioPin)) { return false; } diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp index d72265832..ea4e402cf 100644 --- a/components/device_control/src/estop/EStopConfig.cpp +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -22,11 +22,6 @@ static constexpr const char* kKeyGpio = "gpio"; static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "estop", .order = 6, .hubConfigField = Fbs::HubConfig::VT_ESTOP, .namespaces = kNamespaces}); -Section& OpenShock::Config::EStopSection() -{ - return s_section; -} - EStopConfig::EStopConfig() : enabled(OpenShock::IsValidInputPin(Board::Get().estopGpio)) , gpioPin(static_cast(Board::Get().estopGpio)) @@ -110,7 +105,7 @@ flatbuffers::Offset EStopConfig::ToFlatbuffers(flatbuffers::Fl return Fbs::CreateEStopConfig(builder, enabled, gpioPin); } -std::optional OpenShock::Config::GetEStopConfig() +EStopConfig OpenShock::Config::GetEStopConfig() { return s_section.Get(); } @@ -120,7 +115,7 @@ bool OpenShock::Config::SetEStopConfig(const EStopConfig& config) return s_section.Set(config); } -std::optional OpenShock::Config::GetEStopEnabled() +bool OpenShock::Config::GetEStopEnabled() { return s_section.Read([](const EStopConfig& estop) { return estop.enabled; }); } @@ -133,7 +128,7 @@ bool OpenShock::Config::SetEStopEnabled(bool enabled) }); } -std::optional OpenShock::Config::GetEStopGpioPin() +gpio_num_t OpenShock::Config::GetEStopGpioPin() { return s_section.Read([](const EStopConfig& estop) { return estop.gpioPin; }); } diff --git a/components/device_control/src/radio/RFConfig.cpp b/components/device_control/src/radio/RFConfig.cpp index 0eb8a9439..33468d5f5 100644 --- a/components/device_control/src/radio/RFConfig.cpp +++ b/components/device_control/src/radio/RFConfig.cpp @@ -21,11 +21,6 @@ static constexpr const char* kKeyKeepAlive = "keepalive"; static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "rf", .order = 0, .hubConfigField = Fbs::HubConfig::VT_RF, .namespaces = kNamespaces}); -Section& OpenShock::Config::RFSection() -{ - return s_section; -} - RFConfig::RFConfig() : txPin(static_cast(Board::Get().rfTxGpio)) , keepAliveEnabled(true) @@ -106,7 +101,7 @@ flatbuffers::Offset RFConfig::ToFlatbuffers(flatbuffers::FlatBuff return rfBuilder.Finish(); } -std::optional OpenShock::Config::GetRFConfig() +RFConfig OpenShock::Config::GetRFConfig() { return s_section.Get(); } @@ -116,7 +111,7 @@ bool OpenShock::Config::SetRFConfig(const RFConfig& config) return s_section.Set(config); } -std::optional OpenShock::Config::GetRFConfigTxPin() +gpio_num_t OpenShock::Config::GetRFConfigTxPin() { return s_section.Read([](const RFConfig& rf) { return rf.txPin; }); } @@ -129,7 +124,7 @@ bool OpenShock::Config::SetRFConfigTxPin(gpio_num_t txPin) }); } -std::optional OpenShock::Config::GetRFConfigKeepAliveEnabled() +bool OpenShock::Config::GetRFConfigKeepAliveEnabled() { return s_section.Read([](const RFConfig& rf) { return rf.keepAliveEnabled; }); } diff --git a/components/ota/src/OtaUpdateManager.cpp b/components/ota/src/OtaUpdateManager.cpp index 3ca9e91f1..8b6702e85 100644 --- a/components/ota/src/OtaUpdateManager.cpp +++ b/components/ota/src/OtaUpdateManager.cpp @@ -154,13 +154,7 @@ static void otaum_evh_ipevent(void* event_handler_arg, esp_event_base_t event_ba static bool otaum_send_progress_msg(Serialization::Types::OtaUpdateProgressTask task, float progress) { - auto updateId = Config::GetOtaUpdateId(); - if (!updateId) { - OS_LOGE(TAG, "Failed to get OTA update ID"); - return false; - } - - if (!Serialization::Gateway::SerializeOtaUpdateProgressMessage(*updateId, task, progress, GatewayConnectionManager::SendMessageBIN)) { + if (!Serialization::Gateway::SerializeOtaUpdateProgressMessage(Config::GetOtaUpdateId(), task, progress, GatewayConnectionManager::SendMessageBIN)) { OS_LOGE(TAG, "Failed to send OTA install progress message"); return false; } @@ -169,13 +163,7 @@ static bool otaum_send_progress_msg(Serialization::Types::OtaUpdateProgressTask } static bool _sendFailureMessage(std::string_view message, bool fatal = false) { - auto updateId = Config::GetOtaUpdateId(); - if (!updateId) { - OS_LOGE(TAG, "Failed to get OTA update ID"); - return false; - } - - if (!Serialization::Gateway::SerializeOtaUpdateFailedMessage(*updateId, message, fatal, GatewayConnectionManager::SendMessageBIN)) { + if (!Serialization::Gateway::SerializeOtaUpdateFailedMessage(Config::GetOtaUpdateId(), message, fatal, GatewayConnectionManager::SendMessageBIN)) { OS_LOGE(TAG, "Failed to send OTA install failed message"); return false; } @@ -372,12 +360,7 @@ static void otaum_updatetask(void* arg) int64_t now = OpenShock::millis(); - auto maybeConfig = Config::GetOtaUpdateConfig(); - if (!maybeConfig) { - OS_LOGE(TAG, "Failed to get OTA update config"); - continue; - } - const Config::OtaUpdateConfig& config = *maybeConfig; + const Config::OtaUpdateConfig config = Config::GetOtaUpdateConfig(); if (!config.isEnabled) { OS_LOGD(TAG, "OTA updates are disabled, skipping update check"); @@ -597,18 +580,12 @@ static const char* otaum_channel_name(OpenShock::OtaUpdateChannel channel) /// so the CDN host no longer has to be known by the hub. [[nodiscard]] static std::optional otaum_try_get_repo_domain() { - auto config = Config::GetOtaUpdateConfig(); - if (!config) { - OS_LOGE(TAG, "Failed to get OTA update config"); - return std::nullopt; - } - - if (config->cdnDomain.empty()) { + std::string domain = Config::GetOtaUpdateConfig().cdnDomain; + if (domain.empty()) { OS_LOGE(TAG, "OTA repository domain is not configured"); return std::nullopt; } - - return std::move(config->cdnDomain); + return domain; } /// @brief Issues a GET against the repository API and parses the body as JSON. @@ -673,12 +650,7 @@ bool OtaUpdateManager::Init() _otaImageState = imageState; OS_LOGD(TAG, "Fetching previous update step"); - auto maybeUpdateStep = Config::GetOtaUpdateStep(); - if (!maybeUpdateStep) { - OS_LOGE(TAG, "Failed to get OTA update step"); - return false; - } - OtaUpdateStep updateStep = *maybeUpdateStep; + const OtaUpdateStep updateStep = Config::GetOtaUpdateStep(); // Infer boot type from update step. _bootType = OpenShock::InferFirmwareBootType(updateStep); diff --git a/components/serial_console/include/serial_console/SerialInputConfig.h b/components/serial_console/include/serial_console/SerialInputConfig.h index 08189bb02..1c814ddd1 100644 --- a/components/serial_console/include/serial_console/SerialInputConfig.h +++ b/components/serial_console/include/serial_console/SerialInputConfig.h @@ -33,10 +33,8 @@ namespace OpenShock::Config { void ToJSON(json_gen_str_t* gen, const char* name, bool withSensitiveData) const; }; - Section& SerialInputSection(); - - [[nodiscard]] std::optional GetSerialInputConfig(); + [[nodiscard]] SerialInputConfig GetSerialInputConfig(); bool SetSerialInputConfig(const SerialInputConfig& config); - [[nodiscard]] std::optional GetSerialInputConfigEchoEnabled(); + [[nodiscard]] bool GetSerialInputConfigEchoEnabled(); bool SetSerialInputConfigEchoEnabled(bool enabled); } // namespace OpenShock::Config diff --git a/components/serial_console/include/serial_console/SerialInputHandler.h b/components/serial_console/include/serial_console/SerialInputHandler.h index 153b096e7..2f44c526f 100644 --- a/components/serial_console/include/serial_console/SerialInputHandler.h +++ b/components/serial_console/include/serial_console/SerialInputHandler.h @@ -20,9 +20,6 @@ namespace OpenShock::SerialInputHandler { [[nodiscard]] bool Init(); - bool SerialEchoEnabled(); - void SetSerialEchoEnabled(bool enabled); - void PrintWelcomeHeader(); void PrintVersionInfo(); diff --git a/components/serial_console/include/serial_console/command_handlers/common.h b/components/serial_console/include/serial_console/command_handlers/common.h index 06f479b18..a1ca0e838 100644 --- a/components/serial_console/include/serial_console/command_handlers/common.h +++ b/components/serial_console/include/serial_console/command_handlers/common.h @@ -12,3 +12,10 @@ #define SERPR_ERROR(format, ...) SERPR_SYS("Error|" format, ##__VA_ARGS__) using namespace std::string_view_literals; + +namespace OpenShock::SerialCmds { + /// @brief The tail of every handler that saved a setting: Success or Error for the save's result. + void ReportSaved(bool saved); + /// @brief ReportSaved for a setting the hub has to restart to apply: a successful save restarts the hub. + void ReportSavedAndRestart(bool saved); +} // namespace OpenShock::SerialCmds diff --git a/components/serial_console/src/SerialInputConfig.cpp b/components/serial_console/src/SerialInputConfig.cpp index 9bb5a0d4c..5d8ce017a 100644 --- a/components/serial_console/src/SerialInputConfig.cpp +++ b/components/serial_console/src/SerialInputConfig.cpp @@ -19,11 +19,6 @@ static constexpr const char* kKeyEcho = "echo"; static constexpr const char* kNamespaces[] = {kNamespace}; static Section s_section({.name = "serialInput", .order = 4, .hubConfigField = Fbs::HubConfig::VT_SERIAL_INPUT, .namespaces = kNamespaces}); -Section& OpenShock::Config::SerialInputSection() -{ - return s_section; -} - SerialInputConfig::SerialInputConfig() : echoEnabled(true) { @@ -77,7 +72,7 @@ bool SerialInputConfig::SaveNvs(const char* partition, const SerialInputConfig* return Nvs::Saver(*handle, *this, previous).Set(kKeyEcho, &SerialInputConfig::echoEnabled).Commit(); } -std::optional OpenShock::Config::GetSerialInputConfig() +SerialInputConfig OpenShock::Config::GetSerialInputConfig() { return s_section.Get(); } @@ -87,7 +82,7 @@ bool OpenShock::Config::SetSerialInputConfig(const SerialInputConfig& config) return s_section.Set(config); } -std::optional OpenShock::Config::GetSerialInputConfigEchoEnabled() +bool OpenShock::Config::GetSerialInputConfigEchoEnabled() { return s_section.Read([](const SerialInputConfig& serial) { return serial.echoEnabled; }); } diff --git a/components/serial_console/src/SerialInputHandler.cpp b/components/serial_console/src/SerialInputHandler.cpp index 957cba11d..6b8b52c9f 100644 --- a/components/serial_console/src/SerialInputHandler.cpp +++ b/components/serial_console/src/SerialInputHandler.cpp @@ -58,7 +58,6 @@ using namespace OpenShock; const int64_t PASTE_INTERVAL_THRESHOLD_MS = 20; -static bool s_echoEnabled = true; static std::vector s_commandGroups; static std::unordered_map s_commandHandlers; @@ -513,7 +512,7 @@ static void echoHandleSerialInput(std::string_view buffer, bool hasData) static bool hasChanges = false; // If serial echo is disabled, don't do anything past this point - if (!s_echoEnabled) { + if (!Config::GetSerialInputConfigEchoEnabled()) { return; } @@ -549,7 +548,7 @@ static void processSerialLine(std::string_view line) // If it's not automated, we can echo the command if echo is enabled if (isAutomated) { line = line.substr(1); - } else if (s_echoEnabled) { + } else if (Config::GetSerialInputConfigEchoEnabled()) { echoBuffer(line); OS_SERIAL_PRINTLN(); } @@ -690,13 +689,6 @@ bool SerialInputHandler::Init() SerialInputHandler::PrintVersionInfo(); OS_SERIAL_PRINTLN(); - auto echoEnabled = Config::GetSerialInputConfigEchoEnabled(); - if (!echoEnabled) { - OS_LOGE(TAG, "Failed to get serial echo status from config"); - return false; - } - s_echoEnabled = *echoEnabled; - if (TaskUtils::TaskCreateExpensive(serialRxTask, "SerialRX", CONFIG_OPENSHOCK_SERIAL_RX_TASK_STACK_SIZE, nullptr, 1, nullptr) != pdPASS) { // TODO: Profile stack size OS_LOGE(TAG, "Failed to create serial RX task"); return false; @@ -704,15 +696,6 @@ bool SerialInputHandler::Init() return true; } -bool SerialInputHandler::SerialEchoEnabled() -{ - return s_echoEnabled; -} - -void SerialInputHandler::SetSerialEchoEnabled(bool enabled) -{ - s_echoEnabled = enabled; -} void SerialInputHandler::Print(std::string_view str) { diff --git a/components/serial_console/src/command_handlers/CommandEntry.cpp b/components/serial_console/src/command_handlers/CommandEntry.cpp index 7a8822661..668c29120 100644 --- a/components/serial_console/src/command_handlers/CommandEntry.cpp +++ b/components/serial_console/src/command_handlers/CommandEntry.cpp @@ -1,5 +1,9 @@ #include "serial_console/command_handlers/CommandEntry.h" +#include "serial_console/command_handlers/common.h" + +#include + using namespace OpenShock::SerialCmds; CommandEntry::CommandEntry(std::string_view description, CommandHandler commandHandler) noexcept @@ -37,3 +41,22 @@ CommandEntry& CommandGroup::addCommand(std::string_view name, std::string_view d m_commands.emplace_back(name, description, commandHandler); return m_commands.back(); } + +void OpenShock::SerialCmds::ReportSaved(bool saved) +{ + if (saved) { + SERPR_SUCCESS("Saved config"); + } else { + SERPR_ERROR("Failed to save config"); + } +} + +void OpenShock::SerialCmds::ReportSavedAndRestart(bool saved) +{ + if (!saved) { + SERPR_ERROR("Failed to save config"); + return; + } + SERPR_SUCCESS("Saved config, restarting..."); + esp_restart(); +} diff --git a/components/serial_console/src/command_handlers/authtoken.cpp b/components/serial_console/src/command_handlers/authtoken.cpp index c2e2e4ee8..dcb4b95f1 100644 --- a/components/serial_console/src/command_handlers/authtoken.cpp +++ b/components/serial_console/src/command_handlers/authtoken.cpp @@ -11,14 +11,7 @@ static void handleAuthtokenCommand(std::string_view arg, bool isAutomated) { if (arg.empty()) { - auto authToken = OpenShock::Config::GetBackendAuthToken(); - if (!authToken) { - SERPR_ERROR("Failed to get auth token from config"); - return; - } - - // Get auth token - SERPR_RESPONSE("AuthToken|%s", authToken->c_str()); + SERPR_RESPONSE("AuthToken|%s", OpenShock::Config::GetBackendAuthToken().c_str()); return; } @@ -30,13 +23,7 @@ static void handleAuthtokenCommand(std::string_view arg, bool isAutomated) // If we have some other kind of request fault just set it anyway, we probably arent connected to a network - bool result = OpenShock::GatewayConnectionManager::SetAuthToken(std::string(arg)); - - if (result) { - SERPR_SUCCESS("Saved config"); - } else { - SERPR_ERROR("Failed to save config"); - } + OpenShock::SerialCmds::ReportSaved(OpenShock::GatewayConnectionManager::SetAuthToken(std::string(arg))); } OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::AuthTokenHandler() diff --git a/components/serial_console/src/command_handlers/domain.cpp b/components/serial_console/src/command_handlers/domain.cpp index fdc45c6c8..48983b15c 100644 --- a/components/serial_console/src/command_handlers/domain.cpp +++ b/components/serial_console/src/command_handlers/domain.cpp @@ -6,8 +6,6 @@ #include "http/HTTPRequestManager.h" #include "serialization/JsonAPI.h" -#include - #include #include @@ -16,14 +14,7 @@ const char* const TAG = "SerialCmds::CommandHandlers::Domain"; static void handleDomainCommand(std::string_view arg, bool isAutomated) { if (arg.empty()) { - auto domain = OpenShock::Config::GetBackendDomain(); - if (!domain) { - SERPR_ERROR("Failed to get domain from config"); - return; - } - - // Get domain - SERPR_RESPONSE("Domain|%s", domain->c_str()); + SERPR_RESPONSE("Domain|%s", OpenShock::Config::GetBackendDomain().c_str()); return; } @@ -51,17 +42,8 @@ static void handleDomainCommand(std::string_view arg, bool isAutomated) OS_LOGI(TAG, "Successfully connected to \"%.*s\", version: %s, commit: %s, current time: %s", static_cast(arg.length()), arg.data(), resp.data.version.c_str(), resp.data.commit.c_str(), resp.data.currentTime.c_str()); - bool result = OpenShock::Config::SetBackendDomain(std::string(arg)); - - if (!result) { - SERPR_ERROR("Failed to save config"); - return; - } - - SERPR_SUCCESS("Saved config, restarting..."); - // Restart to use the new domain - esp_restart(); + OpenShock::SerialCmds::ReportSavedAndRestart(OpenShock::Config::SetBackendDomain(std::string(arg))); } OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::DomainHandler() diff --git a/components/serial_console/src/command_handlers/echo.cpp b/components/serial_console/src/command_handlers/echo.cpp index 30ef39bc6..32bd36c81 100644 --- a/components/serial_console/src/command_handlers/echo.cpp +++ b/components/serial_console/src/command_handlers/echo.cpp @@ -2,8 +2,6 @@ #include "serial_console/command_handlers/common.h" -#include "serial_console/SerialInputHandler.h" - #include "Convert.h" #include "serial_console/SerialInputConfig.h" #include "StringHelpers.h" @@ -11,8 +9,7 @@ static void handleSerialEchoCommand(std::string_view arg, bool isAutomated) { if (arg.empty()) { - // Get current serial echo status - SERPR_RESPONSE("SerialEcho|%s", OpenShock::SerialInputHandler::SerialEchoEnabled() ? "true" : "false"); + SERPR_RESPONSE("SerialEcho|%s", OpenShock::Config::GetSerialInputConfigEchoEnabled() ? "true" : "false"); return; } @@ -22,14 +19,7 @@ static void handleSerialEchoCommand(std::string_view arg, bool isAutomated) return; } - bool result = OpenShock::Config::SetSerialInputConfigEchoEnabled(*enabled); - OpenShock::SerialInputHandler::SetSerialEchoEnabled(*enabled); - - if (result) { - SERPR_SUCCESS("Saved config"); - } else { - SERPR_ERROR("Failed to save config"); - } + OpenShock::SerialCmds::ReportSaved(OpenShock::Config::SetSerialInputConfigEchoEnabled(*enabled)); } OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::EchoHandler() diff --git a/components/serial_console/src/command_handlers/estop.cpp b/components/serial_console/src/command_handlers/estop.cpp index 87fcd7c00..6721df5dd 100644 --- a/components/serial_console/src/command_handlers/estop.cpp +++ b/components/serial_console/src/command_handlers/estop.cpp @@ -9,14 +9,7 @@ static void handleEStopEnabledCommand(std::string_view arg, bool isAutomated) { if (arg.empty()) { - auto configEnabled = OpenShock::Config::GetEStopEnabled(); - if (!configEnabled) { - SERPR_ERROR("Failed to get EStop enabled from config"); - return; - } - - // Get EStop enabled - SERPR_RESPONSE("EStopEnabled|%s", *configEnabled ? "true" : "false"); + SERPR_RESPONSE("EStopEnabled|%s", OpenShock::Config::GetEStopEnabled() ? "true" : "false"); return; } @@ -37,14 +30,7 @@ static void handleEStopEnabledCommand(std::string_view arg, bool isAutomated) static void handleEStopPinCommand(std::string_view arg, bool isAutomated) { if (arg.empty()) { - auto configPin = OpenShock::Config::GetEStopGpioPin(); - if (!configPin) { - SERPR_ERROR("Failed to get EStop pin from config"); - return; - } - - // Get EStop pin - SERPR_RESPONSE("EStopPin|%hhi", static_cast(*configPin)); + SERPR_RESPONSE("EStopPin|%hhi", static_cast(OpenShock::Config::GetEStopGpioPin())); return; } diff --git a/components/serial_console/src/command_handlers/hostname.cpp b/components/serial_console/src/command_handlers/hostname.cpp index 21969f9ef..c471d0393 100644 --- a/components/serial_console/src/command_handlers/hostname.cpp +++ b/components/serial_console/src/command_handlers/hostname.cpp @@ -5,8 +5,6 @@ #include "util/HostnameUtils.h" #include "wifi/WiFiConfig.h" -#include - #include const char* const TAG = "SerialCmds::CommandHandlers::Hostname"; @@ -14,13 +12,7 @@ const char* const TAG = "SerialCmds::CommandHandlers::Hostname"; static void handleHostnameCommand(std::string_view arg, bool isAutomated) { if (arg.empty()) { - auto hostname = OpenShock::Config::GetWiFiHostname(); - if (!hostname) { - SERPR_ERROR("Failed to get hostname from config"); - return; - } - // Get hostname - SERPR_RESPONSE("Hostname|%s", hostname->c_str()); + SERPR_RESPONSE("Hostname|%s", OpenShock::Config::GetWiFiHostname().c_str()); return; } @@ -29,13 +21,7 @@ static void handleHostnameCommand(std::string_view arg, bool isAutomated) return; } - bool result = OpenShock::Config::SetWiFiHostname(std::string(arg)); - if (result) { - SERPR_SUCCESS("Saved config, restarting..."); - esp_restart(); - } else { - SERPR_ERROR("Failed to save config"); - } + OpenShock::SerialCmds::ReportSavedAndRestart(OpenShock::Config::SetWiFiHostname(std::string(arg))); } OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::HostnameHandler() diff --git a/components/serial_console/src/command_handlers/jsonconfig.cpp b/components/serial_console/src/command_handlers/jsonconfig.cpp index 4e8d357c8..ed8b84a43 100644 --- a/components/serial_console/src/command_handlers/jsonconfig.cpp +++ b/components/serial_console/src/command_handlers/jsonconfig.cpp @@ -2,8 +2,6 @@ #include "config/Store.h" -#include - static void handleJsonConfigCommand(std::string_view arg, bool isAutomated) { if (arg.empty()) { @@ -14,14 +12,7 @@ static void handleJsonConfigCommand(std::string_view arg, bool isAutomated) return; } - if (!OpenShock::Config::SaveFromJSON(arg)) { - SERPR_ERROR("Failed to save config"); - return; - } - - SERPR_SUCCESS("Saved config, restarting..."); - - esp_restart(); + OpenShock::SerialCmds::ReportSavedAndRestart(OpenShock::Config::SaveFromJSON(arg)); } OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::JsonConfigHandler() diff --git a/components/serial_console/src/command_handlers/keepalive.cpp b/components/serial_console/src/command_handlers/keepalive.cpp index a72ec2526..d932170bf 100644 --- a/components/serial_console/src/command_handlers/keepalive.cpp +++ b/components/serial_console/src/command_handlers/keepalive.cpp @@ -10,14 +10,7 @@ static void handleKeepAliveCommand(std::string_view arg, bool isAutomated) { if (arg.empty()) { - // Get keep alive status - auto configEnabled = OpenShock::Config::GetRFConfigKeepAliveEnabled(); - if (!configEnabled) { - SERPR_ERROR("Failed to get keep-alive status from config"); - return; - } - - SERPR_RESPONSE("KeepAlive|%s", *configEnabled ? "true" : "false"); + SERPR_RESPONSE("KeepAlive|%s", OpenShock::Config::GetRFConfigKeepAliveEnabled() ? "true" : "false"); return; } @@ -27,13 +20,7 @@ static void handleKeepAliveCommand(std::string_view arg, bool isAutomated) return; } - bool result = OpenShock::CommandHandler::SetKeepAliveEnabled(*keepAliveEnabled); - - if (result) { - SERPR_SUCCESS("Saved config"); - } else { - SERPR_ERROR("Failed to save config"); - } + OpenShock::SerialCmds::ReportSaved(OpenShock::CommandHandler::SetKeepAliveEnabled(*keepAliveEnabled)); } OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::KeepAliveHandler() diff --git a/components/serial_console/src/command_handlers/networks.cpp b/components/serial_console/src/command_handlers/networks.cpp index c3a356c4b..52eceda5b 100644 --- a/components/serial_console/src/command_handlers/networks.cpp +++ b/components/serial_console/src/command_handlers/networks.cpp @@ -6,7 +6,6 @@ #include "wifi/WiFiManager.h" #include "json/Json.h" -#include #include const char* const TAG = "SerialCmds::CommandHandlers::Networks"; @@ -18,10 +17,7 @@ static void handleNetworksCommand(std::string_view arg, bool isAutomated) json_gen_str_t* gen = writer.gen(); json_gen_start_array(gen); - if (!OpenShock::Config::GetWiFiCredentials(gen, true)) { - SERPR_ERROR("Failed to get WiFi credentials from config"); - return; - } + OpenShock::Config::GetWiFiCredentials(gen, true); json_gen_end_array(gen); std::string out = writer.finish(); @@ -42,46 +38,24 @@ static void handleNetworksCommand(std::string_view arg, bool isAutomated) return; } + // A network without an id (0) gets the lowest free one from the config section itself std::vector creds; - const int count = root.count(); - std::bitset<256> usedIds; for (int i = 0; i < count; ++i) { OpenShock::Config::WiFiCredentials cred; - if (!cred.FromJSON(root.at(i))) { SERPR_ERROR("Failed to parse network"); return; } - - usedIds[cred.id] = true; + OS_LOGI(TAG, "Adding network \"%s\" to config", cred.ssid.c_str()); creds.push_back(std::move(cred)); } - // Give networks without an ID the lowest IDs not already taken by the ones that have one - uint16_t nextId = 1; - for (auto& cred : creds) { - if (cred.id == 0) { - while (nextId < 256 && usedIds[nextId]) ++nextId; - if (nextId >= 256) { - SERPR_ERROR("Too many networks"); - return; - } - cred.id = static_cast(nextId); - usedIds[nextId] = true; - } - - OS_LOGI(TAG, "Adding network \"%s\" to config, id=%u", cred.ssid.c_str(), cred.id); - } - - if (!OpenShock::Config::SetWiFiCredentials(creds)) { - SERPR_ERROR("Failed to save config"); - return; + const bool saved = OpenShock::Config::SetWiFiCredentials(creds); + OpenShock::SerialCmds::ReportSaved(saved); + if (saved) { + OpenShock::WiFiManager::RefreshNetworkCredentials(); } - - SERPR_SUCCESS("Saved config"); - - OpenShock::WiFiManager::RefreshNetworkCredentials(); } OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::NetworksHandler() diff --git a/components/serial_console/src/command_handlers/rawconfig.cpp b/components/serial_console/src/command_handlers/rawconfig.cpp index 353dc4bd3..d1dd6e02c 100644 --- a/components/serial_console/src/command_handlers/rawconfig.cpp +++ b/components/serial_console/src/command_handlers/rawconfig.cpp @@ -4,8 +4,6 @@ #include "config/Wire.h" #include "TinyVec.h" -#include - #include static void handleRawConfigCommand(std::string_view arg, bool isAutomated) @@ -35,14 +33,7 @@ static void handleRawConfigCommand(std::string_view arg, bool isAutomated) return; } - if (!OpenShock::Config::SetRaw(buffer.data(), buffer.size())) { - SERPR_ERROR("Failed to save config"); - return; - } - - SERPR_SUCCESS("Saved config, restarting..."); - - esp_restart(); + OpenShock::SerialCmds::ReportSavedAndRestart(OpenShock::Config::SetRaw(buffer.data(), buffer.size())); } OpenShock::SerialCmds::CommandGroup OpenShock::SerialCmds::CommandHandlers::RawConfigHandler() diff --git a/components/serial_console/src/command_handlers/rftxpin.cpp b/components/serial_console/src/command_handlers/rftxpin.cpp index 6d5e1744a..043ced1eb 100644 --- a/components/serial_console/src/command_handlers/rftxpin.cpp +++ b/components/serial_console/src/command_handlers/rftxpin.cpp @@ -10,14 +10,7 @@ static void handleRfTxPinCommand(std::string_view arg, bool isAutomated) { if (arg.empty()) { - auto configPin = OpenShock::Config::GetRFConfigTxPin(); - if (!configPin) { - SERPR_ERROR("Failed to get RF TX pin from config"); - return; - } - - // Get rmt pin - SERPR_RESPONSE("RmtPin|%hhi", static_cast(*configPin)); + SERPR_RESPONSE("RmtPin|%hhi", static_cast(OpenShock::Config::GetRFConfigTxPin())); return; } From e61e9c8ac6b812cd85bedf2c07cf4a20f6269836 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 06:46:42 +0100 Subject: [PATCH 16/23] docs(config): note the shared parse helpers and the plain-value accessors --- .changes/config-nvs.md | 3 ++- docs/STORAGE.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changes/config-nvs.md b/.changes/config-nvs.md index c0fcf65c1..988cadbc8 100644 --- a/.changes/config-nvs.md +++ b/.changes/config-nvs.md @@ -16,7 +16,8 @@ The hub keeps its settings in the chip's NVS key-value store instead of a single - The store is marked filled only once every section reads back from flash as written; a first fill cut short is redone on the next boot - A store written by newer firmware is loaded instead of reset, and a `cfg` partition in a newer NVS format is never erased - A section whose NVS namespace fails to open is a failed load, not a load of the defaults: the defaults are only taken when the namespace was never written. The store likewise never fills or cleans up when its own marker cannot be read -- A boolean field of the wrong type in a `jsonconfig set` document rejects the document instead of storing the default +- A boolean field of the wrong type in a `jsonconfig set` document rejects the document instead of storing the default, in every section +- The config accessors return the value itself (a section always has one); the WiFi section hands a network saved without an id the lowest free one on every path, so `networks` over serial no longer allocates ids itself - An OTA check interval of zero and a hostname esp_netif would refuse become their defaults on every path (NVS, JSON and the setters), not only through the captive portal - `Config::MigrateToCfgPartition` retypes the `config` partition as `cfg`; nothing calls it yet diff --git a/docs/STORAGE.md b/docs/STORAGE.md index db2e39148..f9777a79d 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -44,7 +44,8 @@ most 15 characters and are permanent once hubs hold them: renaming one is a migr reads as its default and an unknown key is ignored, so adding or removing a field never changes the schema. To add a section, define a value type whose default constructor is the defaults, with `Normalize` (its validation, -run on every value the section takes in), `LoadNvs`, `SaveNvs`, `FromJSON`, `ToJSON` and `ToFlatbuffers`, and one +run on every value the section takes in), `LoadNvs`, `SaveNvs`, `FromJSON` (opened with `Parse::ObjectOrDefault`, +booleans through `Parse::JsonBool`), `ToJSON` and `ToFlatbuffers`, and one `Config::Section` for it in the module (the `SectionValue` concept in `config/Section.h` is the contract). It joins `jsonconfig`, `rawconfig`, the local protocol's Ready message and factory reset through the section registry. A load opens its namespace with `Nvs::Handle::OpenForLoad`: a namespace never written reads every key as its From 5fc3209fb6297c897e7b15fb57916b33532347db Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 06:47:07 +0100 Subject: [PATCH 17/23] fix(config): finish the two call sites the accessor change missed --- components/core/src/GatewayConnectionManager.cpp | 5 +++-- components/device_control/src/EStopManager.cpp | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/components/core/src/GatewayConnectionManager.cpp b/components/core/src/GatewayConnectionManager.cpp index 7f775e110..aa3559054 100644 --- a/components/core/src/GatewayConnectionManager.cpp +++ b/components/core/src/GatewayConnectionManager.cpp @@ -345,7 +345,8 @@ static void InitializeClient() return; // Checked here so an auth hold-off doesn't also count as a hub info failure and stack the two backoffs } - if (!FetchHubInfo(Config::GetBackendAuthToken())) { + const std::string authToken = Config::GetBackendAuthToken(); + if (!FetchHubInfo(authToken)) { // Back off on failure so an unreachable backend isn't polled on every update tick. s_hubInfoBackoff.backOff(OpenShock::millis()); return; @@ -363,7 +364,7 @@ static void InitializeClient() } s_flags.fetch_or(FLAG_LINKED, std::memory_order_relaxed); - s_wsClient = std::make_shared(*authToken); + s_wsClient = std::make_shared(authToken); } OS_LOGD(TAG, "Successfully verified auth token"); diff --git a/components/device_control/src/EStopManager.cpp b/components/device_control/src/EStopManager.cpp index 57744d2b4..89a8c28c0 100644 --- a/components/device_control/src/EStopManager.cpp +++ b/components/device_control/src/EStopManager.cpp @@ -221,7 +221,7 @@ bool EStopManager::Init() return false; } - s_estopPin = cfg->gpioPin; + s_estopPin = cfg.gpioPin; return estopmgr_taskStart(); } From 8946838e434daff89071469e60e447d25c95570e Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 06:59:32 +0100 Subject: [PATCH 18/23] style: format for the clang-format 21 the linter runs --- components/config/host_test/main/config_test_helpers.cpp | 6 ++---- components/config/include/config/Nvs.h | 2 +- components/core/src/OtaUpdateConfig.cpp | 6 ++---- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/components/config/host_test/main/config_test_helpers.cpp b/components/config/host_test/main/config_test_helpers.cpp index e0e871a4c..d76d19c7f 100644 --- a/components/config/host_test/main/config_test_helpers.cpp +++ b/components/config/host_test/main/config_test_helpers.cpp @@ -17,10 +17,8 @@ using namespace OpenShock; namespace ConfigTest { bool Full::SaveNvs(const char* partition) const { - if ( - !rf.SaveNvs(partition, nullptr) || !wifi.SaveNvs(partition, nullptr) || !captivePortal.SaveNvs(partition, nullptr) || !backend.SaveNvs(partition, nullptr) || !serialInput.SaveNvs(partition, nullptr) || !otaUpdate.SaveNvs(partition, nullptr) - || !estop.SaveNvs(partition, nullptr) - ) + if (!rf.SaveNvs(partition, nullptr) || !wifi.SaveNvs(partition, nullptr) || !captivePortal.SaveNvs(partition, nullptr) || !backend.SaveNvs(partition, nullptr) || !serialInput.SaveNvs(partition, nullptr) || !otaUpdate.SaveNvs(partition, nullptr) + || !estop.SaveNvs(partition, nullptr)) { return false; } diff --git a/components/config/include/config/Nvs.h b/components/config/include/config/Nvs.h index 08618da42..bec8e5e0c 100644 --- a/components/config/include/config/Nvs.h +++ b/components/config/include/config/Nvs.h @@ -129,7 +129,7 @@ namespace OpenShock::Config::Nvs { } template - Saver& Set(const char* key, M T::*member) + Saver& Set(const char* key, M T::* member) { if (!m_ok) { return *this; diff --git a/components/core/src/OtaUpdateConfig.cpp b/components/core/src/OtaUpdateConfig.cpp index ce789933b..fd9279d49 100644 --- a/components/core/src/OtaUpdateConfig.cpp +++ b/components/core/src/OtaUpdateConfig.cpp @@ -95,10 +95,8 @@ bool OtaUpdateConfig::FromJSON(JSON::JsonView json) } // Every boolean is strict (Parse::JsonBool); the numbers and strings take their default for a value of the wrong type - if ( - !Parse::JsonBool(isEnabled, json, "isEnabled", true, TAG) || !Parse::JsonBool(checkOnStartup, json, "checkOnStartup", false, TAG) || !Parse::JsonBool(checkPeriodically, json, "checkPeriodically", false, TAG) - || !Parse::JsonBool(allowBackendManagement, json, "allowBackendManagement", true, TAG) || !Parse::JsonBool(requireManualApproval, json, "requireManualApproval", false, TAG) - ) + if (!Parse::JsonBool(isEnabled, json, "isEnabled", true, TAG) || !Parse::JsonBool(checkOnStartup, json, "checkOnStartup", false, TAG) || !Parse::JsonBool(checkPeriodically, json, "checkPeriodically", false, TAG) + || !Parse::JsonBool(allowBackendManagement, json, "allowBackendManagement", true, TAG) || !Parse::JsonBool(requireManualApproval, json, "requireManualApproval", false, TAG)) { return false; } From 44ca1c9993629b0b155dda820f9832ba048ec75d Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 06:59:32 +0100 Subject: [PATCH 19/23] ci: key the ESP-IDF install cache on the runner image ubuntu-latest moved to a new Ubuntu release. The cached ESP-IDF install was built on the previous image, and its Python venv is tied to that image's Python, so every idf.py call on the new image died with "No module named 'rich_click'" before a single file compiled: every ci-build variant and every host test suite failed at once. The install cache key now carries the runner image name, so each image gets an install built on it. --- .github/actions/setup-esp-idf/action.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-esp-idf/action.yml b/.github/actions/setup-esp-idf/action.yml index e843c045d..8220a86cd 100644 --- a/.github/actions/setup-esp-idf/action.yml +++ b/.github/actions/setup-esp-idf/action.yml @@ -18,6 +18,13 @@ runs: shell: bash run: git submodule update --init --depth 1 components/flatbuffers/flatbuffers + # The install's Python venv is built on the runner image's Python, so an install from one image does not run on + # another (ubuntu-latest moving to a new release broke every job with "No module named 'rich_click'") + - name: Name the runner image + id: image + shell: bash + run: echo "name=${ImageOS:-unknown}" >> "$GITHUB_OUTPUT" + # No restore-keys, a partial match would layer the installer over a stale tree. Bump the trailing number to invalidate. - name: Restore ESP-IDF install id: idf-cache @@ -27,7 +34,7 @@ runs: /tmp/esp /opt/esp ~/.espressif - key: esp-idf-${{ runner.os }}-${{ inputs.idf-version }}-6 + key: esp-idf-${{ runner.os }}-${{ steps.image.outputs.name }}-${{ inputs.idf-version }}-6 - uses: espressif/install-esp-idf-action@8fc05d1470d5591417e7a3707a1f2bec178db4ae # v1 if: ${{ steps.idf-cache.outputs.cache-hit != 'true' }} @@ -91,7 +98,7 @@ runs: /tmp/esp /opt/esp ~/.espressif - key: esp-idf-${{ runner.os }}-${{ inputs.idf-version }}-6 + key: esp-idf-${{ runner.os }}-${{ steps.image.outputs.name }}-${{ inputs.idf-version }}-6 - name: Put ccache on PATH shell: bash From f6a8ead39617f2ac00f717dd70b140fbb2afe486 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 07:23:55 +0100 Subject: [PATCH 20/23] fix(config): finish the host test's value accesses, write the E-Stop pin before its enable flag Three test lines still dereferenced the credential list and the WiFi config as optionals and would not compile. EStopConfig::SaveNvs now writes the GPIO key before the enabled key: NVS commits key by key, so a power cut between the two leaves the old enable state on the new pin rather than the E-Stop enabled on the old one. STORAGE.md: the 1.x import needs a mounting partition and a valid file, else the defaults are written; the shared nvs is only cleaned up after a verified copy, a failed copy keeps the config there; the config migration leaves static0 alone whichever migration runs first. --- .../config/host_test/main/test_roundtrip.cpp | 10 +++++----- .../device_control/src/estop/EStopConfig.cpp | 4 +++- docs/STORAGE.md | 17 ++++++++++------- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index f32827622..6395e0225 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -110,8 +110,8 @@ TEST_CASE("Credentials are enumerated from their keys, never counted", "[config] TEST_ASSERT_EQUAL_size_t(3, creds.size()); TEST_ASSERT_EQUAL_UINT8(3, creds[0].id); TEST_ASSERT_EQUAL_UINT8(200, creds[1].id); - TEST_ASSERT_EQUAL_UINT8(255, (*creds)[2].id); - TEST_ASSERT_EQUAL_STRING("Max", (*creds)[2].ssid.c_str()); + TEST_ASSERT_EQUAL_UINT8(255, creds[2].id); + TEST_ASSERT_EQUAL_STRING("Max", creds[2].ssid.c_str()); } TEST_CASE("One credential that does not read is dropped; the others load and survive the next save", "[config][roundtrip]") @@ -140,7 +140,7 @@ TEST_CASE("One credential that does not read is dropped; the others load and sur TEST_ASSERT_EQUAL_size_t(3, creds.size()); TEST_ASSERT_EQUAL_UINT8(1, creds[0].id); TEST_ASSERT_EQUAL_STRING("Cafe", creds[1].ssid.c_str()); - TEST_ASSERT_EQUAL_UINT8(7, (*creds)[2].id); + TEST_ASSERT_EQUAL_UINT8(7, creds[2].id); } TEST_CASE("GetRaw writes fields that equal their schema default", "[config][roundtrip]") @@ -263,8 +263,8 @@ TEST_CASE("jsonconfig set decodes \\u escapes", "[config][roundtrip][json]") auto wifi = Config::GetWiFiConfig(); TEST_ASSERT_EQUAL_STRING("hub-1", wifi.hostname.c_str()); TEST_ASSERT_EQUAL_size_t(1, wifi.credentialsList.size()); - TEST_ASSERT_EQUAL_STRING("Caf\xc3\xa9 \xf0\x9f\x98\x80", wifi->credentialsList[0].ssid.c_str()); - TEST_ASSERT_EQUAL_STRING("a\tb", wifi->credentialsList[0].password.c_str()); + TEST_ASSERT_EQUAL_STRING("Caf\xc3\xa9 \xf0\x9f\x98\x80", wifi.credentialsList[0].ssid.c_str()); + TEST_ASSERT_EQUAL_STRING("a\tb", wifi.credentialsList[0].password.c_str()); } TEST_CASE("GetAsJSON without sensitive data omits secrets", "[config][roundtrip][json]") diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp index ea4e402cf..c77401c84 100644 --- a/components/device_control/src/estop/EStopConfig.cpp +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -72,7 +72,9 @@ bool EStopConfig::SaveNvs(const char* partition, const EStopConfig* previous) co if (!handle) { return false; } - return Nvs::Saver(*handle, *this, previous).Set(kKeyEnabled, &EStopConfig::enabled).Set(kKeyGpio, &EStopConfig::gpioPin).Commit(); + // The pin first: NVS commits key by key, so a power cut between the two leaves the old enable state on the new pin + // (disabled stays disabled; enabled moves to the pin that was asked for), never the E-Stop enabled on the old pin + return Nvs::Saver(*handle, *this, previous).Set(kKeyGpio, &EStopConfig::gpioPin).Set(kKeyEnabled, &EStopConfig::enabled).Commit(); } bool EStopConfig::FromJSON(JSON::JsonView json) diff --git a/docs/STORAGE.md b/docs/STORAGE.md index f9777a79d..fa52bd162 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -59,14 +59,16 @@ Where the store is: - On the 1.x table, which has no `cfg`, the same namespaces in the shared `nvs` partition. The first boot that finds `cfg` copies them over key by key, compares the copy, and deletes the shared one. - An empty store is filled once: from the 1.x littlefs config file (`/config` on the 1.x `config` partition, read - only, so a rollback to 1.x still finds it until the config partition migration below has run) when there is one, - else from every section's defaults. The store is marked filled only once every section reads back from flash as - written; a fill that failed part-way is redone on the next boot. + only, so a rollback to 1.x still finds it until the config partition migration below has run) when the partition + mounts and the file reads as a valid config, else from every section's defaults (a 1.x file that is missing, + unreadable or invalid is logged and the defaults are written). The store is marked filled only once every section + reads back from flash as written; a fill that failed part-way is redone on the next boot. - Without any usable NVS, the hub runs on defaults in RAM and logs it. The shared `nvs` belongs to ESP-IDF (PHY calibration cache) and may be erased at any time; nothing of OpenShock's is -kept there once `cfg` exists. Until then an erased shared `nvs` is an empty store: the hub imports the 1.x file again -and loses what changed since the update. The WiFi driver's own NVS store is compiled out (`CONFIG_ESP_WIFI_NVS_ENABLED=n`). +kept there once the copy into `cfg` has been made and verified. A copy that fails to write or to read back is undone +and the config stays in the shared `nvs`, to be copied again on the next boot. Until then an erased shared `nvs` is an +empty store: the hub imports the 1.x file again and loses what changed since the update. The WiFi driver's own NVS store is compiled out (`CONFIG_ESP_WIFI_NVS_ENABLED=n`). `sysinfo` on the serial console prints where the config is. ## The config partition migration @@ -101,8 +103,9 @@ which writes `staticfs` over its littlefs `static0` and aborts unless that then picks the file by the release's major version. On the 1.x table, which has no `www`, this firmware serves the portal from the littlefs `static0` and its OTA update -flashes `static0` and verifies it by mounting, as the 1.x firmware does. Nothing on a 1.x hub touches the partition -table until the www migration below runs. +flashes `static0` and verifies it by mounting, as the 1.x firmware does. That holds until the www migration below +runs, whichever of the two migrations goes first: the config migration above only retypes `config` as `cfg` and +leaves `static0` as it is. ## The www partition migration From f7e66bb80b470ac8852b61e7ec0f2f39ef240c6e Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 07:47:14 +0100 Subject: [PATCH 21/23] build: compile the firmware and the host tests as gnu++23, and let the lwip netdb mock build on glibc 2.42 Two host-test suites failed on the Ubuntu 26.04 runner for reasons outside this branch. common: TryStringSplit<4>("192.168.0.1", '.') came back empty. GCC's C++26 mode (GCC 14 to 17, bug 127126) folds a constexpr call with literal arguments wrongly when the function searches a string the way string_view::find does; strict constant evaluation and runtime calls are right, the folded call is not. ESP-IDF forces -std=gnu++26 on chip targets and takes it on linux as soon as the compiler accepts it, which GCC 15 is the first to do; the device toolchain is GCC 15.2 as well. cmake/cxx_standard.cmake appends -std=gnu++23 after ESP-IDF's flag (the last one wins) and is included after every project(): the firmware and all ten host tests. Reproduced and verified with GCC 15.2 at -Og, -O2 and -Os: wrong under gnu++26, right under gnu++23. config: glibc 2.42 includes from , so lwip's sockets.h redefined SIOCATMARK and -Werror failed the netdb mock that only exists for esp_wifi_types.h's dependency chain. That mock library builds without -Werror now. --- CMakeLists.txt | 1 + cmake/cxx_standard.cmake | 10 ++++++++++ components/common/host_test/CMakeLists.txt | 1 + components/config/host_test/CMakeLists.txt | 7 +++++++ components/crypto/host_test/CMakeLists.txt | 1 + components/device_control/host_test/CMakeLists.txt | 1 + components/dns_server/host_test/CMakeLists.txt | 1 + components/fs/host_test/CMakeLists.txt | 1 + components/osjson/host_test/CMakeLists.txt | 1 + components/partition_layout/host_test/CMakeLists.txt | 1 + components/protocols/host_test/CMakeLists.txt | 1 + components/www/host_test/CMakeLists.txt | 1 + 12 files changed, 27 insertions(+) create mode 100644 cmake/cxx_standard.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index abea69209..00d754846 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -140,6 +140,7 @@ if(_os_idf_activated) endif() project(firmware) +include(${CMAKE_CURRENT_LIST_DIR}/cmake/cxx_standard.cmake) # --- ccache by absolute path --------------------------------------------------- # idf.py configures with CCACHE_ENABLE, and ESP-IDF then launches compiles through a bare `ccache`, diff --git a/cmake/cxx_standard.cmake b/cmake/cxx_standard.cmake new file mode 100644 index 000000000..64f6b7c6c --- /dev/null +++ b/cmake/cxx_standard.cmake @@ -0,0 +1,10 @@ +# The C++ standard every OpenShock build compiles with. Included right after project() by the firmware's CMakeLists.txt +# and by every components/*/host_test/CMakeLists.txt, so the firmware and its host tests speak the same language. +# +# ESP-IDF appends -std=gnu++26 for chip targets and the newest -std the host compiler accepts for the linux target. +# GCC's C++26 mode is experimental and, from GCC 14 to 17, miscompiles a constexpr function that searches a string +# (string_view::find and anything on top of it) when the call is folded with literal arguments: the folded value +# disagrees with strict constant evaluation and with a runtime call (GCC bug 127126, -std=c++2c only). The device +# toolchain is GCC 15.2, so the firmware is as exposed as the common host test that first showed it. This flag comes +# after ESP-IDF's and the last -std on the command line wins. Drop it once the toolchain carries the fix. +idf_build_set_property(CXX_COMPILE_OPTIONS "-std=gnu++23" APPEND) diff --git a/components/common/host_test/CMakeLists.txt b/components/common/host_test/CMakeLists.txt index 31fb361a4..2904e7c19 100644 --- a/components/common/host_test/CMakeLists.txt +++ b/components/common/host_test/CMakeLists.txt @@ -13,3 +13,4 @@ list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(common_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) diff --git a/components/config/host_test/CMakeLists.txt b/components/config/host_test/CMakeLists.txt index 930a8f649..a68f064f8 100644 --- a/components/config/host_test/CMakeLists.txt +++ b/components/config/host_test/CMakeLists.txt @@ -19,3 +19,10 @@ list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(config_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) + +# glibc 2.42 (Ubuntu 26.04) pulls into , so SIOCATMARK is already defined when lwip's +# sockets.h defines it again under a guard that only checks SIOCSHIWAT. The netdb mock includes both, and -Werror +# makes the redefinition a build failure. The mock is only here for esp_wifi_types.h's dependency chain. +idf_component_get_property(_lwip_mock lwip COMPONENT_LIB) +target_compile_options(${_lwip_mock} PRIVATE -Wno-error) diff --git a/components/crypto/host_test/CMakeLists.txt b/components/crypto/host_test/CMakeLists.txt index b13d16ba8..fd9dc582d 100644 --- a/components/crypto/host_test/CMakeLists.txt +++ b/components/crypto/host_test/CMakeLists.txt @@ -12,3 +12,4 @@ list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(crypto_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) diff --git a/components/device_control/host_test/CMakeLists.txt b/components/device_control/host_test/CMakeLists.txt index 285a3c847..7655eaa8b 100644 --- a/components/device_control/host_test/CMakeLists.txt +++ b/components/device_control/host_test/CMakeLists.txt @@ -20,3 +20,4 @@ list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(device_control_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) diff --git a/components/dns_server/host_test/CMakeLists.txt b/components/dns_server/host_test/CMakeLists.txt index d27477a06..00a6b1908 100644 --- a/components/dns_server/host_test/CMakeLists.txt +++ b/components/dns_server/host_test/CMakeLists.txt @@ -13,3 +13,4 @@ list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(dns_server_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) diff --git a/components/fs/host_test/CMakeLists.txt b/components/fs/host_test/CMakeLists.txt index 9a06aada8..79e00ce0c 100644 --- a/components/fs/host_test/CMakeLists.txt +++ b/components/fs/host_test/CMakeLists.txt @@ -11,3 +11,4 @@ list(APPEND EXTRA_COMPONENT_DIRS "$ENV{IDF_PATH}/tools/mocks/freertos/") project(fs_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) diff --git a/components/osjson/host_test/CMakeLists.txt b/components/osjson/host_test/CMakeLists.txt index af0c84fab..bbf71a486 100644 --- a/components/osjson/host_test/CMakeLists.txt +++ b/components/osjson/host_test/CMakeLists.txt @@ -12,3 +12,4 @@ list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(osjson_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) diff --git a/components/partition_layout/host_test/CMakeLists.txt b/components/partition_layout/host_test/CMakeLists.txt index 033400958..2cb8b5bad 100644 --- a/components/partition_layout/host_test/CMakeLists.txt +++ b/components/partition_layout/host_test/CMakeLists.txt @@ -12,3 +12,4 @@ list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(partition_layout_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) diff --git a/components/protocols/host_test/CMakeLists.txt b/components/protocols/host_test/CMakeLists.txt index 7a0798656..c9490da4f 100644 --- a/components/protocols/host_test/CMakeLists.txt +++ b/components/protocols/host_test/CMakeLists.txt @@ -16,3 +16,4 @@ list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(protocols_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) diff --git a/components/www/host_test/CMakeLists.txt b/components/www/host_test/CMakeLists.txt index 154b21728..3d029a4da 100644 --- a/components/www/host_test/CMakeLists.txt +++ b/components/www/host_test/CMakeLists.txt @@ -12,3 +12,4 @@ list(APPEND EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/../../../test/host_support") project(www_host_test) +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/cxx_standard.cmake) From 2445ddc655d3ecf08838be62c57c3eaeb374dfe3 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 08:17:28 +0100 Subject: [PATCH 22/23] test(config): decode \u escapes in the jsonconfig test again, and run the host tests when cmake/ changes The "decodes \u escapes" test had been given literal UTF-8 in abd52732, so it no longer exercised the decoder it is named for. The escaped input is back: a BMP code point, a surrogate pair and an escaped ASCII hyphen, with the same UTF-8 assertions after the reload. The host-tests workflow now also triggers on cmake/**, where the shared C++ standard lives. --- .github/workflows/host-tests.yml | 1 + components/config/host_test/main/test_roundtrip.cpp | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/host-tests.yml b/.github/workflows/host-tests.yml index fe4ebfe45..3b847ec1a 100644 --- a/.github/workflows/host-tests.yml +++ b/.github/workflows/host-tests.yml @@ -10,6 +10,7 @@ on: - 'test/**' - 'partitions/**' - 'CMakeLists.txt' + - 'cmake/**' - 'sdkconfig.defaults' - 'dependencies.lock' - '.gitmodules' diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 6395e0225..50854491f 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -257,6 +257,7 @@ TEST_CASE("jsonconfig round trip keeps strings that need escaping", "[config][ro TEST_CASE("jsonconfig set decodes \\u escapes", "[config][roundtrip][json]") { InitDefault(); + // Escaped on purpose (a BMP code point, a surrogate pair, an escaped ASCII hyphen): the decoder is what is under test TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"wifi":{"hostname":"hub-1","credentials":[{"id":1,"ssid":"Café 😀","password":"a\tb"}]}})")); Reload(); // UTF-8 and control characters survive the store too From 4106bdb058962fd6dac2ededc5c0d9848f8bd21d Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Sat, 31 Oct 2026 08:18:01 +0100 Subject: [PATCH 23/23] test(config): the escaped fixture line itself, which the previous commit's edit tool had decoded back to UTF-8 --- components/config/host_test/main/test_roundtrip.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/config/host_test/main/test_roundtrip.cpp b/components/config/host_test/main/test_roundtrip.cpp index 50854491f..6bde677ac 100644 --- a/components/config/host_test/main/test_roundtrip.cpp +++ b/components/config/host_test/main/test_roundtrip.cpp @@ -258,7 +258,7 @@ TEST_CASE("jsonconfig set decodes \\u escapes", "[config][roundtrip][json]") { InitDefault(); // Escaped on purpose (a BMP code point, a surrogate pair, an escaped ASCII hyphen): the decoder is what is under test - TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"wifi":{"hostname":"hub-1","credentials":[{"id":1,"ssid":"Café 😀","password":"a\tb"}]}})")); + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"wifi":{"hostname":"hub\u002d1","credentials":[{"id":1,"ssid":"Caf\u00e9 \ud83d\ude00","password":"a\tb"}]}})")); Reload(); // UTF-8 and control characters survive the store too auto wifi = Config::GetWiFiConfig();