diff --git a/.changes/config-nvs.md b/.changes/config-nvs.md new file mode 100644 index 00000000..988cadbc --- /dev/null +++ b/.changes/config-nvs.md @@ -0,0 +1,26 @@ +--- +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 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, 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 + +## 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, 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/.github/actions/setup-esp-idf/action.yml b/.github/actions/setup-esp-idf/action.yml index e843c045..8220a86c 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 diff --git a/.github/workflows/host-tests.yml b/.github/workflows/host-tests.yml index fe4ebfe4..3b847ec1 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/CMakeLists.txt b/CMakeLists.txt index abea6920..00d75484 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 00000000..64f6b7c6 --- /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/captive_portal/CMakeLists.txt b/components/captive_portal/CMakeLists.txt index 738637b5..c4fd914b 100644 --- a/components/captive_portal/CMakeLists.txt +++ b/components/captive_portal/CMakeLists.txt @@ -9,6 +9,7 @@ # esp_http_server and friends do not leak into consumers. idf_component_register( SRCS "src/captiveportal/ApiHandlers.cpp" + "src/captiveportal/CaptivePortalConfig.cpp" "src/captiveportal/CaptivePortalInstance.cpp" "src/captiveportal/HttpUtils.cpp" "src/captiveportal/Manager.cpp" @@ -16,7 +17,8 @@ idf_component_register( "src/captiveportal/WebSocketEndpoint.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 www # DNSServer, LfsPartition (1.x layout), ContentTypes, PartitionUtils, RFC8908Handler, the www image 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 00000000..8a5de62d --- /dev/null +++ b/components/captive_portal/include/captiveportal/CaptivePortalConfig.h @@ -0,0 +1,38 @@ +#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(); // The defaults: the one place they are defined + CaptivePortalConfig(bool alwaysEnabled); + + bool alwaysEnabled; + + /// @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; + + /// @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; + }; + + [[nodiscard]] CaptivePortalConfig GetCaptivePortalConfig(); + bool SetCaptivePortalConfig(const CaptivePortalConfig& config); +} // namespace OpenShock::Config diff --git a/components/captive_portal/src/captiveportal/ApiHandlers.cpp b/components/captive_portal/src/captiveportal/ApiHandlers.cpp index a8cea7d4..229a51c8 100644 --- a/components/captive_portal/src/captiveportal/ApiHandlers.cpp +++ b/components/captive_portal/src/captiveportal/ApiHandlers.cpp @@ -11,12 +11,12 @@ const char* const TAG = "CaptivePortalApi"; #include "captiveportal/HttpUtils.h" #include "captiveportal/Manager.h" #include "CommandHandler.h" -#include "config/Config.h" #include "Convert.h" #include "enums/OtaUpdateChannel.h" #include "GatewayConnectionManager.h" #include "http/ContentTypes.h" #include "Logging.h" +#include "OtaUpdateConfig.h" #include "RateLimiter.h" #include "util/TaskUtils.h" #include "wifi/WiFiManager.h" diff --git a/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp new file mode 100644 index 00000000..d3bd4f2d --- /dev/null +++ b/components/captive_portal/src/captiveportal/CaptivePortalConfig.cpp @@ -0,0 +1,83 @@ +#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}); + +CaptivePortalConfig::CaptivePortalConfig() + : alwaysEnabled(false) +{ +} + +CaptivePortalConfig::CaptivePortalConfig(bool alwaysEnabled) + : alwaysEnabled(alwaysEnabled) +{ +} + +flatbuffers::Offset CaptivePortalConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + return Serialization::Configuration::CreateCaptivePortalConfig(builder, alwaysEnabled); +} + +bool CaptivePortalConfig::FromJSON(JSON::JsonView json) +{ + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; + } + + if (!Parse::JsonBool(alwaysEnabled, json, "alwaysEnabled", false, TAG)) { + return 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::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; + } + 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 Nvs::Saver(*handle, *this, previous).Set(kKeyAlways, &CaptivePortalConfig::alwaysEnabled).Commit(); +} + +CaptivePortalConfig 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/Manager.cpp b/components/captive_portal/src/captiveportal/Manager.cpp index 155b7966..7cf9986c 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 @@ -84,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() @@ -358,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/chipset/CMakeLists.txt b/components/chipset/CMakeLists.txt index 33203583..868d04bc 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 8587768c..69636601 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/common/host_test/CMakeLists.txt b/components/common/host_test/CMakeLists.txt index 31fb361a..2904e7c1 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/CMakeLists.txt b/components/config/CMakeLists.txt index 6a0efc4a..c7d289e6 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 261b4a33..a68f064f 100644 --- a/components/config/host_test/CMakeLists.txt +++ b/components/config/host_test/CMakeLists.txt @@ -7,16 +7,22 @@ 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/" "${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/config/host_test/main/CMakeLists.txt b/components/config/host_test/main/CMakeLists.txt index f4b5dc1c..bc807fff 100644 --- a/components/config/host_test/main/CMakeLists.txt +++ b/components/config/host_test/main/CMakeLists.txt @@ -1,51 +1,61 @@ -# 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 + # 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 979ae436..d76d19c7 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,36 @@ using namespace OpenShock; namespace ConfigTest { - Config::RootConfig MakeSampleConfig() + 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() { - Config::RootConfig config; + Full config; config.rf = Config::RFConfig(GPIO_NUM_25, false); @@ -30,69 +63,122 @@ 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)); + } + + void WipeStore() + { + 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(); } - bool InitFrom(const uint8_t* data, std::size_t size) + void InitDefault() + { + WipeStore(); + Config::Init(); + } + + bool InitFromLegacy(const uint8_t* data, std::size_t size) { - HostConfigFs::reset(); + 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() + bool NamespaceExists(const char* partition, const char* ns) { - // Every getter must succeed; unwraps the returned optional. - auto get = [](auto section) { - TEST_ASSERT_TRUE(section.has_value()); - return *std::move(section); - }; + return OpenShock::Config::Nvs::Handle::Open(partition, ns, false).has_value(); + } - Config::RootConfig 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()); + 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() + { + Full config; + 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; } @@ -105,7 +191,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 24a86152..db0636db 100644 --- a/components/config/host_test/main/config_test_helpers.h +++ b/components/config/host_test/main/config_test_helpers.h @@ -1,51 +1,135 @@ #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"; + } // 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; - // Every field differs from RootConfig's defaults. - OpenShock::Config::RootConfig MakeSampleConfig(); + // 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; + }; - // Serializes like Config.cpp's trySaveConfig (ToFlatbuffers + FinishHubConfigBuffer). - std::vector Serialize(const OpenShock::Config::RootConfig& config, bool withSensitiveData = true); + // Every field differs from the defaults. + Full MakeSampleConfig(); - // 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); + // 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); - // 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) + // 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); + + // Erases the `cfg` and shared `nvs` partitions and the fake 1.x file: a hub with nothing stored. + void WipeStore(); + + // 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 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 bytes currently stored in the config file (empty if there is none). - std::vector StoredFile(); + // 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 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 3c81bfce..00000000 --- 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 00000000..10ce1423 --- /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 e62a5aad..24531498 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,87 @@ 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 +230,124 @@ 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]") +TEST_CASE("A malformed JSON bssid does not pin a partial address", "[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; + InitDefault(); + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"wifi":{"credentials":[{"id":1,"ssid":"net","bssid":"123456789aZZ"}]}})")); - AssertConfigEqual(sample, Snapshot()); - TEST_ASSERT_TRUE(before == StoredFile()); + const Full loaded = Snapshot(); + TEST_ASSERT_EQUAL_size_t(1, loaded.wifi.credentialsList.size()); + TEST_ASSERT_FALSE(loaded.wifi.credentialsList[0].HasPinnedBSSID()); } -TEST_CASE("Adding WiFi credentials that cannot be stored changes nothing", "[config][corrupt]") +TEST_CASE("An unknown OTA channel in JSON falls back to the default, not the running value", "[config][corrupt][json]") { - const Config::RootConfig sample = MakeSampleConfig(); - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); + Full sample = MakeSampleConfig(); + sample.otaUpdate.updateChannel = OtaUpdateChannel::Develop; + InitWith(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; + TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"otaUpdate":{"updateChannel":"nightly","updateStep":42}})")); - AssertConfigEqual(sample, 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("Setters that cannot be stored leave the running config unchanged", "[config][corrupt]") +TEST_CASE("A boolean field of the wrong type rejects the document instead of storing the default", "[config][corrupt][json]") { - 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; - + 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 malformed JSON bssid does not pin a partial address", "[config][corrupt][json]") +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"({"wifi":{"credentials":[{"id":1,"ssid":"net","bssid":"123456789aZZ"}]}})")); - - const Config::RootConfig loaded = Snapshot(); - TEST_ASSERT_EQUAL_size_t(1, loaded.wifi.credentialsList.size()); - TEST_ASSERT_FALSE(loaded.wifi.credentialsList[0].HasPinnedBSSID()); + 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 unknown OTA channel in JSON falls back to the default, not the running value", "[config][corrupt][json]") +TEST_CASE("An invalid hostname is normalized to the default on every path", "[config][corrupt][json]") { - Config::RootConfig sample = MakeSampleConfig(); - sample.otaUpdate.updateChannel = OtaUpdateChannel::Develop; - TEST_ASSERT_TRUE(InitFrom(Serialize(sample))); - - TEST_ASSERT_TRUE(Config::SaveFromJSON(R"({"otaUpdate":{"updateChannel":"nightly","updateStep":42}})")); + 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); + } - const Config::RootConfig 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_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 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 +359,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,12 +372,11 @@ 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()); - 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); } } @@ -323,7 +405,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 13f5c5b2..c929e05e 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); @@ -51,74 +66,64 @@ 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]") +TEST_CASE("An empty store: Init writes a default config that reloads", "[config][defaults]") { - Config::RootConfig config = MakeSampleConfig(); - config.ToDefault(); - AssertConfigEqual(Config::RootConfig(), config); -} - -TEST_CASE("Missing config file: 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(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,57 +138,83 @@ 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()); - 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]") { - 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)))); - - auto txPin = Config::GetRFConfigTxPin(); - TEST_ASSERT_TRUE(txPin.has_value()); - TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, *txPin); + 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 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. - flatbuffers::FlatBufferBuilder builder2; - auto rf2 = Fbs::CreateRFConfig(builder2, -1, true); - TEST_ASSERT_TRUE(InitFrom(Finish(builder2, Fbs::CreateHubConfig(builder2, rf2)))); - txPin = Config::GetRFConfigTxPin(); - TEST_ASSERT_TRUE(txPin.has_value()); - TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, *txPin); + WipeStore(); + { + auto rf = Open(Keys::Ns::kRf); + TEST_ASSERT_TRUE(rf.SetI8(Keys::Rf::kTxPin, -1)); + TEST_ASSERT_TRUE(rf.Commit()); + } + MarkSchema(); + Reload(); + TEST_ASSERT_EQUAL_INT(OPENSHOCK_RF_TX_GPIO, Config::GetRFConfigTxPin()); } 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 +222,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 +234,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_legacy.cpp b/components/config/host_test/main/test_legacy.cpp index fa61f1ac..a3dda6e2 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]") -{ - // 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_CASE("The imported 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,15 +244,12 @@ 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()); - 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 7a92ae6a..6bde677a 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,41 +11,13 @@ using namespace OpenShock; using namespace ConfigTest; -// Whether `field` is physically present in `table`. Generated tables derive privately from -// flatbuffers::Table (a view of the same bytes), so CheckField is reached through it. -static bool IsStored(const void* table, flatbuffers::voffset_t field) -{ - return static_cast(table)->CheckField(field); -} - -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(); @@ -64,17 +38,14 @@ 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()); - 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()); @@ -84,7 +55,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)); @@ -96,76 +67,117 @@ 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_TRUE(InitFrom(StoredFile())); - 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)); + + Reload(); + auto creds = Config::GetWiFiCredentials(); + 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_ASSERT_FALSE(Config::HasWiFiCredentials()); } -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); + + auto creds = Config::GetWiFiCredentials(); + 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_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()); +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(); - 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()); + Full expected = sample; + expected.wifi.credentialsList.erase(expected.wifi.credentialsList.begin() + 1); // Guest, id 2 + AssertConfigEqual(expected, Snapshot()); - // SetRaw takes effect immediately and survives a restart. - AssertConfigEqual(MakeSampleConfig(), Snapshot()); - TEST_ASSERT_TRUE(InitFrom(StoredFile())); - AssertConfigEqual(MakeSampleConfig(), 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_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("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(IsStored(root->rf(), Serialization::Configuration::RFConfig::VT_KEEPALIVE_ENABLED)); - TEST_ASSERT_TRUE(IsStored(root->ota_update(), 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(IsStored(rawRoot->ota_update(), 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(); - - TEST_ASSERT_TRUE(Config::SetRaw(bytes.data(), bytes.size())); - TEST_ASSERT_TRUE(Config::SetSerialInputConfigEchoEnabled(!MakeSampleConfig().serialInput.echoEnabled)); + const Full sample = MakeSampleConfig(); + InitWith(sample); + const std::size_t before = UsedEntries(); - 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]") @@ -176,105 +188,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()); @@ -282,8 +223,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. @@ -292,8 +233,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(); @@ -316,19 +257,20 @@ 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\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(); - 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("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("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_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); @@ -342,14 +284,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) { @@ -357,11 +313,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()); } @@ -376,6 +332,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 00000000..6b28f90f --- /dev/null +++ b/components/config/host_test/main/test_storage.cpp @@ -0,0 +1,178 @@ +// 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, 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("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(); + 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 00000000..7ea4c63b --- /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 60da2c64..2d2c265b 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 0b312985..00000000 --- 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 75105c56..00000000 --- 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 a0978688..00000000 --- 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 3b43e0f5..00000000 --- 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 7bec4f1c..00000000 --- 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 00000000..63d1583e --- /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 00000000..bec8e5e0 --- /dev/null +++ b/components/config/include/config/Nvs.h @@ -0,0 +1,184 @@ +#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. 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. + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace OpenShock::Config::Nvs { + enum class Result : uint8_t { + Found, + Missing, + 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`. 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. 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; + Handle(const Handle&) = delete; + 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; + [[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; + + [[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. + [[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 + { + 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) { + 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 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); + + /// @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 d81241b6..00000000 --- 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/Parse.h b/components/config/include/config/Parse.h new file mode 100644 index 00000000..71805881 --- /dev/null +++ b/components/config/include/config/Parse.h @@ -0,0 +1,90 @@ +#pragma once + +// 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" + +#include + +#include +#include +#include +#include +#include + +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); + + [[nodiscard]] std::optional JsonGpioNum(JSON::JsonView json, std::string_view name); + + 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()) { + val = defaultVal; + return true; + } + + // Wrong type or unknown value: fall back to the default like every other field, rather than keeping + // whatever value `val` held before (the current running config when importing JSON). + auto str = jsonVal.tryGetStr(); + if (!str) { + val = defaultVal; + return false; + } + + auto parsed = StringParser(*str); + if (!parsed) { + val = defaultVal; + return false; + } + + val = *parsed; + return true; + } + + /// @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()) { + return true; + } + + const int count = jsonArray.count(); + for (int i = 0; i < count; ++i) { + T item; + if (item.FromJSON(jsonArray.at(i))) { + vec.push_back(std::move(item)); + } + } + + return true; + } +} // 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 49cf370d..00000000 --- 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 946f3078..00000000 --- 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 00000000..51158936 --- /dev/null +++ b/components/config/include/config/Section.h @@ -0,0 +1,282 @@ +#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 meeting SectionValue (below) and one Section object for it (a namespace-scope +// static in its own .cpp): +// +// struct RFConfig { +// 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 +// 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. 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 +// (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" + +#include + +#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; + }; + + /// @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(); + 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 + 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 running 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; + } + updated.Normalize(); + if (!save(updated)) { + return false; + } + publish(std::move(updated)); + return true; + } + + bool Load(const char* partition) override + { + T loaded; + bool ok = loadNvs(loaded, partition); + if (!ok) { + loaded = defaults(); + } + m_unsynced = !ok; + publish(std::move(loaded)); + return ok; + } + + bool Reset(const char* partition) override + { + T value = defaults(); + bool ok = true; + if (partition != nullptr) { + ok = Internal::EraseNamespaces(partition, Info().namespaces) && value.SaveNvs(partition, nullptr); + } + m_unsynced = !ok; + publish(std::move(value)); // 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 loadNvs(stored, 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; + } + staged.Normalize(); + 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 || m_unsynced ? nullptr : &m_value); + m_unsynced = !ok; + 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: + /// @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)) { + 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) + { + const char* partition = StorageLabel(); + if (partition == nullptr) { + return true; + } + m_unsynced = !value.SaveNvs(partition, m_unsynced ? nullptr : &m_value); + return !m_unsynced; + } + + void publish(T&& value) + { + ScopedLock lock__(&m_dataMutex); + m_value = std::move(value); + } + + 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; + 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 OpenShock::Config diff --git a/components/config/include/config/SerialInputConfig.h b/components/config/include/config/SerialInputConfig.h deleted file mode 100644 index 8742aa8d..00000000 --- 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 00000000..72dff90c --- /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 ca16b5b7..00000000 --- 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 00000000..0a085c16 --- /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/include/config/internal/utils.h b/components/config/include/config/internal/utils.h deleted file mode 100644 index f46917c1..00000000 --- a/components/config/include/config/internal/utils.h +++ /dev/null @@ -1,84 +0,0 @@ -#pragma once - -#include "config/ConfigBase.h" - -#include "util/FunctionRef.h" -#include "json/Json.h" - -#include - -#include -#include -#include -#include -#include - -namespace OpenShock::Config::Internal::Utils { - // 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); - - void FromFbsStr(std::string& str, const flatbuffers::String* fbsStr, const char* defaultStr); - - [[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) - { - JSON::JsonView jsonVal = json[name]; - if (!jsonVal.valid()) { - val = defaultVal; - return true; - } - - // Wrong type or unknown value: fall back to the default like every other field, rather than keeping - // whatever value `val` held before (the current running config when importing JSON). - auto str = jsonVal.tryGetStr(); - if (!str) { - val = defaultVal; - return false; - } - - auto parsed = StringParser(*str); - if (!parsed) { - val = defaultVal; - return false; - } - - val = *parsed; - return true; - } - template // T inherits from ConfigBase - bool FromJsonArray(std::vector& vec, JSON::JsonView jsonArray) - { - vec.clear(); - if (!jsonArray.isArray()) { - return true; - } - - const int count = jsonArray.count(); - for (int i = 0; i < count; ++i) { - T item; - if (item.FromJSON(jsonArray.at(i))) { - vec.push_back(std::move(item)); - } - } - - return true; - } -} // namespace OpenShock::Config::Internal::Utils diff --git a/components/config/src/BackendConfig.cpp b/components/config/src/BackendConfig.cpp deleted file mode 100644 index 908f433a..00000000 --- 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 33993daa..00000000 --- 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 2b080e9b..00000000 --- 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 98ed9e73..00000000 --- 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 00000000..6a3dfd82 --- /dev/null +++ b/components/config/src/Migration.cpp @@ -0,0 +1,94 @@ +#include + +#include "config/Migration.h" + +const char* const TAG = "ConfigMigration"; + +#include "config/Nvs.h" +#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 + +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() + { + if (Config::Nvs::EraseNamespace("nvs", kWifiDriverNamespace)) { + OS_LOGI(TAG, "Dropped the WiFi driver's stale NVS store (%s), if it had one", kWifiDriverNamespace); + } + } +} // 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 00000000..a3cef16a --- /dev/null +++ b/components/config/src/Nvs.cpp @@ -0,0 +1,476 @@ +#include "config/Nvs.h" + +const char* const TAG = "Config::Nvs"; + +#include "Logging.h" + +#include + +#include +#include +#include +#include + +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; + 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; +} + +std::optional Nvs::Handle::OpenForLoad(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) { + return Handle(handle); + } + if (err == ESP_ERR_NVS_NOT_FOUND) { + 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 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 +{ + 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) { + 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 +{ + 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) { + 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) +{ + return checkWrite(nvs_set_u8(m_handle, key, value), key); +} +bool Nvs::Handle::SetBool(const char* key, bool value) +{ + return checkWrite(nvs_set_u8(m_handle, key, value ? 1 : 0), key); +} +bool Nvs::Handle::SetI8(const char* key, int8_t value) +{ + return checkWrite(nvs_set_i8(m_handle, key, value), key); +} +bool Nvs::Handle::SetU16(const char* key, uint16_t value) +{ + return checkWrite(nvs_set_u16(m_handle, key, value), key); +} +bool Nvs::Handle::SetI32(const char* key, int32_t value) +{ + return checkWrite(nvs_set_i32(m_handle, key, value), key); +} +bool Nvs::Handle::SetStr(const char* key, std::string_view value) +{ + // 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::EraseNamespace(const char* partition, const char* ns) +{ + // 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; + } + if (!existing->Exists()) { + return true; + } + existing.reset(); + auto handle = Handle::Open(partition, ns, true); + return handle && handle->EraseAll() && handle->Commit(); +} + +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; + } + + auto src = Handle::Open(from, ns, false); + auto dst = Handle::Open(to, ns, true); + if (!src || !dst) { + return false; + } + for (const auto& [key, type] : keys) { + RawEntry entry; + if (!readRaw(src->get(), key.c_str(), type, entry) || !writeRaw(dst->get(), key.c_str(), entry)) { + return false; + } + } + return dst->Commit(); +} + +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; + } + + auto ha = Handle::Open(a, ns, false); + auto hb = Handle::Open(b, ns, false); + if (!ha || !hb) { + return false; + } + for (const auto& [key, type] : keysA) { + RawEntry ea, eb; + if (!readRaw(ha->get(), key.c_str(), type, ea) || !readRaw(hb->get(), key.c_str(), type, eb) || ea.data != eb.data) { + return false; + } + } + return true; +} diff --git a/components/config/src/OtaUpdateConfig.cpp b/components/config/src/OtaUpdateConfig.cpp deleted file mode 100644 index ee5b96f0..00000000 --- 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 00000000..6e2af1a2 --- /dev/null +++ b/components/config/src/Parse.cpp @@ -0,0 +1,63 @@ +#include "config/Parse.h" + +const char* const TAG = "Config::Parse"; + +#include "Logging.h" + +#include +#include + +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) { + 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 3c617612..00000000 --- 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 75a9b86b..00000000 --- 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 00000000..2fe0b8a8 --- /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 d48e68ec..00000000 --- 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 00000000..0d4cd74b --- /dev/null +++ b/components/config/src/Store.cpp @@ -0,0 +1,350 @@ +#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::StorageLabel() +{ + return s_partition; +} + +#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 std::optional hasSchema(const char* partition) +{ + auto handle = Config::Nvs::Handle::OpenForLoad(partition, kRootNamespace); + uint8_t schema = 0; + if (!handle || !Config::Nvs::Load(*handle, kSchemaKey, schema, 0)) { + return std::nullopt; + } + if (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) +{ + 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; +} + +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 (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) && 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; + } + 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. 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) || !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 + // 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 (!*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]); + } + 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 kPartitionShared; + } + 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 (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"); + } + return kPartitionCfg; +} + +// 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. 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 written = true; + 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()) { + written &= 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); + 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"); + } +} + +void Config::Init() +{ + CONFIG_LOCK_WRITE(); + + s_partition = openStorage(); + if (s_partition != nullptr && std::strcmp(s_partition, kPartitionCfg) == 0) { + s_partition = moveSharedIntoCfg(); + } + + // 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; + } + + 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). 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); +} + +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 f0996afb..00000000 --- a/components/config/src/WiFiConfig.cpp +++ /dev/null @@ -1,106 +0,0 @@ -#include "config/WiFiConfig.h" - -const char* const TAG = "Config::WiFiConfig"; - -#include "config/internal/utils.h" -#include "Logging.h" -#include "util/HostnameUtils.h" - -using namespace OpenShock::Config; - -static_assert(OpenShock::HostnameUtils::IsValid(CONFIG_OPENSHOCK_FW_HOSTNAME), "CONFIG_OPENSHOCK_FW_HOSTNAME must be 1-32 letters, digits or hyphens, not starting or ending with a hyphen"); - -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 9c43d36f..00000000 --- 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 00000000..4225e3d3 --- /dev/null +++ b/components/config/src/Wire.cpp @@ -0,0 +1,272 @@ +#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 "util/HexUtils.h" + +#include "json/Json.h" + +#include +#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()) { + JSON::objSetString(gen, "bssid", HexUtils::ToHex(std::span(bssid->bytes()->data(), 6)).data()); + } + 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 d1c73b9b..00000000 --- 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 00000000..e2e71863 --- /dev/null +++ b/components/core/include/BackendConfig.h @@ -0,0 +1,47 @@ +#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(); // The defaults: the one place they are defined + BackendConfig(std::string_view domain, std::string_view authToken); + + std::string domain; + std::string authToken; + + /// @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; + + /// @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; + }; + + [[nodiscard]] BackendConfig GetBackendConfig(); + bool SetBackendConfig(const BackendConfig& config); + [[nodiscard]] std::string GetBackendDomain(); + bool SetBackendDomain(std::string domain); + bool HasBackendAuthToken(); + [[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 new file mode 100644 index 00000000..6be5faa1 --- /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(); // 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 + ); + + bool isEnabled; + std::string cdnDomain; + OtaUpdateChannel updateChannel; + bool checkOnStartup; + bool checkPeriodically; + uint16_t checkInterval; + bool allowBackendManagement; + bool requireManualApproval; + int32_t updateId; + OtaUpdateStep updateStep; + + /// @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; + + /// @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; + }; + + [[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]] int32_t GetOtaUpdateId(); + bool SetOtaUpdateId(int32_t updateId); + [[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` + 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 7d17a99b..190f204e 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 00000000..0027ee66 --- /dev/null +++ b/components/core/include/wifi/WiFiConfig.h @@ -0,0 +1,65 @@ +#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(); // 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 + + /// @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, 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); + 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; + }; + + [[nodiscard]] WiFiConfig GetWiFiConfig(); + bool SetWiFiConfig(const WiFiConfig& config); + + [[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). + 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); + /// @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::string 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 50% rename from components/config/include/config/WiFiCredentials.h rename to components/core/include/wifi/WiFiCredentials.h index b79b64e9..c3db60db 100644 --- a/components/config/include/config/WiFiCredentials.h +++ b/components/core/include/wifi/WiFiCredentials.h @@ -1,17 +1,25 @@ #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); @@ -22,13 +30,16 @@ namespace OpenShock::Config { std::array bssid; // Pinned BSSID, all zeros = no pin bool HasPinnedBSSID() const; + bool operator==(const WiFiCredentials&) const = default; - 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); - 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 00000000..8ab28717 --- /dev/null +++ b/components/core/src/BackendConfig.cpp @@ -0,0 +1,138 @@ +#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}); + +BackendConfig::BackendConfig() + : domain(CONFIG_OPENSHOCK_API_DOMAIN) + , authToken() +{ +} + +BackendConfig::BackendConfig(std::string_view domain, std::string_view authToken) + : domain(domain) + , authToken(authToken) +{ +} + +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 (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; + } + + 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::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; + } + 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 Nvs::Saver(*handle, *this, previous).Set(kKeyDomain, &BackendConfig::domain).Set(kKeyToken, &BackendConfig::authToken).Commit(); +} + +BackendConfig OpenShock::Config::GetBackendConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetBackendConfig(const BackendConfig& config) +{ + return s_section.Set(config); +} + +std::string 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::string 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 030f8d00..96c204a1 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" @@ -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 35c1ecd6..aa355905 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" @@ -301,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"); @@ -349,13 +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 } - auto authToken = Config::GetBackendAuthToken(); - if (!authToken) { - OS_LOGE(TAG, "Failed to get auth token"); - return; - } - - if (!FetchHubInfo(*authToken)) { + 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; @@ -373,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/core/src/OtaUpdateConfig.cpp b/components/core/src/OtaUpdateConfig.cpp new file mode 100644 index 00000000..fd9279d4 --- /dev/null +++ b/components/core/src/OtaUpdateConfig.cpp @@ -0,0 +1,241 @@ +#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 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}); + +using namespace std::string_view_literals; + +OtaUpdateConfig::OtaUpdateConfig() + : isEnabled(true) + , cdnDomain(CONFIG_OPENSHOCK_FW_CDN_DOMAIN) + , updateChannel(OtaUpdateChannel::Stable) + , checkOnStartup(false) + , checkPeriodically(false) + , checkInterval(kDefaultCheckInterval) + , 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) +{ +} + +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 (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; + } + + // 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; + } + + cdnDomain = json["cdnDomain"].tryGetStr().value_or(CONFIG_OPENSHOCK_FW_CDN_DOMAIN); + Parse::JsonStrParsed(updateChannel, json, "updateChannel"sv, OpenShock::TryParseOtaUpdateChannel, OpenShock::OtaUpdateChannel::Stable); + 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; +} + +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); +} + +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::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; + } + + 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, 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; + } + // 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; + } + 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(); +} + +OtaUpdateConfig 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); +} + +int32_t 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; + }); +} + +OpenShock::OtaUpdateStep 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 b332a116..50fb5d5f 100644 --- a/components/core/src/http/JsonAPI.cpp +++ b/components/core/src/http/JsonAPI.cpp @@ -1,42 +1,36 @@ +#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" -#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"} }, @@ -47,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)} @@ -65,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/serialization/WSLocal.cpp b/components/core/src/serialization/WSLocal.cpp index 555c804f..e68ca2ad 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 00000000..2a242ad8 --- /dev/null +++ b/components/core/src/wifi/WiFiConfig.cpp @@ -0,0 +1,447 @@ +#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 "util/HostnameUtils.h" + +#include "json/Json.h" + +#include +#include +#include +#include + +using namespace OpenShock::Config; +namespace Fbs = OpenShock::Serialization::Configuration; + +static_assert(OpenShock::HostnameUtils::IsValid(CONFIG_OPENSHOCK_FW_HOSTNAME), "CONFIG_OPENSHOCK_FW_HOSTNAME must be 1-32 letters, digits or hyphens, not starting or ending with a hyphen"); + +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}); + +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) +{ +} + +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 (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; + } + + 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); + } + + // 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 (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() +{ + if (!HostnameUtils::IsValid(hostname)) { + 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) +{ + 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(); + 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. + 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; + } + // 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; + WiFiCredentials creds; + 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)); + } + 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 (!Nvs::Saver(*handle, *this, previous).Set(kKeyApSsid, &WiFiConfig::accessPointSSID).Set(kKeyHostname, &WiFiConfig::hostname).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 && *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; +} + +WiFiConfig OpenShock::Config::GetWiFiConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetWiFiConfig(const WiFiConfig& config) +{ + return s_section.Set(config); +} + +std::vector OpenShock::Config::GetWiFiCredentials() +{ + return s_section.Read([](const WiFiConfig& wifi) { return wifi.credentialsList; }); +} + +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); + } + }); +} + +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 && 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; + + if (WiFiCredentials* existing = findBySsid(list, ssid)) { + existing->password = password; + if (authMode != WIFI_AUTH_MAX) { + existing->authMode = authMode; + } + id = existing->id; + return true; + } + + id = lowestFreeId(list); + 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 { + 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 { + 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 { + const WiFiCredentials* creds = findBySsid(wifi.credentialsList, ssid); + return creds != nullptr ? creds->id : 0; + }); +} + +bool OpenShock::Config::PinWiFiCredentialsBSSID(uint8_t id, const uint8_t (&bssid)[6]) +{ + bool found = false; + bool saved = s_section.Update([&](WiFiConfig& wifi) { + WiFiCredentials* creds = findById(wifi.credentialsList, id); + if (creds == nullptr) { + return false; + } + memcpy(creds->bssid.data(), bssid, 6); + found = true; + return true; + }); + return found && saved; +} + +bool OpenShock::Config::RemoveWiFiCredentials(uint8_t id) +{ + bool found = false; + bool saved = s_section.Update([&](WiFiConfig& wifi) { + auto& list = wifi.credentialsList; + const WiFiCredentials* creds = findById(list, id); + if (creds == nullptr) { + return false; + } + list.erase(list.begin() + (creds - list.data())); + found = true; + return true; + }); + return found && saved; +} + +bool OpenShock::Config::ClearWiFiCredentials() +{ + return s_section.Update([](WiFiConfig& wifi) { + wifi.credentialsList.clear(); + return true; + }); +} + +std::string 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 00000000..cc57c024 --- /dev/null +++ b/components/core/src/wifi/WiFiCredentials.cpp @@ -0,0 +1,208 @@ +#include "wifi/WiFiCredentials.h" + +const char* const TAG = "Config::WiFiCredentials"; + +#include "config/Parse.h" +#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; +} + +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 (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; + } + + 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()) { + JSON::objSetString(gen, "bssid", HexUtils::ToHex(std::span(bssid)).data()); + } + 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 1dca2277..4d8a070c 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" @@ -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/crypto/host_test/CMakeLists.txt b/components/crypto/host_test/CMakeLists.txt index b13d16ba..fd9dc582 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/CMakeLists.txt b/components/device_control/CMakeLists.txt index bc4f3bf3..19b63b47 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/CMakeLists.txt b/components/device_control/host_test/CMakeLists.txt index 285a3c84..7655eaa8 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/device_control/host_test/main/CMakeLists.txt b/components/device_control/host_test/main/CMakeLists.txt index eb931c8f..955331b8 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.cpp b/components/device_control/host_test/main/host_fakes.cpp index bbd0dfee..65402635 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/host_fakes.h b/components/device_control/host_test/main/host_fakes.h index 3921bde8..8ba12480 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/config/Config.h b/components/device_control/host_test/main/stubs/config/Config.h deleted file mode 100644 index 90efbb65..00000000 --- a/components/device_control/host_test/main/stubs/config/Config.h +++ /dev/null @@ -1,28 +0,0 @@ -#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). -#include - -#include - -namespace OpenShock::Config { - struct RFConfig { - gpio_num_t txPin; - 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/host_test/main/stubs/estop/EStopConfig.h b/components/device_control/host_test/main/stubs/estop/EStopConfig.h new file mode 100644 index 00000000..2910712a --- /dev/null +++ b/components/device_control/host_test/main/stubs/estop/EStopConfig.h @@ -0,0 +1,15 @@ +#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 + +namespace OpenShock::Config { + struct EStopConfig { + bool enabled; + gpio_num_t gpioPin; + }; + + [[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 new file mode 100644 index 00000000..f3611f42 --- /dev/null +++ b/components/device_control/host_test/main/stubs/radio/RFConfig.h @@ -0,0 +1,18 @@ +#pragma once + +// 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 + +namespace OpenShock::Config { + struct RFConfig { + gpio_num_t txPin; + bool keepAliveEnabled; + }; + + [[nodiscard]] RFConfig GetRFConfig(); + [[nodiscard]] gpio_num_t GetRFConfigTxPin(); + bool SetRFConfigTxPin(gpio_num_t txPin); + [[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 new file mode 100644 index 00000000..7106199a --- /dev/null +++ b/components/device_control/include/estop/EStopConfig.h @@ -0,0 +1,45 @@ +#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). + +#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(); // The defaults: the one place they are defined + EStopConfig(bool enabled, gpio_num_t gpioPin); + + bool enabled; + gpio_num_t gpioPin; + + /// @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); + 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; + }; + + [[nodiscard]] EStopConfig GetEStopConfig(); + bool SetEStopConfig(const EStopConfig& config); + [[nodiscard]] bool GetEStopEnabled(); + bool SetEStopEnabled(bool enabled); + [[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 new file mode 100644 index 00000000..4610fcf6 --- /dev/null +++ b/components/device_control/include/radio/RFConfig.h @@ -0,0 +1,44 @@ +#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(); // The defaults: the one place they are defined + RFConfig(gpio_num_t txPin, bool keepAliveEnabled); + + gpio_num_t txPin; + bool keepAliveEnabled; + + /// @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; + + 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. + [[nodiscard]] RFConfig GetRFConfig(); + bool SetRFConfig(const RFConfig& config); + [[nodiscard]] gpio_num_t GetRFConfigTxPin(); + bool SetRFConfigTxPin(gpio_num_t txPin); + [[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 a625be2f..c2fe1d58 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" @@ -218,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)) { @@ -269,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; } @@ -331,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 2a2b45e3..89a8c28c 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" @@ -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,23 +210,18 @@ 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; } - s_estopPin = cfg->gpioPin; + s_estopPin = cfg.gpioPin; return estopmgr_taskStart(); } diff --git a/components/device_control/src/estop/EStopConfig.cpp b/components/device_control/src/estop/EStopConfig.cpp new file mode 100644 index 00000000..c77401c8 --- /dev/null +++ b/components/device_control/src/estop/EStopConfig.cpp @@ -0,0 +1,149 @@ +#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* kNamespaces[] = {kNamespace}; +static Section s_section({.name = "estop", .order = 6, .hubConfigField = Fbs::HubConfig::VT_ESTOP, .namespaces = kNamespaces}); + +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::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::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; + } + + 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); + *this = EStopConfig(); + return true; + } + gpioPin = *pin; + return Nvs::Load(*handle, kKeyEnabled, enabled, OpenShock::IsValidInputPin(gpioPin)); +} + +bool EStopConfig::SaveNvs(const char* partition, const EStopConfig* previous) const +{ + auto handle = Nvs::Handle::Open(partition, kNamespace, true); + if (!handle) { + return false; + } + // 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) +{ + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; + } + + gpioPin = Parse::JsonGpioNum(json, "gpioPin").value_or(static_cast(Board::Get().estopGpio)); + + if (!Parse::JsonBool(enabled, json, "enabled", OpenShock::IsValidInputPin(gpioPin), TAG)) { + return false; + } + + 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); +} + +EStopConfig OpenShock::Config::GetEStopConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetEStopConfig(const EStopConfig& config) +{ + return s_section.Set(config); +} + +bool 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; + }); +} + +gpio_num_t 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; + }); +} diff --git a/components/device_control/src/radio/RFConfig.cpp b/components/device_control/src/radio/RFConfig.cpp new file mode 100644 index 00000000..33468d5f --- /dev/null +++ b/components/device_control/src/radio/RFConfig.cpp @@ -0,0 +1,138 @@ +#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}); + +RFConfig::RFConfig() + : txPin(static_cast(Board::Get().rfTxGpio)) + , keepAliveEnabled(true) +{ +} + +RFConfig::RFConfig(gpio_num_t txPin, bool keepAliveEnabled) + : txPin(txPin) + , keepAliveEnabled(keepAliveEnabled) +{ +} + +bool RFConfig::LoadNvs(const char* partition) +{ + auto handle = Nvs::Handle::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; + } + + 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; + } + return Nvs::Saver(*handle, *this, previous).Set(kKeyTxPin, &RFConfig::txPin).Set(kKeyKeepAlive, &RFConfig::keepAliveEnabled).Commit(); +} + +bool RFConfig::FromJSON(JSON::JsonView json) +{ + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; + } + + if (auto pin = Parse::JsonGpioNum(json, "txPin"); pin && *pin != GPIO_NUM_NC) { + txPin = *pin; + } else { + txPin = static_cast(Board::Get().rfTxGpio); + } + if (!Parse::JsonBool(keepAliveEnabled, json, "keepAliveEnabled", true, TAG)) { + return false; + } + + 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(); +} + +RFConfig OpenShock::Config::GetRFConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetRFConfig(const RFConfig& config) +{ + return s_section.Set(config); +} + +gpio_num_t 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; + }); +} + +bool 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/dns_server/host_test/CMakeLists.txt b/components/dns_server/host_test/CMakeLists.txt index d27477a0..00a6b190 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 9a06aada..79e00ce0 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/fs/host_test/main/test_config_partition.cpp b/components/fs/host_test/main/test_config_partition.cpp index 48074196..6c0dc219 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/osjson/host_test/CMakeLists.txt b/components/osjson/host_test/CMakeLists.txt index af0c84fa..bbf71a48 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/ota/src/OtaUpdateManager.cpp b/components/ota/src/OtaUpdateManager.cpp index d8f6543a..8b6702e8 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" @@ -152,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; } @@ -167,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; } @@ -370,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"); @@ -595,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. @@ -671,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/partition_layout/host_test/CMakeLists.txt b/components/partition_layout/host_test/CMakeLists.txt index 03340095..2cb8b5ba 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 7a079865..c9490da4 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/serial_console/CMakeLists.txt b/components/serial_console/CMakeLists.txt index a07c62d4..a1864043 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 00000000..1c814ddd --- /dev/null +++ b/components/serial_console/include/serial_console/SerialInputConfig.h @@ -0,0 +1,40 @@ +#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(); // The defaults: the one place they are defined + SerialInputConfig(bool echoEnabled); + + bool echoEnabled; + + /// @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; + + /// @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; + }; + + [[nodiscard]] SerialInputConfig GetSerialInputConfig(); + bool SetSerialInputConfig(const SerialInputConfig& config); + [[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 153b096e..2f44c526 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 06f479b1..a1ca0e83 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 new file mode 100644 index 00000000..5d8ce017 --- /dev/null +++ b/components/serial_console/src/SerialInputConfig.cpp @@ -0,0 +1,96 @@ +#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}); + +SerialInputConfig::SerialInputConfig() + : echoEnabled(true) +{ +} + +SerialInputConfig::SerialInputConfig(bool echoEnabled) + : echoEnabled(echoEnabled) +{ +} + +flatbuffers::Offset SerialInputConfig::ToFlatbuffers(flatbuffers::FlatBufferBuilder& builder, bool withSensitiveData) const +{ + return Serialization::Configuration::CreateSerialInputConfig(builder, echoEnabled); +} + +bool SerialInputConfig::FromJSON(JSON::JsonView json) +{ + if (auto done = Parse::ObjectOrDefault(*this, json, TAG)) { + return *done; + } + + if (!Parse::JsonBool(echoEnabled, json, "echoEnabled", true, TAG)) { + return false; + } + + 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::OpenForLoad(partition, kNamespace); + if (!handle) { + return false; + } + 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 Nvs::Saver(*handle, *this, previous).Set(kKeyEcho, &SerialInputConfig::echoEnabled).Commit(); +} + +SerialInputConfig OpenShock::Config::GetSerialInputConfig() +{ + return s_section.Get(); +} + +bool OpenShock::Config::SetSerialInputConfig(const SerialInputConfig& config) +{ + return s_section.Set(config); +} + +bool 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 446055a2..6b8b52c9 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" @@ -57,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; @@ -512,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; } @@ -548,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(); } @@ -689,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; @@ -703,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 7a882266..668c2912 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 4095c1c3..dcb4b95f 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" @@ -9,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; } @@ -28,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 49d0ae54..48983b15 100644 --- a/components/serial_console/src/command_handlers/domain.cpp +++ b/components/serial_console/src/command_handlers/domain.cpp @@ -1,11 +1,11 @@ +#include + #include "serial_console/command_handlers/common.h" -#include "config/Config.h" +#include "BackendConfig.h" #include "http/HTTPRequestManager.h" #include "serialization/JsonAPI.h" -#include - #include #include @@ -14,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; } @@ -49,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 dad6aa8e..32bd36c8 100644 --- a/components/serial_console/src/command_handlers/echo.cpp +++ b/components/serial_console/src/command_handlers/echo.cpp @@ -1,16 +1,15 @@ -#include "serial_console/command_handlers/common.h" +#include -#include "serial_console/SerialInputHandler.h" +#include "serial_console/command_handlers/common.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) { 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; } @@ -20,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 34ed213e..6721df5d 100644 --- a/components/serial_console/src/command_handlers/estop.cpp +++ b/components/serial_console/src/command_handlers/estop.cpp @@ -1,20 +1,15 @@ +#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) { 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; } @@ -35,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/factoryreset.cpp b/components/serial_console/src/command_handlers/factoryreset.cpp index 8aaf91b9..dceaf6af 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 4aae8dfb..c471d039 100644 --- a/components/serial_console/src/command_handlers/hostname.cpp +++ b/components/serial_console/src/command_handlers/hostname.cpp @@ -1,9 +1,9 @@ +#include + #include "serial_console/command_handlers/common.h" -#include "config/Config.h" #include "util/HostnameUtils.h" - -#include +#include "wifi/WiFiConfig.h" #include @@ -12,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; } @@ -27,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 7460259b..ed8b84a4 100644 --- a/components/serial_console/src/command_handlers/jsonconfig.cpp +++ b/components/serial_console/src/command_handlers/jsonconfig.cpp @@ -1,8 +1,6 @@ #include "serial_console/command_handlers/common.h" -#include "config/Config.h" - -#include +#include "config/Store.h" static void handleJsonConfigCommand(std::string_view arg, bool isAutomated) { @@ -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 22333c90..d932170b 100644 --- a/components/serial_console/src/command_handlers/keepalive.cpp +++ b/components/serial_console/src/command_handlers/keepalive.cpp @@ -1,21 +1,16 @@ +#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) { 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; } @@ -25,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 687f63ca..52eceda5 100644 --- a/components/serial_console/src/command_handlers/networks.cpp +++ b/components/serial_console/src/command_handlers/networks.cpp @@ -1,10 +1,11 @@ +#include + #include "serial_console/command_handlers/common.h" -#include "config/Config.h" +#include "wifi/WiFiConfig.h" #include "wifi/WiFiManager.h" #include "json/Json.h" -#include #include const char* const TAG = "SerialCmds::CommandHandlers::Networks"; @@ -16,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(); @@ -40,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 33b1dc85..d1dd6e02 100644 --- a/components/serial_console/src/command_handlers/rawconfig.cpp +++ b/components/serial_console/src/command_handlers/rawconfig.cpp @@ -1,11 +1,9 @@ #include "serial_console/command_handlers/common.h" #include "Base64.h" -#include "config/Config.h" +#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 ed19a7a3..043ced1e 100644 --- a/components/serial_console/src/command_handlers/rftxpin.cpp +++ b/components/serial_console/src/command_handlers/rftxpin.cpp @@ -1,21 +1,16 @@ +#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) { 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; } diff --git a/components/serial_console/src/command_handlers/sysinfo.cpp b/components/serial_console/src/command_handlers/sysinfo.cpp index 7b2cd1a4..5e27929f 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/components/www/host_test/CMakeLists.txt b/components/www/host_test/CMakeLists.txt index 154b2172..3d029a4d 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) diff --git a/docs/STORAGE.md b/docs/STORAGE.md index d1d49baf..fa52bd16 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -1,24 +1,90 @@ # Storage and partition layouts -How the firmware lays out flash, where it keeps the captive portal's assets, and how a 1.x hub's littlefs `static0` -partition becomes the `www` image partition 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. +How the firmware lays out flash, where it keeps its config and the captive portal's assets, and how a 1.x hub's +littlefs `config` and `static0` partitions become the NVS config store and the `www` image partition 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. +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 | -| `config` (data/spiffs) | 0x350000 | 12 KiB | the same | +| `cfg` (data/nvs) | 0x350000 | 12 KiB | littlefs `config` (data/spiffs) | | `www` (type 0x40) | 0x353000 | 628 KiB | littlefs `static0` (data/spiffs) | | `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 `static0` -changes, in type and label. The flash past 4 MB on bigger boards stays unused. +It is the 1.x geometry entry for entry: a fielded hub rewrites its table in place and nothing moves. Only `config` and +`static0` change, in type and label, each by its own migration; either can run first. 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 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 whose default constructor is the defaults, with `Normalize` (its validation, +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 +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: + +- `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 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 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 + +`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` (the 1.x config file with it: from here a downgrade to + 1.x starts from defaults) and moves the config in. ## Portal assets @@ -37,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 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 @@ -55,7 +122,7 @@ pipelines the storage migrations decides when it runs, with the portal closed an old table back; the hub never restarts without a table that parses. 5. The hub restarts and the portal serves from `www`. -### Recovery +## 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, config and assets are still on flash, so diff --git a/main/main.cpp b/main/main.cpp index 40c6d412..e84853a2 100644 --- a/main/main.cpp +++ b/main/main.cpp @@ -6,7 +6,8 @@ const char* const TAG = "main"; #include "captiveportal/Manager.h" #include "Chipset.h" #include "CommandHandler.h" -#include "config/Config.h" +#include "config/Nvs.h" +#include "config/Store.h" #include "estop/EStopManager.h" #include "events/Events.h" #include "GatewayConnectionManager.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 diff --git a/partitions/legacy/ota_4mb_v1.csv b/partitions/legacy/ota_4mb_v1.csv index ccd8a3c6..6d656b61 100644 --- a/partitions/legacy/ota_4mb_v1.csv +++ b/partitions/legacy/ota_4mb_v1.csv @@ -1,11 +1,13 @@ # 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: the www migration recognises this -# layout, writes the www image over its littlefs `static0` and retypes it into the `www` partition of -# partitions/ota_4mb.csv, keeping every offset and size. +# 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 storage migrations recognise +# this layout and retype it into partitions/ota_4mb.csv, keeping every offset and size. The config +# migration retypes its littlefs `config` into the `cfg` NVS partition; the www migration writes the +# www image over its littlefs `static0` and retypes it into the `www` partition. # 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, diff --git a/partitions/ota_4mb.csv b/partitions/ota_4mb.csv index 087ec6ca..9efc6fea 100644 --- a/partitions/ota_4mb.csv +++ b/partitions/ota_4mb.csv @@ -1,14 +1,15 @@ -# 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 www migration -# rewrites the table in place and moves nothing. Only the 1.x littlefs `static0` changes, in type and -# label: it becomes `www`, the flat read-only image of the captive-portal assets (type 0x40 so no -# ESP-IDF component ever tries to mount it; the firmware looks it up by type). See docs/STORAGE.md. +# The geometry of the 1.x table (partitions/legacy/ota_4mb_v1.csv), entry for entry: the storage +# migrations rewrite the table in place and move nothing. Two 1.x littlefs partitions change, in type +# and label only: `config` becomes `cfg`, the NVS config store, and `static0` becomes `www`, the flat +# read-only image of the captive-portal assets (type 0x40 so no ESP-IDF component ever tries to mount +# it; the firmware looks it up by type). 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, www, 0x40, 0x00, 0x353000, 0x09D000, coredump, data, coredump, 0x3F0000, 0x010000, diff --git a/scripts/tests/test_build_scripts.py b/scripts/tests/test_build_scripts.py index 2b01a838..9b66e5f0 100644 --- a/scripts/tests/test_build_scripts.py +++ b/scripts/tests/test_build_scripts.py @@ -14,7 +14,7 @@ def test_reads_the_repository_partition_tables(): table = partitions.read_partitions(ROOT / 'partitions' / 'ota_4mb.csv') assert table['www']['offset'] == 0x353000 assert table['www']['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 d13c0dc0..f7525463 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -49,13 +49,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/www/coredump). Set it here so a build without a board fragment (e.g. the +# cfg/www/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 www 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