From fd093d60bcc83bf28df554b261cc292f7425961f Mon Sep 17 00:00:00 2001 From: liu weikai Date: Thu, 20 Aug 2026 12:33:26 +0800 Subject: [PATCH] feat(config): complete SD working configuration --- CHANGELOG.md | Bin 107780 -> 108302 bytes .../esp32_lvgl_sd_coredump_contract_smoke.cpp | 4 - apps/linux_sim_shell/CMakeLists.txt | 8 - .../esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake | 1 - cmake/TrailMateLinuxSources.cmake | 1 - docs/MULTI_PROTOCOL_SUPPORT.md | 8 +- .../audits/LEGACY_COMPAT_TEMP_SURFACE_SCAN.md | 2 +- docs/config_persistence_architecture.md | 51 +- docs/design/use-case-diagrams-maps.md | 4 +- .../backup-restore-settings.html | 1 - .../backup-restore-settings.md | 37 +- .../backup-restore-settings/activity.html | 7 - .../backup-restore-settings/activity.md | 51 +- .../sequence-backup-restore-settings.html | 12 - .../sequence-backup-restore-settings.md | 49 +- docs/meshcore/v2_multibyte_paths.md | 5 +- .../SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md | 136 +- docs/ux_profiles/screen_240x320_mono_ui.md | 4 +- docs/wifi_access_resource_policy.md | 3 +- .../core_sys/include/app/tms_config_codec.h | 64 +- .../core_sys/include/platform/ui/README.md | 3 - .../platform/ui/settings_backup_runtime.h | 22 - .../include/platform/ui/settings_store.h | 22 +- .../include/platform/ui/wifi_runtime.h | 11 + modules/core_sys/src/app/tms_config_codec.cpp | 94 +- .../core_sys/tests/test_tms_config_codec.cpp | 66 + .../ui/screens/settings/settings_spec.h | 1 - .../ui/screens/settings/settings_state.h | 4 - .../settings/settings_page_components.cpp | 179 +- .../src/ui/screens/settings/settings_spec.cpp | 20 - .../app_config_sd_tms_runtime.h | 18 + .../app_config_tms_settings_extension.h | 26 + .../src/a7682e_cellular_runtime.cpp | 2 + .../src/app_config_sd_tms_runtime.cpp | 132 +- .../arduino_common/src/app_config_store.cpp | 3 + .../src/app_config_tms_settings_extension.cpp | 692 +++++ .../src/app_runtime_support.cpp | 2 + .../platform_ui_settings_backup_runtime.cpp | 2344 ----------------- platform/esp/arduino_common/src/ui_common.cpp | 2 + ...test_settings_backup_coverage_contract.cpp | 62 - .../platform/esp/common/settings_store_impl.h | 85 + .../platform/esp/common/wifi_runtime_impl.h | 79 +- .../platform/ui/settings_backup_runtime.cpp | 36 - .../platform_ui_settings_backup_runtime.cpp | 36 - .../check_scope_guard.py | 18 - 45 files changed, 1490 insertions(+), 2917 deletions(-) delete mode 100644 docs/design/use-case-diagrams/backup-restore-settings.html delete mode 100644 docs/design/use-case-diagrams/backup-restore-settings/activity.html delete mode 100644 docs/design/use-case-diagrams/backup-restore-settings/sequences/sequence-backup-restore-settings.html delete mode 100644 modules/core_sys/include/platform/ui/settings_backup_runtime.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_tms_settings_extension.h create mode 100644 platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp delete mode 100644 platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp delete mode 100644 platform/esp/arduino_common/tests/test_settings_backup_coverage_contract.cpp delete mode 100644 platform/linux/common/src/platform/ui/settings_backup_runtime.cpp delete mode 100644 platform/nrf52/arduino_common/src/platform_ui_settings_backup_runtime.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d527ea3d5546cc04c71768561e2c1e18ef1e21f..a2de1a09c107054904921e8940b127f96747d333 100644 GIT binary patch delta 727 zcmaJ;JugF17=EtOHl!huYO3gIA`#JQOcG55!62c6Fc{?OEgJoh-b-VW(Ajc^E*7Ij zZ2p5m;vY!-1f#|Cp6hlr+;h))zn+h`{T9A|55Ii&>Yk-h-3~unF+CS4hN`IQPGssR z3k45nDDz)J12tTr%9m#%4K(5K*5v0PhJIE_eHnG+SlML92`Z*T;efXs)_E$ptnrZ5 z-$OHJUtYQ{1eRDhBC;sr(P9y_I9a0uQE9|k^QPz)7@zl!S49VrOV+hGK zf^M7{UsRH-PR%9@d)Opc9veK1R2VEnSc_LR4s}UIVVU0vfw^Lzptciny%5SC zicWFKrj%>uTvj#dNTusaKs<0zRxfj~xKFzX_xGsR)M#kX+w9hO-lTHznT f%(e`IY{KUz{jvq$VBzYR3~}x<(br7W`g-^U;nR+0 delta 284 zcmeA>$JTO+ZNmeW$xk!{SVI_m8Gm1|#oeMhEd`H?Hk&T#P5?000}h BN%H^z diff --git a/apps/esp32_lvgl/tests/esp32_lvgl_sd_coredump_contract_smoke.cpp b/apps/esp32_lvgl/tests/esp32_lvgl_sd_coredump_contract_smoke.cpp index 72cd0806..e1d54de9 100644 --- a/apps/esp32_lvgl/tests/esp32_lvgl_sd_coredump_contract_smoke.cpp +++ b/apps/esp32_lvgl/tests/esp32_lvgl_sd_coredump_contract_smoke.cpp @@ -220,10 +220,6 @@ int main(int argc, char** argv) "platform/esp/arduino_common/src/ui/runtime/pack_repository.cpp")); assert(!contains(idf_sources, "platform/esp/idf_common/src/platform_ui_pack_repository_runtime.cpp")); - assert(contains(idf_sources, - "platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp")); - assert(!contains(idf_sources, - "platform/esp/idf_common/src/platform_ui_settings_backup_runtime.cpp")); assert(contains(idf_sources, "platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp")); assert(!contains(idf_sources, diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index ccc61d6f..5ef12994 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -676,14 +676,6 @@ if(BUILD_TESTING) COMMAND trailmate_auto_reply_settings_persistence_contract_smoke "${TRAIL_MATE_REPO_ROOT}") - add_executable(trailmate_settings_backup_coverage_contract_smoke - "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/tests/test_settings_backup_coverage_contract.cpp") - target_compile_features(trailmate_settings_backup_coverage_contract_smoke - PRIVATE cxx_std_17) - add_test(NAME trailmate_settings_backup_coverage_contract_smoke - COMMAND trailmate_settings_backup_coverage_contract_smoke - "${TRAIL_MATE_REPO_ROOT}") - add_executable(trailmate_reticulum_network_defaults_refresh_contract_smoke "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/tests/test_reticulum_network_defaults_refresh_contract.cpp") target_compile_features(trailmate_reticulum_network_defaults_refresh_contract_smoke diff --git a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake index 6ce91691..0037f78f 100644 --- a/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake +++ b/builds/esp_idf/ESP_IDF_COMPONENT_SOURCES.cmake @@ -474,7 +474,6 @@ set(TRAILMATE_ESP_IDF_PLATFORM_COMMON_SOURCES "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_reticulum_group_config_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/platform_ui_reticulum_network_config_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/platform_ui_route_storage.cpp" - "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp" "${TRAILMATE_ROOT}/platform/esp/arduino_common/src/ui/screens/team/team_ui_store.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_settings_store.cpp" "${TRAILMATE_ROOT}/platform/esp/idf_common/src/platform_ui_sstv_runtime.cpp" diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index b716076d..6a06b359 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -167,7 +167,6 @@ set(TRAIL_MATE_LINUX_COMMON_SOURCES "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/screen_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/reticulum_directory_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/reticulum_group_config_runtime.cpp" - "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/settings_backup_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/settings_store.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/sstv_runtime.cpp" "${TRAIL_MATE_LINUX_COMMON_SRC_ROOT}/platform/ui/team_ui_store_runtime.cpp" diff --git a/docs/MULTI_PROTOCOL_SUPPORT.md b/docs/MULTI_PROTOCOL_SUPPORT.md index c9d9c0cf..e5aa4d8e 100644 --- a/docs/MULTI_PROTOCOL_SUPPORT.md +++ b/docs/MULTI_PROTOCOL_SUPPORT.md @@ -95,9 +95,9 @@ LVGL Settings are grouped by responsibilities instead of the old "Chat/Network/S - `Wi-Fi`: Wi-Fi enable/status/scan/SSID/password/connect/disconnect. - `Location`: GPS receiver, position strategy, NMEA export, map source, contours, and track recording. - `Device`: locale/IME, screen, speaker, vibration, C6 companion, time zone/date-time, and battery gauge calibration. -- `Maintenance`: firmware update, settings backup/restore, debug logs, mesh/node/message resets, factory reset. +- `Maintenance`: firmware update, debug logs, mesh/node/message resets, and factory reset. The full editable configuration is maintained as the SD-first `/trailmate/config.tms` working document rather than through a separate backup/restore action. -Bluetooth is intentionally absent from Settings. For the ESP Arduino firmware profile, `TRAIL_MATE_ENABLE_BLE=0`, the NimBLE dependency is not part of the PlatformIO lib set, and the real ESP `src/ble/` implementation is excluded from the firmware build. Persisted or restored `ble_enabled=true` values are normalized back to `false`. +Bluetooth is intentionally absent from Settings. For the ESP Arduino firmware profile, `TRAIL_MATE_ENABLE_BLE=0`, the NimBLE dependency is not part of the PlatformIO lib set, and the real ESP `src/ble/` implementation is excluded from the firmware build. Any legacy `ble_enabled=true` value is normalized back to `false`. ### Settings implementation contract @@ -105,7 +105,7 @@ Settings items are bound through `SettingId` and `settings::ui::spec` instead of Resource constraints are part of the contract: - MT/MC channel key helpers use fixed-size caller-owned buffers and bounded key lengths. -- SD backup/restore blob handling uses bounded stack buffers instead of dynamic byte vectors. +- SD working-configuration blob handling uses bounded static/PSRAM scratch storage instead of dynamic byte vectors. - Wi-Fi scan fills the Settings page's fixed network slots directly; it does not allocate an intermediate dynamic scan list. ## 5) Phone Independence Boundary @@ -116,7 +116,7 @@ The device-side goal is to operate without a phone. The first required channel-m - Meshtastic Secondary channel: enabled flag, name, PSK, MQTT uplink/downlink flags. - MeshCore channel: slot, name, key. -These settings write into the existing `AppConfig` / `MeshConfig` fields and use the same NVS and SD backup/restore paths as the runtime configuration. Meshtastic PSK generation creates a 16-byte key by default, encoded as 32 uppercase hex characters; manual entry also accepts the existing 16/32-byte Meshtastic PSK formats. MeshCore channel key generation creates a 16-byte key. +These settings write into the existing `AppConfig` / `MeshConfig` fields and use the same NVS-mirrored, SD-first working-configuration path as the runtime configuration. Meshtastic PSK generation creates a 16-byte key by default, encoded as 32 uppercase hex characters; manual entry also accepts the existing 16/32-byte Meshtastic PSK formats. MeshCore channel key generation creates a 16-byte key. ## Current implementation status description diff --git a/docs/audits/LEGACY_COMPAT_TEMP_SURFACE_SCAN.md b/docs/audits/LEGACY_COMPAT_TEMP_SURFACE_SCAN.md index ea6bce29..37a5bee7 100644 --- a/docs/audits/LEGACY_COMPAT_TEMP_SURFACE_SCAN.md +++ b/docs/audits/LEGACY_COMPAT_TEMP_SURFACE_SCAN.md @@ -620,7 +620,7 @@ must be interpreted through `LEGACY_COMPAT_TEMP_SURFACE_INVENTORY.md`. | `platform/esp/arduino_common/src/mesh/esp_preferences_mesh_identity_store.cpp` | `legacy` | | `platform/esp/arduino_common/src/platform_ui_firmware_update_runtime.cpp` | `fallback` | | `platform/esp/arduino_common/src/platform_ui_hostlink_runtime.cpp` | `bridge` | -| `platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp` | `fallback`, `probe` | +| `platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp` | `fallback`, `probe` | | `platform/esp/arduino_common/src/rnode_kiss/rnode_kiss_service.cpp` | `adapter` | | `platform/esp/arduino_common/src/sstv/sstv_service.cpp` | `compat` | | `platform/esp/arduino_common/src/team/event/team_app_data_event_bus_bridge.cpp` | `bridge` | diff --git a/docs/config_persistence_architecture.md b/docs/config_persistence_architecture.md index 7f060db8..016539a2 100644 --- a/docs/config_persistence_architecture.md +++ b/docs/config_persistence_architecture.md @@ -108,44 +108,37 @@ by the same mechanism: | `AppConfig::chat_policy.max_channels` | AppConfig / `chat` section | Persisted as part of `Channels` | | `AppConfig::reticulumConfig().reticulum_groups` | Reticulum group storage | Runtime mirror only; loaded and saved by its own SD-backed owner | | `AppConfig::ble_enabled` | BLE runtime policy | Not an AppConfig persistence field on ESP Arduino; the backend currently keeps it disabled | -| Settings backup JSON | Settings backup service | Separate full backup/restore format, not the async NVS section writer | +| `/trailmate/config.tms` working projection | SD-first configuration owner | Complete persisted `AppConfig`, NVS-backed settings, saved Wi-Fi profiles, and supported cellular settings; NVS is its durable mirror and fallback | Adding a field to `AppConfig` does not make it persistent automatically. The field must be assigned to a domain, included in change detection, and handled by the owning adapter, or explicitly documented as runtime-only. -## Portable Settings Backup (ESP) +## SD Working Configuration (ESP) -Normal operation and portable backup deliberately have different roles: +`/trailmate/config.tms` is the only editable working configuration. It is a +bounded, line-streamed TMS document—not JSON and not a whole-file object. On +startup, a valid document is applied before Preferences/NVS; its AppConfig and +independent settings values are then mirrored to NVS. If the card is absent or +the document is absent, NVS supplies the fallback values. Invalid documents are +retained for repair and do not partially apply. -| Concern | Active owner | Backup behavior | +| Concern | Active owner | Working-document behavior | | --- | --- | --- | -| `AppConfig` domains | Preferences/NVS | The complete persisted `AppConfig` projection is exported to the SD backup document. | -| Device and presentation preferences outside `AppConfig` | Preferences/NVS through `settings_store` | Every supported key is exported with its logical name and an explicit presence state, then restored through `settings_store`, so NVS key aliases remain an implementation detail. | -| Reticulum groups | `platform::ui::reticulum_groups` SD owner | The group list is embedded in the backup and restored through the group owner; restore does not report success until that owner has completed its physical SD write. | +| `AppConfig` domains | SD working document with Preferences/NVS mirror | All persisted fields are read from valid `config.tms` before NVS and are canonically rewritten after sync. | +| Device and presentation preferences outside `AppConfig` | `settings_store` / NVS mirror | The full supported set is written and validated under typed `ui.*`, `chat.*`, `debug.*`, and `power.*` keys. | +| Saved Wi-Fi credentials | Wi-Fi runtime / NVS mirror | An ordered exact set of zero through ten SSID/password profiles is validated as a whole before replacement. | +| A7682E settings | Cellular runtime / NVS mirror | The complete supported cellular block is emitted only on the A7682E product variant and is validated as a whole before application. | +| Reticulum groups | `platform::ui::reticulum_groups` SD owner | Purpose-specific group data remains in its own bounded SD file, not in generic configuration or NVS. | -This separation is intentional: an absent, replaced, or failed SD card must -not prevent the device from loading its active settings at boot. It does **not** -permit a partial portable backup. The settings backup is the aggregate boundary -for all user-configurable persistence owners listed above; messages, node -databases, map tiles, and diagnostic logs remain data stores rather than -settings and are outside this backup. - -The current JSON schema is version 2. Version 1 documents remain importable: -fields added in version 2 are optional during restore and retain their current -value when absent, avoiding destructive resets from an older backup. Version 2 -adds the Meshtastic channel presentation settings, Reticulum location-request -policy, Reticulum group owner, and presence markers for `settings_store` -preferences to the portable snapshot. A v2 `present: false` marker clears the -target-side logical key, returning it to the same code-defined default used by -the source device. An omitted key in a v1 document remains non-destructive for -backward compatibility. - -During restore, the Reticulum group owner completes its physical SD write -before NVS preferences and `AppConfig` are committed. Therefore an SD group -write failure leaves those NVS owners untouched. This is deliberate failure -ordering, not a cross-owner transaction: a later NVS failure still cannot be -rolled back across independent owners. +NVS changes use a tiny write-ahead marker. The foreground lifecycle atomically +rewrites `config.tms` after the NVS write; a card removal or power loss between +those steps cannot make an older SD copy overwrite the newer NVS state at boot. +The document uses schema 3, while schema-2 AppConfig-only documents are loaded +for migration and rewritten as a complete schema-3 file on the next successful +sync. There is no second Settings Backup/Restore schema: another document on +the same SD card does not protect the NVS-erasure failure mode and would create +a second source of truth. ## Edit Boundary And Platform Semantics diff --git a/docs/design/use-case-diagrams-maps.md b/docs/design/use-case-diagrams-maps.md index 3b842749..96e827ed 100644 --- a/docs/design/use-case-diagrams-maps.md +++ b/docs/design/use-case-diagrams-maps.md @@ -22,7 +22,7 @@ Design Explorer organizes content by "what the user wants to accomplish, what th | Communications, media and delivery | 5 | Messages, real-time calls, Walkie, and SSTV all have independent behaviors and failure paths | | Maps, positioning and on-site awareness | 5 | Maps, GNSS, trajectories, and spectrum have been implemented; route navigation rules are still in the UI runtime | | Team collaboration | 2 | Pairing, keys and shared messages exist; member lifecycle model is still incomplete | -| Device maintenance and data ownership | 4 | Package, Firmware, Backup, USB all have resource/commit boundaries | +| Device maintenance and data ownership | 4 | Package, Firmware, SD configuration lifecycle, and USB all have resource/commit boundaries | | External application and host integration | 2 | HostLink and Phone BLE are two different sets of integration contracts | ## Use Case Diagram Index @@ -46,7 +46,7 @@ Use-case:connect-wifi-services | use-case:share-team-situation | [Share team location, waypoints, tracks and chat](use-case-diagrams/share-team-situation.md) | [HTML](use-case-diagrams/share-team-situation.html) | 2 | Trail Mate / Team collaboration | confirmed | high | source-audit | 2026-07-23 | | use-case:manage-extension-packages | [Install, update, or uninstall extension packages](use-case-diagrams/manage-extension-packages.md) | [HTML](use-case-diagrams/manage-extension-packages.html) | 3 | Trail Mate / Device Maintenance and Data Ownership | confirmed | high | source-audit | 2026-07-23 | | use-case:update-device-firmware | [Check and install device firmware updates](use-case-diagrams/update-device-firmware.md) | [HTML](use-case-diagrams/update-device-firmware.html) | 3 | Trail Mate / Device Maintenance and Data Ownership | confirmed | high | source-audit | 2026-07-23 | -| use-case:backup-restore-settings | [Backup, restore, or reset device settings](use-case-diagrams/backup-restore-settings.md) | [HTML](use-case-diagrams/backup-restore-settings.html) | 2 | Trail Mate / Device maintenance and data ownership | confirmed | high | source-audit | 2026-07-23 | +| use-case:backup-restore-settings | [Maintain or reset the SD working configuration](use-case-diagrams/backup-restore-settings.md) | Rendered copy retired | 2 | Trail Mate / Device maintenance and data ownership | implemented | high | source-audit | 2026-08-20 | | use-case:expose-usb-storage | [Leave device storage security to the USB host](use-case-diagrams/expose-usb-storage.md) | [HTML](use-case-diagrams/expose-usb-storage.html) | 3 | Trail Mate / Device maintenance and data ownership | confirmed | high | source-audit | 2026-07-23 | | use-case:hostlink-data-exchange | [Exchange application data with external hosts through HostLink](use-case-diagrams/hostlink-data-exchange.md) | [HTML](use-case-diagrams/hostlink-data-exchange.html) | 3 | Trail Mate / External application and host integration | confirmed | high | source-audit | 2026-07-23 | | use-case:sync-phone-application | [Provide protocol compatibility services to mobile applications](use-case-diagrams/sync-phone-application.md) | [HTML](use-case-diagrams/sync-phone-application.html) | 3 | Trail Mate / External application and host integration | confirmed | high | source-audit | 2026-07-23 | diff --git a/docs/design/use-case-diagrams/backup-restore-settings.html b/docs/design/use-case-diagrams/backup-restore-settings.html deleted file mode 100644 index 31aed1f6..00000000 --- a/docs/design/use-case-diagrams/backup-restore-settings.html +++ /dev/null @@ -1 +0,0 @@ -Back up, restore or reset device settings

Back up, restore or reset device settings

Backup uses temporary files and atomic replacement; restore first verify; four types of reset have different damage scopes.

diff --git a/docs/design/use-case-diagrams/backup-restore-settings.md b/docs/design/use-case-diagrams/backup-restore-settings.md index 57c8d9d2..6a709d42 100644 --- a/docs/design/use-case-diagrams/backup-restore-settings.md +++ b/docs/design/use-case-diagrams/backup-restore-settings.md @@ -1,20 +1,39 @@ -# Use Case: Back up, restore or reset device settings +# Use Case: Maintain or reset the SD working configuration -Status: **confirmed behavior; configuration model incomplete** -Business boundary: device maintenance and data ownership +Status: **implemented design** +Business boundary: device configuration ownership and reset semantics ## User Goals -Create a local backup before modifying complex protocol/GNSS/display/network settings; verify and restore when needed; only perform deletion of messages, nodes, mesh settings or factory reset after explicit confirmation. +Edit a complete device configuration on a computer when on-device text entry is +impractical, especially for channel keys, MQTT credentials, Wi-Fi passwords, and +cellular credentials. A normal restart must load that configuration even when NVS was +erased or changed by another firmware. Reset operations must delete only their stated +data and cannot be undone by an old SD document on the next boot. ## Behavior and Rules -1. Backup checks SD availability, aggregates all user settings owner: `AppConfig`, `settings_store` preferences and Reticulum group, and atomically replaces the target backup after writing the temporary file. Each supported key in `settings_store` carries an existing state; the default state is recorded when not explicitly written by the origin. -2. Restore first parses/verifies the backup; the Reticulum group must be handed over to its SD owner to complete the actual placement, and cannot only update the runtime image of `AppConfig`. Only after the SD is successfully written, the NVS preferences are written and `AppConfig` is submitted, so the failure of the SD will not change these two owners. After success, restart/reinitialize owner as needed. -3. Reset Mesh, Reset Nodes, Clear Messages and Factory Reset are different damage scopes and must be confirmed separately. -4. The current `AppConfig` lacks a unified schema version, cross-field validation, and an atomic ConfigurationService across NVS/SD owners; the document cannot promise a complete transaction beyond what the runtime actually supports. Set the backup format to currently be schema v2: Restoring a preference of `present: false` will clear the target NVS override and return to the default state; v1 can be imported and will not clear v2's new fields or its unlisted preferences. +1. `/trailmate/config.tms` is the only working configuration document. It contains the + `AppConfig` projection plus every supported independent setting owner, including the + ordered ten-profile Wi-Fi set and, on supported hardware, A7682E configuration. +2. The firmware validates the full document with bounded line storage before applying + it. A valid SD document loads before NVS; its values then mirror to NVS. If no valid + document exists, current NVS values are imported once into a canonical document. +3. Every NVS-backed settings owner emits a coalesced change notification. A tiny + write-ahead marker is committed immediately; the foreground lifecycle atomically + rewrites the SD document. Therefore power loss before the SD write makes boot retain + newer NVS instead of resurrecting an older SD projection. +4. JSON is not used and no whole-document object is constructed. Parsing uses one + bounded line, while the only multi-record staging area is a static/PSRAM Wi-Fi + profile set needed to validate all ten profiles before replacement. +5. Reset Mesh, Reset Nodes, Clear Messages, and Factory Reset remain distinct confirmed + actions. Factory Reset first removes `config.tms` and its commit metadata, then clears + NVS. It never restarts into an old SD configuration. -Source code: `modules/core_sys/include/platform/ui/settings_backup_runtime.h`, `platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp`, Settings reset actions. +There is no Settings Backup/Restore operation. It duplicated configuration schema and +was intended to protect NVS from cross-firmware writes; the complete SD working file is +already the durable authority for that failure model. A second file on the same SD card +would not protect against loss or corruption of that card. ## Drill down diff --git a/docs/design/use-case-diagrams/backup-restore-settings/activity.html b/docs/design/use-case-diagrams/backup-restore-settings/activity.html deleted file mode 100644 index e5f9a028..00000000 --- a/docs/design/use-case-diagrams/backup-restore-settings/activity.html +++ /dev/null @@ -1,7 +0,0 @@ -Activity: Settings Protection

Backup, Restore and Reset

flowchart TD
-Intent -- Backup --> Temp["temp + atomic replace"]
-Intent -- Restore --> Validate{"valid backup?"}
-Validate -- Yes--> Apply
-Validate -- No--> Keep
-Intent -- Reset --> Confirm{"confirm exact scope?"}
-Confirm -- Yes--> Scoped["scoped destructive action"]
diff --git a/docs/design/use-case-diagrams/backup-restore-settings/activity.md b/docs/design/use-case-diagrams/backup-restore-settings/activity.md index a5334e78..eb5b8ba2 100644 --- a/docs/design/use-case-diagrams/backup-restore-settings/activity.md +++ b/docs/design/use-case-diagrams/backup-restore-settings/activity.md @@ -1,33 +1,32 @@ -# Activity: Backup, restore and reset +# Activity: SD working configuration and reset ```mermaid flowchart TD - Intent{"Backup / Restore / Reset?"} - Intent -- Backup --> SD{"SD ready?"} - SD -- No --> Error - SD -- Yes --> Temp["serialize supported settings to temp"] - Temp --> Atomic{"atomic replace success?"} - Atomic -- Yes --> Success - Atomic -- No --> Error - Intent -- Restore --> Parse{"backup parse + validate?"} - Parse -- No --> Keep["Keep current settings"] - Parse -- Yes --> Apply["apply supported settings"] - Apply --> Reinit["reinitialize/reboot affected owners"] - Intent -- Reset --> Confirm{"Show specific damage scope and confirm?"} + Boot{"Boot / setting change / reset?"} + Boot -- Boot --> SD{"valid config.tms?"} + SD -- Yes --> Validate["stream validate full document"] + Validate -- Valid --> Apply["apply SD config then mirror NVS"] + SD -- No --> NVS["load NVS then materialize config.tms"] + Boot -- Setting change --> Marker["commit tiny NVS write-ahead marker"] + Marker --> Flush["foreground loop atomically rewrites config.tms"] + Boot -- Reset --> Confirm{"Show specific damage scope and confirm?"} Confirm -- No --> Cancel - Confirm -- Yes --> Scoped["Execute mesh/nodes/messages/factory Corresponding to cleanup"] + Confirm -- Yes --> Scoped["Execute mesh/nodes/messages cleanup"] + Scoped --> Factory{"Factory reset?"} + Factory -- Yes --> Remove["remove config.tms + metadata, then clear NVS"] ``` ## Questions answered by this picture -How do backup, recovery and reset of four different damage scopes share configuration boundaries while avoiding bad backups from overwriting current settings or vague confirmations leading to excessive deletion. +How does one SD working document preserve complete settings while avoiding multiple +configuration schemas, unsafe timer-context SD writes, and reset actions that are +silently reversed by an old SD file. -## Backup +## SD authority -Backup only serializes explicitly supported and migrable fields, writes them to a temporary file and replaces them atomically after a successful flush/close. The inclusion of sensitive keys must be determined by the format version and user intent. Old backups remain available until new files are committed. - -## Recovery - - Recovery first parses the version, verification structure, target compatibility and each field constraint, and then constructs a complete candidate configuration. Any validation failures retain the current settings. Reinitialize in the order of affected owners after Apply; changes that require reboot are not pretended to have taken effect immediately. +The SD document is validated before any setting owner changes. It is then applied as a +complete candidate, mirrored to NVS, and canonicalized with an atomic temporary-file +replace. Unknown future keys remain forward compatible; malformed known keys leave the +existing NVS configuration untouched. ## Reset range @@ -36,14 +35,18 @@ Backup only serializes explicitly supported and migrable fields, writes them to | Mesh reset | Clear mesh data such as protocol configuration, identity and channel | | Nodes reset | peer/node directory and local relationship | | Messages reset | Session, message and delivery ledger | -| Factory reset | All user configuration and data defined in the document | +| Factory reset | Removes the SD working authority and all user configuration/data defined by the reset contract | The confirmation interface must display the specific range and cannot be replaced by the same "Are you sure?" Cancellation does not have any persistence side effects. ## Failure and recovery -SD unavailability, temporary write failures, atomic replacement failures, and reinitialize failures are reported separately. If the recovery spans multiple owners, complete candidates must be verified in advance and the stages must be recorded; unexplained mixed configurations cannot be left behind after a failure occurs midway. +SD unavailability, temporary write failures, and atomic replacement failures are +reported separately. The immediate NVS marker makes an interrupted mirror safe: the +next boot selects newer NVS rather than applying a stale SD document. ## Testing - Covers format upgrades, unknown fields, damaged backups, atomic replacement failures, sensitive field policies, negative deletion assertions for each reset, and configurations that require restarting. +Covers schema upgrades, unknown fields, malformed documents, atomic replacement +failures, all ten Wi-Fi profile records, sensitive field handling, and negative +deletion assertions for each reset scope. diff --git a/docs/design/use-case-diagrams/backup-restore-settings/sequences/sequence-backup-restore-settings.html b/docs/design/use-case-diagrams/backup-restore-settings/sequences/sequence-backup-restore-settings.html deleted file mode 100644 index 49e07630..00000000 --- a/docs/design/use-case-diagrams/backup-restore-settings/sequences/sequence-backup-restore-settings.html +++ /dev/null @@ -1,12 +0,0 @@ -Sequence:Settings Backup

Settings Backup Store

sequenceDiagram
-participant UI
-participant B as Backup Runtime
-participant C as Config Stores
-participant SD
-UI->>B: backup
-B->>C: read
-B->>SD: temp + atomic replace
-UI->>B: restore
-B->>SD: read
-B->>B: validate
-B->>C: apply
diff --git a/docs/design/use-case-diagrams/backup-restore-settings/sequences/sequence-backup-restore-settings.md b/docs/design/use-case-diagrams/backup-restore-settings/sequences/sequence-backup-restore-settings.md index e82ef9c5..79c58d9e 100644 --- a/docs/design/use-case-diagrams/backup-restore-settings/sequences/sequence-backup-restore-settings.md +++ b/docs/design/use-case-diagrams/backup-restore-settings/sequences/sequence-backup-restore-settings.md @@ -1,40 +1,39 @@ -# Sequence:Settings Backup Store +# Sequence: SD working configuration ```mermaid sequenceDiagram - actor U as user - participant UI as Settings - participant Backup as Settings Backup Runtime - participant Config as Config Stores participant SD as SD Filesystem - U->>UI: Backup - UI->>Backup: backup() - Backup->>Config: read supported settings - Backup->>SD: write temp + fsync/close + replace - SD-->>UI: success / error - U->>UI: Restore - UI->>Backup: restore() - Backup->>SD: read backup - Backup->>Backup: parse + validate - Backup->>Config: apply only after validation - Config-->>UI: reinitialize/reboot required + participant TMS as TMS Runtime + participant Config as Config Stores + participant Loop as Foreground lifecycle + SD->>TMS: config.tms at boot + TMS->>TMS: stream validate complete document + TMS->>Config: apply only after validation + TMS->>SD: atomically write canonical config.tms + Config->>TMS: setting changed notification + TMS->>Config: commit small write-ahead marker + Loop->>TMS: service pending mirror + TMS->>SD: atomically replace config.tms ``` ## Scenarios and responsibilities -Settings collects user commands; Backup Runtime defines versioned formats and transactions; Config Stores provides supported fields and atomic applications; SD Filesystem is only responsible for file semantics. The UI does not traverse or overwrite individual configuration files directly. +The TMS runtime defines one versioned grammar and atomic file replacement. Individual +configuration owners continue to own their runtime application, but cannot drift from +the SD projection because their durable write notifications are coalesced through the +same mirror service. -## Backup sequence +## Boot and save order -Read supported settings to form an immutable snapshot, write temporary files, flush/fsync/close and then atomic replace. Any stage failure retains old backups. The UI will only display the new backup time after receiving replace successfully. - -## Restore order - -First complete read, parse, version migration and field verification, and then call Config apply. No owner must be written if validation fails. Apply returns the effective policy for each owner: immediate reinitialize, next startup, or must reboot. +Boot validates before any application. On a normal save, NVS commits first, then a +small metadata record makes an interrupted SD projection safely stale. The foreground +lifecycle performs the SD I/O, so Wi-Fi profile retry timers never access the card. ## Consistency and sensitive data -Apply across multiple Config Stores requires aggregation of validation and controlled submission; otherwise, a mixed version will be formed if it fails midway. The backup format clearly marks whether sensitive keys are included and avoids outputting values ​​in the error log. +The document includes all supported sensitive configuration because it is the working +authority. It is plaintext and must never be logged or shared unredacted. ## Tests -Cover temp write/close/replace failure, old version migration, unknown fields, cross-Store verification failure, partial apply and reboot-required projections. +Cover temp write/close/replace failure, schema v2-to-v3 migration, unknown fields, +cross-owner validation, all ten Wi-Fi profiles, and factory-reset SD removal. diff --git a/docs/meshcore/v2_multibyte_paths.md b/docs/meshcore/v2_multibyte_paths.md index b7e71d12..59add4e6 100644 --- a/docs/meshcore/v2_multibyte_paths.md +++ b/docs/meshcore/v2_multibyte_paths.md @@ -393,7 +393,8 @@ The old repeat switch still controls whether this node repeats at all. The new forwarding policy controls which packet profiles may be repeated when repeat is enabled. -Settings backup/restore must include the new values with stable JSON keys. +The SD-first working configuration must include the new values with stable TMS +keys, alongside their NVS mirror. ## BLE And Phone Protocol Extension @@ -450,7 +451,7 @@ model: adapter must at least use explicit profile headers, V2 peer/anonymous request hashes/MACs when pubkeys are available, and `sendRawDataEx` for external V2 path reuse. -10. Extend settings persistence, UI settings, and backup/restore. +10. Extend settings persistence, UI settings, and the SD-first working configuration. 11. Extend BLE/phone path/contact protocol so V2 paths can be displayed and reused. 12. Update docs and tests. diff --git a/docs/specification/SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md b/docs/specification/SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md index 47cc40df..a18cd491 100644 --- a/docs/specification/SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md +++ b/docs/specification/SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md @@ -3,7 +3,7 @@ Status date: 2026-07-09 This document defines the next phase of retrofit specifications for Trail Mate settings. It is not a minor UI - tidying up, but a re-straightening of "what the configuration is, which protocol it belongs to, how to display it, how to persist it, how to restore it from SD, and how it + tidying up, but a re-straightening of "what the configuration is, which protocol it belongs to, how to display it, how to persist it as an SD-first working document, and how it applies it to the runtime". This specification will be documented first and the runtime code will not be changed. The actual implementation must be carried out in stages according to this article, and GitNexus impact analysis must be performed according to the warehouse rules before modifying any @@ -20,8 +20,8 @@ The target user scenario is that Trail Mate can work without a mobile phone: configuration must not be automatically changed to enabled when Wi-Fi is on. - If a protocol is configured with MQTT and the runtime is using the Wi-Fi MQTT transport, the protocol corresponding BLE phone dependency should be suppressed or turned off to avoid users mistakenly thinking that the phone must still be connected. -- All user configurations must be persistent and restoreable from SD backups. -- Backup restore cannot sacrifice ESP memory safety for "human readability"; the current whole package JSON/cJSON schema needs to be replaced. +- All user configuration must be persistent in the SD-first working document and mirrored to NVS. +- SD configuration cannot sacrifice ESP memory safety for "human readability"; the former whole-package JSON/cJSON schema is replaced by bounded TMS streaming. ## Distinctions @@ -35,7 +35,7 @@ These concepts must be separated in code and UI, and cannot continue to be mixed | Transport | Bearing methods such as LoRa, BLE phone link, Wi-Fi MQTT, Reticulum TCP/UDP | protocol itself | | Conversation | Broadcast session, private chat session, contact context in the UI | Meshtastic channel slot or MeshCore channel slot | | Device settings | Screen, language, GPS, map, Wi-Fi, owner name, privacy and other cross-protocol settings | Profile of the current active protocol | -| Persistence | NVS/Preferences/IDF store/SD backup placement fact | UI widget state | +| Persistence | SD working document/NVS/Preferences/IDF store placement fact | UI widget state | | Apply runtime | Apply configuration changes to radio, MQTT, BLE, GPS, privacy policy | Save configuration | ### Channel, Radio, Broadcast, Private @@ -70,7 +70,7 @@ This section records the current implementation facts as a baseline before trans | GTK settings | `apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_settings_logic.cpp` | There is a prototype of stack/page switching by protocol, which can be used as a reference for the "protocol page" idea, but the GTK widget logic should not be copied directly | | Arduino ESP persistence | `platform/esp/arduino_common/src/app_config_store.cpp` | Use Preferences to save by field, a large number of keys already exist, but field coverage relies on handwriting load/save to keep synchronized | | IDF persistence | `apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp` | Put the entire `AppConfig` is packaged into raw blob, and the version is determined by `sizeof(AppConfig)`. Once the structure becomes old, the configuration will be rejected | -| SD settings backup | `platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp` | The current path is `/trailmate/settings-backup.json`, using cJSON Construct/parse the entire tree and read the entire file | +| SD working configuration | `platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp` | `/trailmate/config.tms` is a bounded, line-streamed, SD-first configuration authority; it mirrors every supported settings owner to NVS. | | Store API | `modules/core_sys/include/platform/ui/settings_store.h` | Also provides `get_blob(std::vector&)` and `get_blob_into(...)`; new ESP paths should preferentially use the bounded buffer version | | Apply facade | `modules/core_sys/include/app/app_facades.h` | UI can be modified directly with `getConfig()`, and then call `saveConfig()`, `applyMeshConfig()`, `applyUserInfo()` and other apply methods | @@ -90,29 +90,30 @@ This section records the current implementation facts as a baseline before trans `AppConfig` and `MeshConfig` are currently runtime large objects. They can serve as transitional compatibility layers, but cannot continue to be Long-term settings schema. Reason: -- The field ownership is unclear, causing UI, Preferences, SD backup, and protocol apply to each record a copy of the facts. +- The field ownership is unclear, causing UI, Preferences, the SD working projection, and protocol apply to each record a copy of the facts. - ESP stack hygiene has listed `AppConfig` and `MeshConfig` as dangerous automatic local types. - When adding a channel list or more protocol profiles, if you continue to stuff these two structs, the memory risk will continue to amplify. ### 3. Persistence is not schema-driven Arduino Preferences is currently saved by field, which is more stable than raw blob, but each field requires hand-written load/save, default value, and -migration logic. When adding a settings field, it is easy to miss SD backup or a certain UI. +migration logic. When adding a settings field, it is easy to miss the SD working projection or a certain UI. IDF raw blob uses `sizeof(AppConfig)` as the compatibility condition, which is very fragile to subsequent split structures. Any `AppConfig` layout changes may cause the old configuration to fail to load. -### 4. SD JSON backup is too heavy +### 4. Historical SD JSON backup was too heavy The advantage of the current JSON solution is that it is readable, but the cost is high on ESP: -- restore needs to read the entire file into memory. +- JSON restore needed to read the entire file into memory. - cJSON parse will construct the entire tree. -- backup print will generate the complete string. +- JSON backup print generated the complete string. - A combination of `std::string`, `std::vector`, whole-document parse/print exists in the old code. -This conflicts with the goal of "configuration can be completely dropped to SD and can be restored reliably". The recovery configuration should be a low-memory path, not the path that -easiest pushes the device to the heap/stack boundary. +This conflicts with the goal of a complete SD working configuration. The current +TMS path is line-streamed and bounded, so it does not construct a DOM or a +whole-document allocation. ### 5. Apply semantics are scattered @@ -160,7 +161,7 @@ The long-term structure should divide the configuration into these owners: | `MeshCoreProfile` | radio profile、channel slot/name/key、public-channel fallback、MeshCore MQTT | | `ReticulumProfile` | identity、LoRa interface、Wi-Fi interface、LXMF/announce groups | | `ChatPresentationSettings` | active conversation defaults、notification/presentation preferences | -| `BackupRestoreSettings` | backup version、restore policy、sensitive export policy | +| `SdWorkingConfiguration` | schema version, SD/NVS authority, sensitive plaintext warning, and migration policy | These owners can be mapped to the existing `AppConfig` fields first, but the schema naming must be designed according to the owner first to avoid continuing to stuff the three protocols back into `Chat` / `Network` in the future. @@ -201,7 +202,7 @@ The top level of Settings is recommended to be split into: - MQTT - GPS & Map - Privacy -- Backup & Restore +- Configuration lifecycle and reset - Diagnostics The contents of `Protocol`, `Channels`, and `MQTT` are determined by the active protocol. @@ -266,11 +267,11 @@ The default MQTT preset cannot use a personal broker as the default value. The d The implementation does not hard-code a personal domain name as the default. Personal brokers can exist in custom presets or user configurations. -## SD Backup Format +## SD Working Configuration Format ### Decision -The new format does not use JSON by default. +The working configuration does not use JSON. Using line-oriented typed key-value format, the goal is: @@ -281,21 +282,22 @@ Using line-oriented typed key-value format, the goal is: - No need to read the entire file into `std::string`. - No need for `std::vector` to accept whole blob. -Recommended file name: +Authoritative file names: ```text -/trailmate/settings-backup.tms -/trailmate/settings-backup.tmp +/trailmate/config.tms +/trailmate/config.tms.tmp ``` -`.json` Old files can be used as legacy restore input during the migration period, but new backups must be written out using `.tms`. +There is no parallel settings backup/restore file. A second schema on the same SD card +would not protect the NVS-erasure scenario and would reintroduce consistency drift. ### Format Sketch ```text -TMSET2 -schema.version=u16:2 -created.unix=u32:1783500000 +TMSET3 +schema.version=u16:3 +document.kind=enum:working device.owner.long=str:Trail Mate device.owner.short=str:TM protocol.active=enum:meshtastic @@ -316,36 +318,39 @@ mc.channel.name=str:public mc.channel.key=hex: mc.mqtt.enabled=bool:0 -checksum.crc32=hex:89ABCDEF +wifi.enabled=bool:1 +wifi.profile_count=u8:2 +wifi.profile.0.ssid=str:Office +wifi.profile.0.password=str:secret ``` Rules: -- First line is magic: `TMSET2`. -- Max line length is fixed, initially 256 or 384 bytes. Any longer line is skipped with a diagnostic. +- First line is magic: `TMSET3`. +- Max content line length is 383 bytes; max document size is 32 KiB. Any longer line is rejected before apply. - Key is ASCII stable storage key. - Type prefix is mandatory. - Strings are bounded by descriptor metadata. - Hex blob max length is bounded by descriptor metadata before decoding. - Unknown keys are ignored but counted. - Known key with invalid value is rejected and reported, not partially applied. -- Restore writes through `SettingsTransaction`; it must not directly mutate random globals. -- Backup write uses temp file + fsync/close + rename where backend supports it. -- CRC covers all lines before checksum. +- Validation completes before independent settings owners are written; a static/PSRAM staging area is used only for the ten Wi-Fi profiles. +- Every normal settings write first commits NVS metadata, then a foreground service uses temp file + flush/close + replace to update `config.tms`. ### Why Not Binary TLV First Binary TLV is smaller and faster, but it is harder to inspect and repair on SD. The line KV format is the better first target because it keeps manual recovery possible without the cJSON memory cost. A future binary -TLV export can be added for factory/provisioning use, but it should not be the only user backup format. +TLV export can be added for factory/provisioning use, but it should not replace the editable user working format. ### Sensitive Fields -In order to meet "complete recovery from SD", Wi-Fi password, MQTT password, channel PSK should be able to enter the backup. The UI must -mark these fields as sensitive and provide clear prompts in the manual export/restore interface. +To make SD the complete working authority, Wi-Fi passwords, MQTT passwords, channel +PSKs, and cellular credentials are emitted into `config.tms`. The UI and logs must +continue to treat them as sensitive. -In implementation, sensitive only affects UI rendering and log desensitization, but does not mean that it will not be deleted. When the user selects SD backup, the goal is -to restore the complete configuration of an offline device. +The document is plaintext. Sensitivity controls rendering and log redaction; it does +not encrypt the SD card. ## Persistence Model @@ -368,19 +373,22 @@ raw `sizeof(AppConfig)` blob can only be used as legacy input. The new path must - New saves no longer depend on `sizeof(AppConfig)`. - If compact snapshot is retained for startup speed, there must also be independent schema version and field-level fallback. -### SD Backup +### SD Working Configuration -SD backup is a cross-store restore source, not the only store at runtime. NVS should not be overwritten from SD every time on boot. -Restore should be an explicit action: +`/trailmate/config.tms` is the startup authority when complete and valid. It +is parsed and validated in a streaming pass before any supported independent +settings owner is modified; the second pass applies it and mirrors values to +NVS. When the card or file is absent, NVS provides the fallback. A small NVS +write-ahead marker prevents an interrupted SD mirror write from making a stale +file overwrite a newer NVS change on the next boot: ```text -User chooses Restore - -> parse .tms stream - -> validate descriptors - -> build transaction - -> persist to primary store - -> apply affected runtimes - -> emit UI result +durable setting change + -> commit NVS value + -> commit tiny NVS stale-replica marker + -> foreground service streams a canonical config.tms temporary file + -> flush, replace document and commit sidecar + -> mark SD replica synchronized ``` ## Memory Budget Rules @@ -390,7 +398,7 @@ Actual implementation must comply with: - Do not create `AppConfig`, `chat::MeshConfig`, protobuf frame, large byte array on ESP task stack. - Settings UI edit session does not copy the entire `AppConfig`; only saves the field-level dirty value or active editor buffer. -- SD restore does not read the complete file, does not construct the tree, and does not use `cJSON_ParseWithLength` as the new path. +- SD working-configuration import does not read the complete file, does not construct the tree, and does not use `cJSON_ParseWithLength`. - Do not introduce `std::deque` to ESP BLE/Meshtastic bridge headers. - New schema table uses static/constexpr storage. - The new channel list uses a fixed upper limit and explicit drop/error policy, and cannot grow without bounds. @@ -400,11 +408,11 @@ Actual implementation must comply with: This refactor is delivered as one cohesive feature package, not as user-visible partial phases. The steps below are an internal construction sequence only. The final deliverable must include -schema, protocol-aware UI, persistence, SD backup/restore, runtime apply behavior, tests and +schema, protocol-aware UI, persistence, SD-first configuration, runtime apply behavior, tests and verification together. No intermediate state should be considered complete if it leaves settings half migrated, exposes -new protocol pages without matching persistence, or writes a new SD backup format without restore. +new protocol pages without matching persistence, or creates a second SD configuration authority. ### Slice 0: Specification and Audit @@ -412,7 +420,7 @@ Deliverables: - This document. - Current settings/persistence/apply code listing. - - Confirm JSON backup replacement direction. + - Confirm SD-first TMS authority and JSON retirement direction. No runtime behavior change. @@ -454,7 +462,7 @@ Replace `kChatItems` / `kNetworkItems` as primary organization. Deliverables: -- Device/Connectivity/Protocol/Channels/MQTT/GPS & Map/Privacy/Backup sections. +- Device/Connectivity/Protocol/Channels/MQTT/GPS & Map/Privacy/Maintenance sections. - Active protocol filter from descriptor metadata. - Board capability filter from descriptor metadata. - No business visibility based on `pref_key` string comparisons. @@ -465,9 +473,9 @@ Acceptance: - Selecting MeshCore shows MeshCore channel/MQTT/radio settings only. - Selecting Reticulum shows Reticulum interface/group settings and hides MQTT. -### Slice 4: Lightweight SD Backup +### Slice 4: Bounded SD Working Configuration -Replace default JSON backup writer/reader with `.tms`. +Replace the former JSON backup writer/reader with the startup-authoritative `.tms` working document. Deliverables: @@ -475,14 +483,14 @@ Deliverables: - streaming parser. - fixed max line length. - CRC. -- descriptor-backed export/restore coverage. -- legacy `.json` restore either removed or isolated behind explicit compatibility path with strict size cap. +- descriptor-backed working-TMS coverage. +- legacy schema v2 import that rewrites a complete schema v3 document after a successful boot. Acceptance: -- Full settings backup/restore succeeds without whole-file allocation. +- Full working configuration synchronization succeeds without whole-file allocation. - Unknown future keys are ignored safely. -- Sensitive fields restore correctly and logs are redacted. +- Sensitive fields import correctly and logs are redacted. ### Slice 5: Channel Management @@ -508,7 +516,7 @@ After migrations are covered by tests and field store is proven: - Stop writing raw `AppConfig` blobs. - Keep one-way read migration for a bounded release window. -- Remove legacy keys only after backup/restore and migration tests prove no supported user path is lost. +- Remove legacy keys only after working-configuration migration tests prove no supported user path is lost. ### Package Acceptance @@ -517,9 +525,9 @@ The package is not done until all of these are true: - Protocol selection changes visible settings, stored settings and runtime apply behavior together. - Meshtastic, MeshCore and Reticulum each have their own settings surface; hidden fields are hidden by descriptor/capability metadata, not by ad hoc string checks. -- Meshtastic MQTT and MeshCore MQTT can be configured independently and are persisted/restored. +- Meshtastic MQTT and MeshCore MQTT can be configured independently and are persisted in the working configuration. - Wi-Fi off stops MQTT runtime; Wi-Fi on does not auto-enable MQTT config. -- SD backup writes `.tms`, restore reads `.tms`, and all user settings covered by descriptors round trip. +- The SD working configuration writes and reads `.tms`, and all supported settings covered by descriptors round trip. - Legacy Preferences/IDF/raw config paths migrate into the new schema without losing existing user settings. - Settings UI does not create new large ESP stack objects or whole-config drafts. - Tests and stack hygiene checks pass for the touched areas. @@ -529,7 +537,7 @@ The package is not done until all of these are true: Before implementation PR/commit: - Run GitNexus impact analysis before each edited symbol, and warn before HIGH/CRITICAL edits. -- Run unit tests for descriptor coverage, migration, transaction diff and backup restore parser. +- Run unit tests for descriptor coverage, migration, transaction diff and the TMS parser. - Run `python3 scripts/check_esp_stack_hygiene.py` when touching settings save/load, ESP BLE, Meshtastic bridge, or app config code. - For PlatformIO build/upload/monitor, use background process + log polling as required by repo rules. @@ -543,7 +551,7 @@ Suggested tests: | protocol visibility matrix | MT/MC/Reticulum show different settings | | legacy Preferences migration | existing NVS keys map to schema fields | | IDF raw blob migration | old raw config can migrate once | -| `.tms` round trip | export -> restore produces equivalent config | +| `.tms` round trip | export -> boot import produces equivalent configuration | | `.tms` malformed input | long line, bad type, bad hex, unknown key, bad CRC handled safely | | MQTT policy matrix | Wi-Fi off stops runtime; Wi-Fi on does not enable config; protocol switch stops old runtime | | contact projection parity | MQTT receive and LoRa receive update contacts/nearby through same app event path | @@ -554,7 +562,7 @@ Suggested tests: - Do not add TLS for MQTT. - Do not disguise Reticulum as MQTT/channel page. - Don't force a generic `Channel` type onto three protocols. - - Don't use whole-document JSON in new backup paths. +- Don't use whole-document JSON in new SD configuration paths. - Discontinue using raw `sizeof(AppConfig)` as the new persistence format. - Do not continue to expand the `pref_key` string blacklist for fast UI hiding. - Don't stuff a lot of channel or QR/share payloads into `AppConfig`. @@ -563,12 +571,12 @@ Suggested tests: | Decision | Recommendation | | --- | --- | -| `.tms` max line length | Start at 256 bytes; allow 384 only if current MQTT/password fields need it | -| Legacy JSON restore | Keep one release as explicit compatibility restore with strict size cap, then remove | +| `.tms` max line length | Fixed at 383 content bytes; max document size is 32 KiB | +| Legacy schema migration | Accept AppConfig-only TMSET2/schema-2 documents once, retain current NVS device preferences, and rewrite canonical TMSET3 | | MeshCore public MQTT preset | Verify upstream/community default before hardcoding; otherwise default disabled with preset picker | | Meshtastic channel slot count | Implement current primary/secondary first, schema list-ready | | IDF protocol support | Current IDF runtime appears Meshtastic-only; full protocol UI must either expose capability limits or implement MC/RT there first | -| Sensitive backup UX | Default to full restore capability, with explicit warning/redaction rather than silently omitting secrets | +| Sensitive working-file UX | Keep full capability, with explicit plaintext warning/redaction rather than silently omitting secrets | ## Implementation Guardrail diff --git a/docs/ux_profiles/screen_240x320_mono_ui.md b/docs/ux_profiles/screen_240x320_mono_ui.md index a4571abd..b3642975 100644 --- a/docs/ux_profiles/screen_240x320_mono_ui.md +++ b/docs/ux_profiles/screen_240x320_mono_ui.md @@ -73,8 +73,8 @@ The catalogue maps the following stable capability entries to this projection: 2. **Text and secret settings** — node names, APN/MQTT endpoints and credentials. They are never represented as a toggle; a focused editor is required, and existing secret values are not echoed into the editor. - 3. **Maintenance or destructive actions** — firmware update, erase/reset, - backup/restore and modem recovery. They must keep their dedicated full + 3. **Maintenance or destructive actions** — firmware update, erase/reset, + modem recovery and factory reset. They must keep their dedicated full settings workflow and confirmation model; the compact projection does not turn them into one-tap commands. - Tracker, Walkie, SSTV and USB Storage: `RuntimeFeaturePort` snapshots and diff --git a/docs/wifi_access_resource_policy.md b/docs/wifi_access_resource_policy.md index 06a2247d..c7287f60 100644 --- a/docs/wifi_access_resource_policy.md +++ b/docs/wifi_access_resource_policy.md @@ -63,7 +63,8 @@ Reticulum Wi-Fi gateway, HTTP pre-connect, and OTA pre-connect paths. Trail Mate devices must remember up to ten Wi-Fi STA profiles. The legacy `wifi_ssid` and `wifi_password` settings are retained only as the current -preferred-profile projection for existing UI and backup compatibility; they are +preferred-profile projection for existing UI and SD working-configuration +compatibility; they are not the only source of Wi-Fi truth. Settings must not expose the saved profile list as a separate management UI. diff --git a/modules/core_sys/include/app/tms_config_codec.h b/modules/core_sys/include/app/tms_config_codec.h index 494d9310..43458a85 100644 --- a/modules/core_sys/include/app/tms_config_codec.h +++ b/modules/core_sys/include/app/tms_config_codec.h @@ -5,7 +5,7 @@ * The codec deliberately does not own a file, an AppConfig snapshot, or a * dynamically sized string. A platform adapter provides one reusable line * scratch buffer and streams it to or from durable storage. This keeps both - * normal configuration projection and portable backup/restore out of ESP task + * normal configuration projection and platform-owned extensions out of ESP task * stacks and avoids a whole-document JSON representation. */ @@ -20,8 +20,11 @@ namespace app::tms { constexpr std::size_t kMaxLineBytes = 384U; -constexpr std::size_t kMaxDocumentBytes = 16U * 1024U; -constexpr uint16_t kSchemaVersion = 2U; +// The document is streamed a line at a time. This limit bounds SD-card +// input without reserving document-sized RAM; it also leaves room for all ten +// Wi-Fi profiles and optional cellular credentials alongside AppConfig. +constexpr std::size_t kMaxDocumentBytes = 32U * 1024U; +constexpr uint16_t kSchemaVersion = 3U; struct LineScratch { @@ -63,7 +66,7 @@ struct Output bool (*write)(void* context, const char* data, std::size_t length) = nullptr; }; -// A non-owning record emitter used by platform-specific backup extensions. +// A non-owning record emitter used by platform-specific TMS extensions. // It writes through the same bounded scratch as the core document writer. class RecordWriter { @@ -89,8 +92,47 @@ class RecordWriter using RecordExtension = bool (*)(void* context, RecordWriter& writer); +// A non-owning decoded record exposed to platform extensions. It deliberately +// reuses the core codec's strict scalar, percent-escaped text, and base64 +// parsers so an extension cannot accidentally create a second TMS grammar. +class RecordReader +{ + public: + const char* key() const { return key_; } + bool boolean(bool* target = nullptr) const; + bool u8(uint8_t* target = nullptr, uint8_t maximum = UINT8_MAX) const; + bool u16(uint16_t* target = nullptr, uint16_t maximum = UINT16_MAX) const; + bool u32(uint32_t* target = nullptr) const; + bool i32(int32_t* target = nullptr) const; + bool text(char* target, std::size_t capacity) const; + bool blob(uint8_t* target, + std::size_t capacity, + std::size_t* decoded_length = nullptr) const; + + private: + friend class Decoder; + RecordReader(const char* key, const char* type, const char* value) + : key_(key), type_(type), value_(value) + { + } + + const char* key_ = nullptr; + const char* type_ = nullptr; + const char* value_ = nullptr; +}; + +enum class RecordConsumeResult : uint8_t +{ + Unhandled, + Accepted, + Invalid, +}; + +using RecordConsumer = RecordConsumeResult (*)(void* context, const RecordReader& reader); +using DocumentFinalizer = bool (*)(void* context, bool applying, uint16_t schema_version); + /** - * Emits a complete canonical TMSET2 document. `scratch` is caller-owned and + * Emits a complete canonical document for the current TMS schema. `scratch` is caller-owned and * must remain valid only for the call; it is never retained. Values that can * contain arbitrary bytes are base64 encoded, while text values use percent * escapes, so every emitted record is one bounded physical line. @@ -112,7 +154,11 @@ bool writeDocument(const AppConfig& config, class Decoder { public: - explicit Decoder(AppConfig* target, DocumentKind expected_kind); + explicit Decoder(AppConfig* target, + DocumentKind expected_kind, + RecordConsumer consumer = nullptr, + void* extension_context = nullptr, + DocumentFinalizer finalizer = nullptr); // `line` must be NUL-terminated, contain no trailing newline, and is // mutable because the parser splits key/type/value in place. @@ -120,13 +166,19 @@ class Decoder bool finish(); const DocumentInfo& info() const { return info_; } + uint16_t schemaVersion() const { return schema_version_; } private: bool consumeRecord(char* key, char* type, char* value); AppConfig* target_ = nullptr; DocumentKind expected_kind_ = DocumentKind::Working; + RecordConsumer consumer_ = nullptr; + void* extension_context_ = nullptr; + DocumentFinalizer finalizer_ = nullptr; DocumentInfo info_{}; + uint16_t schema_version_ = 0U; + uint16_t magic_version_ = 0U; bool saw_magic_ = false; bool saw_schema_ = false; bool saw_kind_ = false; diff --git a/modules/core_sys/include/platform/ui/README.md b/modules/core_sys/include/platform/ui/README.md index 73867b06..5883fa0a 100644 --- a/modules/core_sys/include/platform/ui/README.md +++ b/modules/core_sys/include/platform/ui/README.md @@ -64,9 +64,6 @@ They must not depend on concrete implementations under `platform/esp/*`, policy instead of defining their own brightness tables or arithmetic. - `settings_store.h` Persistent key/value and blob storage for shared UI/runtime settings. -- `settings_backup_runtime.h` - SD-card Trail-Mate settings backup/restore contract for shared Settings UI - while keeping filesystem and platform persistence details out of shared code. - `sstv_runtime.h` SSTV support and session lifecycle hooks. - `team_ui_store_runtime.h` diff --git a/modules/core_sys/include/platform/ui/settings_backup_runtime.h b/modules/core_sys/include/platform/ui/settings_backup_runtime.h deleted file mode 100644 index adecdb56..00000000 --- a/modules/core_sys/include/platform/ui/settings_backup_runtime.h +++ /dev/null @@ -1,22 +0,0 @@ -#pragma once - -namespace platform::ui::settings_backup -{ - -struct Status -{ - bool supported = false; - bool sd_present = false; - bool has_backup = false; - bool busy = false; - char message[96] = {}; - char detail[128] = {}; -}; - -bool is_supported(); -Status status(); -bool backup(); -bool restore(); -bool remove(); - -} // namespace platform::ui::settings_backup diff --git a/modules/core_sys/include/platform/ui/settings_store.h b/modules/core_sys/include/platform/ui/settings_store.h index c32ee6f4..a7b2aa63 100644 --- a/modules/core_sys/include/platform/ui/settings_store.h +++ b/modules/core_sys/include/platform/ui/settings_store.h @@ -8,6 +8,25 @@ namespace platform::ui::settings_store { +using ChangeObserver = void (*)(void* context, const char* ns, const char* key); + +// One platform may subscribe to durable settings changes (for example, to +// refresh an SD working projection). A batch coalesces multi-key owners such +// as the Wi-Fi profile set into one notification without retaining values. +void set_change_observer(ChangeObserver observer, void* context); +void begin_change_batch(); +void end_change_batch(); + +class ScopedChangeBatch +{ + public: + ScopedChangeBatch() { begin_change_batch(); } + ~ScopedChangeBatch() { end_change_batch(); } + + ScopedChangeBatch(const ScopedChangeBatch&) = delete; + ScopedChangeBatch& operator=(const ScopedChangeBatch&) = delete; +}; + void put_int(const char* ns, const char* key, int value); void put_bool(const char* ns, const char* key, bool value); void put_uint(const char* ns, const char* key, uint32_t value); @@ -18,7 +37,8 @@ bool get_bool(const char* ns, const char* key, bool default_value); uint32_t get_uint(const char* ns, const char* key, uint32_t default_value); bool get_string(const char* ns, const char* key, std::string& out); // Bounded counterpart to get_string. ESP persistence paths must prefer this -// overload so a settings backup never allocates a string sized by NVS input. +// overload so working-configuration sync never allocates a string sized by +// NVS input. bool get_string_into(const char* ns, const char* key, char* out, diff --git a/modules/core_sys/include/platform/ui/wifi_runtime.h b/modules/core_sys/include/platform/ui/wifi_runtime.h index 469aee03..90c346dd 100644 --- a/modules/core_sys/include/platform/ui/wifi_runtime.h +++ b/modules/core_sys/include/platform/ui/wifi_runtime.h @@ -8,6 +8,7 @@ namespace platform::ui::wifi constexpr std::size_t kMaxSsidLength = 32; constexpr std::size_t kMaxPasswordLength = 64; +constexpr std::size_t kMaxSavedProfileCount = 10; constexpr std::size_t kMaxIpLength = 47; constexpr std::size_t kMaxStatusMessageLength = 95; @@ -51,10 +52,20 @@ struct ScanResult bool requires_password = true; }; +// Iteration keeps callers from materialising a ten-profile automatic object +// merely to serialize the persistent network set. `index == 0` is the highest +// connection priority. +using SavedProfileVisitor = bool (*)(void* context, std::size_t index, const Config& profile); + bool is_supported(); bool load_config(Config& out); bool save_config(const Config& config); bool find_saved_config(const char* ssid, Config& out); +bool visit_saved_profiles(bool* enabled, + std::size_t* count, + SavedProfileVisitor visitor, + void* context); +bool replace_saved_profiles(bool enabled, const Config* profiles, std::size_t count); bool apply_enabled(bool enabled); bool connect(const Config* override_config = nullptr); void disconnect(); diff --git a/modules/core_sys/src/app/tms_config_codec.cpp b/modules/core_sys/src/app/tms_config_codec.cpp index e0a4c8f3..9ca2ed16 100644 --- a/modules/core_sys/src/app/tms_config_codec.cpp +++ b/modules/core_sys/src/app/tms_config_codec.cpp @@ -12,7 +12,8 @@ namespace app::tms namespace { -constexpr const char* kMagic = "TMSET2"; +constexpr const char* kMagic = "TMSET3"; +constexpr const char* kLegacyMagic = "TMSET2"; constexpr const char* kWorkingKind = "working"; constexpr const char* kBackupKind = "backup"; // The full AppConfig projection currently emits just over two hundred short @@ -228,6 +229,20 @@ bool assignU32(const char* type, const char* value, uint32_t* target) return true; } +bool assignI32(const char* type, const char* value, int32_t* target) +{ + int32_t parsed = 0; + if (!typeIs(type, "i32") || !parseSigned(value, &parsed)) + { + return false; + } + if (target) + { + *target = parsed; + } + return true; +} + bool assignI8(const char* type, const char* value, int8_t* target, @@ -1014,6 +1029,43 @@ bool parseMeshCoreChannel(const char* key, } // namespace +bool RecordReader::boolean(bool* target) const +{ + return assignBool(type_, value_, target); +} + +bool RecordReader::u8(uint8_t* target, uint8_t maximum) const +{ + return assignU8(type_, value_, target, maximum); +} + +bool RecordReader::u16(uint16_t* target, uint16_t maximum) const +{ + return assignU16(type_, value_, target, maximum); +} + +bool RecordReader::u32(uint32_t* target) const +{ + return assignU32(type_, value_, target); +} + +bool RecordReader::i32(int32_t* target) const +{ + return assignI32(type_, value_, target); +} + +bool RecordReader::text(char* target, std::size_t capacity) const +{ + return assignText(type_, value_, target, capacity); +} + +bool RecordReader::blob(uint8_t* target, + std::size_t capacity, + std::size_t* decoded_length) const +{ + return assignBlob(type_, value_, target, capacity, decoded_length); +} + RecordWriter::RecordWriter(Output output, LineScratch& scratch, DocumentInfo* info) : output_(output), scratch_(scratch), info_(info) { @@ -1104,8 +1156,13 @@ bool writeDocument(const AppConfig& config, return (!extension || extension(extension_context, extension_writer)) && encoder.raw("END"); } -Decoder::Decoder(AppConfig* target, DocumentKind expected_kind) - : target_(target), expected_kind_(expected_kind) +Decoder::Decoder(AppConfig* target, + DocumentKind expected_kind, + RecordConsumer consumer, + void* extension_context, + DocumentFinalizer finalizer) + : target_(target), expected_kind_(expected_kind), consumer_(consumer), + extension_context_(extension_context), finalizer_(finalizer) { } @@ -1135,7 +1192,15 @@ bool Decoder::consumeLine(char* line) } if (!saw_magic_) { - if (std::strcmp(line, kMagic) != 0) + if (std::strcmp(line, kMagic) == 0) + { + magic_version_ = kSchemaVersion; + } + else if (std::strcmp(line, kLegacyMagic) == 0) + { + magic_version_ = 2U; + } + else { info_.error = DecodeError::MissingMagic; return false; @@ -1207,6 +1272,11 @@ bool Decoder::finish() target_->meshcore_config.meshCoreChannel(0).enabled = true; target_->meshcore_config.syncMeshCoreLegacyChannelMirror(); } + if (finalizer_ && !finalizer_(extension_context_, target_ != nullptr, schema_version_)) + { + info_.error = DecodeError::InvalidKnownValue; + return false; + } return true; } @@ -1215,11 +1285,14 @@ bool Decoder::consumeRecord(char* key, char* type, char* value) if (std::strcmp(key, "schema.version") == 0) { uint16_t version = 0U; - if (!assignU16(type, value, &version) || version != kSchemaVersion) + if (!assignU16(type, value, &version) || + (version != 2U && version != kSchemaVersion) || + version != magic_version_) { info_.error = DecodeError::UnsupportedSchema; return false; } + schema_version_ = version; saw_schema_ = true; return true; } @@ -1578,6 +1651,17 @@ bool Decoder::consumeRecord(char* key, char* type, char* value) return false; } } + if (!known && consumer_) + { + const RecordConsumeResult extension_result = + consumer_(extension_context_, RecordReader(key, type, value)); + if (extension_result == RecordConsumeResult::Invalid) + { + info_.error = DecodeError::InvalidKnownValue; + return false; + } + known = extension_result == RecordConsumeResult::Accepted; + } if (!known) { // Unknown keys are forward-compatible. A known namespace with a diff --git a/modules/core_sys/tests/test_tms_config_codec.cpp b/modules/core_sys/tests/test_tms_config_codec.cpp index b543d944..290ca6b4 100644 --- a/modules/core_sys/tests/test_tms_config_codec.cpp +++ b/modules/core_sys/tests/test_tms_config_codec.cpp @@ -124,6 +124,71 @@ void testLongPhysicalLineIsRejected() assert(decoder.info().error == app::tms::DecodeError::MalformedRecord); } +struct ExtensionState +{ + bool seen = false; + bool finalized = false; + int32_t value = 0; +}; + +app::tms::RecordConsumeResult consumeExtension(void* context, + const app::tms::RecordReader& reader) +{ + auto* state = static_cast(context); + if (!state || std::strcmp(reader.key(), "extension.example") != 0) + { + return app::tms::RecordConsumeResult::Unhandled; + } + if (state->seen || !reader.i32(&state->value)) + { + return app::tms::RecordConsumeResult::Invalid; + } + state->seen = true; + return app::tms::RecordConsumeResult::Accepted; +} + +bool finishExtension(void* context, bool applying, uint16_t schema_version) +{ + auto* state = static_cast(context); + if (!state || !state->seen || schema_version != app::tms::kSchemaVersion) + { + return false; + } + state->finalized = applying; + return true; +} + +void testCurrentSchemaConsumesPlatformExtension() +{ + const std::string document = + "TMSET3\n" + "schema.version=u16:3\n" + "document.kind=enum:working\n" + "extension.example=i32:-42\n" + "END\n"; + ExtensionState validation_state{}; + app::tms::Decoder validation(nullptr, + app::tms::DocumentKind::Working, + consumeExtension, + &validation_state, + finishExtension); + assert(decodeDocument(document, validation)); + assert(validation_state.value == -42); + assert(!validation_state.finalized); + assert(validation.info().unknown_records == 0U); + + ExtensionState applying_state{}; + app::AppConfig target; + app::tms::Decoder applying(&target, + app::tms::DocumentKind::Working, + consumeExtension, + &applying_state, + finishExtension); + assert(decodeDocument(document, applying)); + assert(applying_state.value == -42); + assert(applying_state.finalized); +} + } // namespace int main() @@ -132,5 +197,6 @@ int main() testInvalidPskCannotPartiallyApply(); testUnknownFutureKeyIsIgnored(); testLongPhysicalLineIsRejected(); + testCurrentSchemaConsumesPlatformExtension(); return 0; } diff --git a/modules/ui_shared/include/ui/screens/settings/settings_spec.h b/modules/ui_shared/include/ui/screens/settings/settings_spec.h index a59c0a2e..0ec1fe1c 100644 --- a/modules/ui_shared/include/ui/screens/settings/settings_spec.h +++ b/modules/ui_shared/include/ui/screens/settings/settings_spec.h @@ -25,7 +25,6 @@ struct VisibilityContext bool wifi_supported = false; bool has_wifi_networks = false; bool firmware_update_supported = false; - bool settings_backup_supported = false; bool wireless_companion_supported = false; bool mt_secondary_enabled = false; bool mt_use_preset = false; diff --git a/modules/ui_shared/include/ui/screens/settings/settings_state.h b/modules/ui_shared/include/ui/screens/settings/settings_state.h index 88bb688e..4ef31f0b 100644 --- a/modules/ui_shared/include/ui/screens/settings/settings_state.h +++ b/modules/ui_shared/include/ui/screens/settings/settings_state.h @@ -158,9 +158,6 @@ enum class SettingId : std::uint16_t FwStatus, FwCheck, FwInstall, - SettingsBackupStatus, - SettingsBackup, - SettingsRestore, AdvDebug, ChatResetMesh, ChatResetNodes, @@ -339,7 +336,6 @@ struct SettingsData char fw_current_version[24] = ""; char fw_latest_version[24] = ""; char fw_update_status[96] = ""; - char settings_backup_status[96] = ""; bool advanced_debug_logs = false; }; diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index 9932a544..a57f02f7 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -27,7 +27,6 @@ #include "platform/ui/gps_runtime.h" #include "platform/ui/screen_brightness_steps.h" #include "platform/ui/screen_runtime.h" -#include "platform/ui/settings_backup_runtime.h" #include "platform/ui/settings_store.h" #include "platform/ui/spi_diagnostics_runtime.h" #include "platform/ui/team_ui_store_runtime.h" @@ -36,6 +35,9 @@ #include "platform/ui/tracker_runtime.h" #include "platform/ui/wifi_runtime.h" #include "platform/ui/wireless_companion_runtime.h" +#if defined(ARDUINO_ARCH_ESP32) +#include "platform/esp/arduino_common/app_config_sd_tms_runtime.h" +#endif #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" #include "ui/components/info_card.h" @@ -81,7 +83,6 @@ namespace device_runtime = ::platform::ui::device; namespace firmware_update_runtime = ::platform::ui::firmware_update; namespace gps_runtime = ::platform::ui::gps; namespace screen_runtime = ::platform::ui::screen; -namespace settings_backup_runtime = ::platform::ui::settings_backup; namespace settings_store = ::platform::ui::settings_store; #if defined(ARDUINO_ARCH_ESP32) namespace spi_diagnostics_runtime = ::platform::ui::spi_diagnostics; @@ -356,7 +357,6 @@ static void append_custom_timezone_option_if_needed(int profile_id, int offset_m static void update_item_value(settings::ui::ItemWidget& widget); static settings::ui::SettingId item_id(const settings::ui::SettingItem& item); static void open_factory_reset_modal(); -static void open_settings_restore_modal(); static void open_gps_diagnostics_modal(); static void open_spi_diagnostics_modal(); static void open_enabled_imes_modal(settings::ui::ItemWidget& widget); @@ -559,17 +559,6 @@ static void refresh_firmware_update_state_from_runtime() firmware_status_summary(status, g_settings.fw_update_status, sizeof(g_settings.fw_update_status)); } -static void refresh_settings_backup_state_from_runtime() -{ - const settings_backup_runtime::Status status = settings_backup_runtime::status(); - const char* message = status.message[0] != '\0' - ? status.message - : (status.supported ? "No backup found" : "Backup unsupported"); - copy_bounded(g_settings.settings_backup_status, - sizeof(g_settings.settings_backup_status), - ::ui::i18n::tr(message)); -} - static void refresh_wireless_companion_state_from_runtime() { const wireless_companion_runtime::Status status = wireless_companion_runtime::status(); @@ -1480,14 +1469,24 @@ static void clear_message_db() ::ui::feedback::show_notice(::ui::i18n::tr("Message DB cleared"), 3000); } -static void perform_factory_reset() +static bool perform_factory_reset() { +#if defined(ARDUINO_ARCH_ESP32) + app::sd_tms::beginWorkingConfigReset(); + if (!app::sd_tms::resetWorkingConfig()) + { + app::sd_tms::endWorkingConfigReset(); + ::ui::feedback::show_notice(::ui::i18n::tr("Unable to reset SD configuration"), 3500); + return false; + } +#endif static const char* kNamespacesToClear[] = { "chat", "gps", "settings", "aprs", "power", + "a7682e", "chat_pki", "mc_peers", "lxmf_peers", @@ -1500,9 +1499,14 @@ static void perform_factory_reset() team::ui::team_ui_snapshot_store().clear(); +#if defined(ARDUINO_ARCH_ESP32) + app::sd_tms::endWorkingConfigReset(); +#endif + ::ui::feedback::show_notice(::ui::i18n::tr("Resetting..."), 1500); platform_delay_ms(300); platform_restart(); + return true; } static void settings_load() @@ -1818,7 +1822,6 @@ static void settings_load() g_settings.vibration_enabled = prefs_get_bool("vibration_enabled", true); refresh_wifi_state_from_runtime(); refresh_firmware_update_state_from_runtime(); - refresh_settings_backup_state_from_runtime(); refresh_wireless_companion_state_from_runtime(); g_settings.advanced_debug_logs = prefs_get_bool("adv_debug", false); @@ -4177,7 +4180,7 @@ static void on_factory_reset_confirm_clicked(lv_event_t* e) ScopedSettingsBusyOverlay busy(::ui::i18n::tr("Resetting..."), ::ui::i18n::tr("Clearing stored settings"), 15); - perform_factory_reset(); + (void)perform_factory_reset(); } static void on_factory_reset_cancel_clicked(lv_event_t* e) @@ -4186,93 +4189,6 @@ static void on_factory_reset_cancel_clicked(lv_event_t* e) modal_close(); } -static void on_settings_restore_confirm_clicked(lv_event_t* e) -{ - (void)e; - modal_close(); - ScopedSettingsBusyOverlay busy(::ui::i18n::tr("Restoring settings..."), - ::ui::i18n::tr("Reading backup from SD"), - 20); - if (!settings_backup_runtime::restore()) - { - refresh_settings_backup_state_from_runtime(); - ::ui::feedback::show_notice(::ui::i18n::tr("Restore failed"), 3500); - refresh_visible_item_values(); - return; - } - busy.update(::ui::i18n::tr("Restarting..."), - ::ui::i18n::tr("Settings restored"), - 90); - ::ui::feedback::show_notice(::ui::i18n::tr("Settings restored. Restarting..."), 1500); - platform_delay_ms(300); - platform_restart(); -} - -static void on_settings_restore_cancel_clicked(lv_event_t* e) -{ - (void)e; - modal_close(); -} - -static void open_settings_restore_modal() -{ - if (g_state.modal_root) - { - return; - } - - modal_prepare_group(); - g_state.modal_root = create_modal_root(300, 180); - lv_obj_t* win = lv_obj_get_child(g_state.modal_root, 0); - - lv_obj_t* title = lv_label_create(win); - ::ui::i18n::set_label_text(title, "Restore Settings"); - style::apply_label_primary(title); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); - - lv_obj_t* body = lv_label_create(win); - lv_obj_set_width(body, LV_PCT(100)); - ::ui::i18n::set_label_text(body, "Overwrite current settings and restart?"); - style::apply_label_muted(body); - lv_obj_set_style_text_align(body, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_align(body, LV_ALIGN_CENTER, 0, -8); - - lv_obj_t* btn_row = lv_obj_create(win); - lv_obj_set_size(btn_row, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_align(btn_row, LV_ALIGN_BOTTOM_MID, 0, 0); - lv_obj_set_flex_flow(btn_row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(btn_row, - LV_FLEX_ALIGN_SPACE_EVENLY, - LV_FLEX_ALIGN_CENTER, - LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(btn_row, 0, LV_PART_MAIN); - lv_obj_set_style_bg_opa(btn_row, LV_OPA_TRANSP, LV_PART_MAIN); - lv_obj_set_style_border_width(btn_row, 0, LV_PART_MAIN); - lv_obj_clear_flag(btn_row, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t* cancel_btn = lv_btn_create(btn_row); - lv_obj_set_size(cancel_btn, - ::ui::page_profile::resolve_control_button_min_width(), - ::ui::page_profile::resolve_control_button_height()); - lv_obj_t* cancel_label = lv_label_create(cancel_btn); - ::ui::i18n::set_label_text(cancel_label, "Cancel"); - lv_obj_center(cancel_label); - lv_obj_add_event_cb(cancel_btn, on_settings_restore_cancel_clicked, LV_EVENT_CLICKED, nullptr); - - lv_obj_t* confirm_btn = lv_btn_create(btn_row); - lv_obj_set_size(confirm_btn, - ::ui::page_profile::resolve_control_button_min_width(), - ::ui::page_profile::resolve_control_button_height()); - lv_obj_t* confirm_label = lv_label_create(confirm_btn); - ::ui::i18n::set_label_text(confirm_label, "Restore"); - lv_obj_center(confirm_label); - lv_obj_add_event_cb(confirm_btn, on_settings_restore_confirm_clicked, LV_EVENT_CLICKED, nullptr); - - modal_add_focus_obj(cancel_btn); - modal_add_focus_obj(confirm_btn); - lv_group_focus_obj(cancel_btn); -} - static void open_factory_reset_modal() { if (g_state.modal_root) @@ -5383,9 +5299,6 @@ static settings::ui::SettingItem kMaintenanceItems[] = { {"OTA Status", settings::ui::SettingType::Info, nullptr, 0, nullptr, nullptr, g_settings.fw_update_status, sizeof(g_settings.fw_update_status), false, "fw_status"}, {"Check for Updates", settings::ui::SettingType::Action, nullptr, 0, nullptr, nullptr, nullptr, 0, false, "fw_check"}, {"Install Update", settings::ui::SettingType::Action, nullptr, 0, nullptr, nullptr, nullptr, 0, false, "fw_install"}, - {"Backup Status", settings::ui::SettingType::Info, nullptr, 0, nullptr, nullptr, g_settings.settings_backup_status, sizeof(g_settings.settings_backup_status), false, "settings_backup_status"}, - {"Backup Settings", settings::ui::SettingType::Action, nullptr, 0, nullptr, nullptr, nullptr, 0, false, "settings_backup"}, - {"Restore Settings", settings::ui::SettingType::Action, nullptr, 0, nullptr, nullptr, nullptr, 0, false, "settings_restore"}, {"Debug Logs", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.advanced_debug_logs, nullptr, 0, false, "adv_debug"}, {"Reset Mesh Profiles", settings::ui::SettingType::Action, nullptr, 0, nullptr, nullptr, nullptr, 0, false, "chat_reset_mesh"}, {"Reset Node DB", settings::ui::SettingType::Action, nullptr, 0, nullptr, nullptr, nullptr, 0, false, "chat_reset_nodes"}, @@ -5528,7 +5441,6 @@ static bool should_show_item(const settings::ui::SettingItem& item) context.wifi_supported = wifi_runtime::is_supported(); context.has_wifi_networks = kWifiNetworkOptionCount > 0; context.firmware_update_supported = firmware_update_runtime::is_supported(); - context.settings_backup_supported = settings_backup_runtime::is_supported(); context.wireless_companion_supported = wireless_companion_runtime::is_supported(); context.mt_secondary_enabled = g_settings.mt_secondary_enabled; context.mt_use_preset = g_settings.net_use_preset != 0; @@ -6476,57 +6388,6 @@ static bool activate_item_widget(settings::ui::ItemWidget& widget) } break; } - case settings::ui::SettingId::SettingsBackup: - { - const settings_backup_runtime::Status before = settings_backup_runtime::status(); - if (!before.sd_present) - { - ::ui::feedback::show_notice(::ui::i18n::tr("Insert SD card to backup settings"), 3000); - } - else - { - ScopedSettingsBusyOverlay busy(::ui::i18n::tr("Backing up settings..."), - ::ui::i18n::tr("Writing to SD card"), - 30); - if (!settings_backup_runtime::backup()) - { - busy.update(::ui::i18n::tr("Backup failed"), - ::ui::i18n::tr("Check SD card"), - 100); - refresh_settings_backup_state_from_runtime(); - ::ui::feedback::show_notice(::ui::i18n::tr("Backup failed"), 3000); - } - else - { - busy.update(::ui::i18n::tr("Backup complete"), - ::ui::i18n::tr("Settings saved to SD"), - 100); - refresh_settings_backup_state_from_runtime(); - ::ui::feedback::show_notice(::ui::i18n::tr("Settings backup saved to SD"), 2500); - } - } - refresh_visible_item_values(); - break; - } - case settings::ui::SettingId::SettingsRestore: - { - const settings_backup_runtime::Status status = settings_backup_runtime::status(); - if (!status.sd_present) - { - ::ui::feedback::show_notice(::ui::i18n::tr("Insert SD card to restore settings"), 3000); - } - else if (!status.has_backup) - { - ::ui::feedback::show_notice(::ui::i18n::tr("No settings backup found"), 3000); - } - else - { - open_settings_restore_modal(); - } - refresh_settings_backup_state_from_runtime(); - refresh_visible_item_values(); - break; - } default: break; } diff --git a/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp b/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp index 085a1e3f..4746e126 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp @@ -149,9 +149,6 @@ constexpr SettingSpec kSpecs[] = { {SettingId::FwStatus, "fw_status", DynamicOptionKind::None}, {SettingId::FwCheck, "fw_check", DynamicOptionKind::None}, {SettingId::FwInstall, "fw_install", DynamicOptionKind::None}, - {SettingId::SettingsBackupStatus, "settings_backup_status", DynamicOptionKind::None}, - {SettingId::SettingsBackup, "settings_backup", DynamicOptionKind::None}, - {SettingId::SettingsRestore, "settings_restore", DynamicOptionKind::None}, {SettingId::AdvDebug, "adv_debug", DynamicOptionKind::None}, {SettingId::ChatResetMesh, "chat_reset_mesh", DynamicOptionKind::None}, {SettingId::ChatResetNodes, "chat_reset_nodes", DynamicOptionKind::None}, @@ -391,19 +388,6 @@ bool is_firmware_update_setting(SettingId id) } } -bool is_settings_backup_setting(SettingId id) -{ - switch (id) - { - case SettingId::SettingsBackupStatus: - case SettingId::SettingsBackup: - case SettingId::SettingsRestore: - return true; - default: - return false; - } -} - bool is_wireless_companion_setting(SettingId id) { switch (id) @@ -552,10 +536,6 @@ bool should_show(SettingId id, const VisibilityContext& context) { return false; } - if (is_settings_backup_setting(id) && !context.settings_backup_supported) - { - return false; - } if (is_wireless_companion_setting(id) && !context.wireless_companion_supported) { return false; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_sd_tms_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_sd_tms_runtime.h index cb713acd..8f8fa615 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_sd_tms_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_sd_tms_runtime.h @@ -25,6 +25,14 @@ enum class LoadResult : uint8_t // when it is the current SD authority. `config` is otherwise untouched. LoadResult loadWorkingConfig(AppConfig& config); +// Binds the live AppConfig after startup. Independent settings-store owners +// then mark a coalesced SD projection dirty without reaching back into UI code. +void bindWorkingConfig(const AppConfig& config); + +// Flushes a pending settings-store change from the normal application loop. +// It deliberately does no SD I/O from Wi-Fi timers or other write callers. +void serviceWorkingConfig(); + // Records that NVS now contains a newer configuration before the SD projection // is replaced. This prevents an old SD file from overwriting fresh NVS after // a card removal or interrupted write. @@ -35,6 +43,16 @@ bool markNvsCommitted(); // failure of the primary NVS configuration save. bool syncWorkingConfig(const AppConfig& config); +// Suppresses settings-store change notifications across the NVS half of a +// factory reset. This prevents a partially reset runtime from recreating the +// just-removed SD authority before the restart. +void beginWorkingConfigReset(); +void endWorkingConfigReset(); + +// Factory reset removes the SD authority as well as its tiny commit metadata. +// A missing SD card is already-reset from this runtime's perspective. +bool resetWorkingConfig(); + const char* loadResultName(LoadResult result); } // namespace app::sd_tms diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_tms_settings_extension.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_tms_settings_extension.h new file mode 100644 index 00000000..d8bc3f4a --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/app_config_tms_settings_extension.h @@ -0,0 +1,26 @@ +/** + * @file app_config_tms_settings_extension.h + * @brief Platform-owned TMS records for Settings values outside AppConfig. + * + * AppConfig owns mesh, location, and protocol configuration. This extension + * owns the remaining user-editable runtime settings that have independent + * NVS owners, so the SD working document remains a single configuration + * authority without moving platform runtime state into AppConfig. + */ + +#pragma once + +#include "app/tms_config_codec.h" + +namespace app::sd_tms::settings_extension +{ + +// The extension state is static, bounded storage. Begin a fresh two-pass +// read before feeding records to the core Decoder. +void beginRead(bool applying); + +tms::RecordConsumeResult consumeRecord(void* context, const tms::RecordReader& reader); +bool finishDocument(void* context, bool applying, uint16_t schema_version); +bool writeRecords(void* context, tms::RecordWriter& writer); + +} // namespace app::sd_tms::settings_extension diff --git a/platform/esp/arduino_common/src/a7682e_cellular_runtime.cpp b/platform/esp/arduino_common/src/a7682e_cellular_runtime.cpp index 7aab8f34..aaca6637 100644 --- a/platform/esp/arduino_common/src/a7682e_cellular_runtime.cpp +++ b/platform/esp/arduino_common/src/a7682e_cellular_runtime.cpp @@ -932,6 +932,7 @@ const Status& status() bool save_config(const Config& next) { + ::platform::ui::settings_store::ScopedChangeBatch changes; load_config_once(); s_runtime.config = next; ::platform::ui::settings_store::put_bool(kSettingsNamespace, "enabled", next.enabled); @@ -955,6 +956,7 @@ bool save_config(const Config& next) bool set_enabled(bool enabled) { + ::platform::ui::settings_store::ScopedChangeBatch changes; load_config_once(); if (s_runtime.config.enabled == enabled) { diff --git a/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp b/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp index 33581bf2..eb5888c4 100644 --- a/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp +++ b/platform/esp/arduino_common/src/app_config_sd_tms_runtime.cpp @@ -1,8 +1,11 @@ #include "platform/esp/arduino_common/app_config_sd_tms_runtime.h" #include "app/tms_config_codec.h" +#include "platform/esp/arduino_common/app_config_tms_settings_extension.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#include "platform/ui/settings_store.h" +#include #include #include @@ -69,6 +72,48 @@ struct FileDigest // PSRAM; keeping this sub-384 byte parser buffer in BSS also keeps boot usable // on ESP variants without PSRAM and avoids a hidden allocator fallback. tms::LineScratch s_line_scratch{}; +const AppConfig* s_working_config = nullptr; +bool s_sync_pending = false; +bool s_sync_suppressed = false; +uint32_t s_next_sync_attempt_ms = 0U; + +class ScopedSyncSuppression +{ + public: + ScopedSyncSuppression() + : previous_(s_sync_suppressed) + { + s_sync_suppressed = true; + } + + ~ScopedSyncSuppression() { s_sync_suppressed = previous_; } + + private: + bool previous_ = false; +}; + +bool tracksWorkingSettings(const char* ns) +{ + // A batched notification intentionally has no key. The current ESP + // multi-key owners are Wi-Fi and cellular and are both working-config + // fields, so it must be retained rather than dropped. + return ns == nullptr || std::strcmp(ns, "settings") == 0 || + std::strcmp(ns, "power") == 0 || std::strcmp(ns, "a7682e") == 0; +} + +void onSettingsStoreChanged(void*, const char* ns, const char*) +{ + if (s_sync_suppressed || !tracksWorkingSettings(ns)) + { + return; + } + // This small NVS marker is the write-ahead record. If power is lost + // before the loop can flush SD, boot detects the stale SD replica and + // retains the newer NVS settings instead of reversing the user's change. + (void)::app::sd_tms::markNvsCommitted(); + s_sync_pending = true; + s_next_sync_attempt_ms = 0U; +} uint32_t crc32Update(uint32_t crc, const uint8_t* data, std::size_t length) { @@ -192,7 +237,13 @@ bool readDocument(AppConfig* target, FileDigest* digest, tms::DocumentInfo* info return false; } - tms::Decoder decoder(target, tms::DocumentKind::Working); + ScopedSyncSuppression suppress_sync; + settings_extension::beginRead(target != nullptr); + tms::Decoder decoder(target, + tms::DocumentKind::Working, + settings_extension::consumeRecord, + nullptr, + settings_extension::finishDocument); uint32_t running_crc = 0xFFFFFFFFUL; std::size_t line_length = 0U; bool ok = true; @@ -367,6 +418,36 @@ LoadResult loadWorkingConfig(AppConfig& config) return LoadResult::Applied; } +void bindWorkingConfig(const AppConfig& config) +{ + s_working_config = &config; + ::platform::ui::settings_store::set_change_observer(onSettingsStoreChanged, nullptr); +} + +void serviceWorkingConfig() +{ + if (!s_sync_pending || !s_working_config) + { + return; + } + const uint32_t now_ms = millis(); + if (s_next_sync_attempt_ms != 0U && + static_cast(now_ms - s_next_sync_attempt_ms) < 0) + { + return; + } + if (syncWorkingConfig(*s_working_config)) + { + s_sync_pending = false; + s_next_sync_attempt_ms = 0U; + return; + } + // Card insertion/removal and SD contention are expected states. Retry at + // a bounded cadence from the foreground loop rather than from a timer or + // a settings-store write call. + s_next_sync_attempt_ms = now_ms + 1000U; +} + bool markNvsCommitted() { ReplicaMeta meta{}; @@ -378,6 +459,7 @@ bool markNvsCommitted() bool syncWorkingConfig(const AppConfig& config) { + ScopedSyncSuppression suppress_sync; if (!sdAvailable()) { return false; @@ -398,7 +480,9 @@ bool syncWorkingConfig(const AppConfig& config) tms::DocumentKind::Working, {&output, writeOutput}, s_line_scratch, - &info); + &info, + settings_extension::writeRecords, + nullptr); const bool flushed = file.flush(); file.close(); if (!wrote || !flushed) @@ -434,6 +518,50 @@ bool syncWorkingConfig(const AppConfig& config) static_cast(meta.revision), static_cast(meta.last_sd_crc), static_cast(commit.records)); + s_sync_pending = false; + s_next_sync_attempt_ms = 0U; + return true; +} + +void beginWorkingConfigReset() +{ + s_sync_suppressed = true; +} + +void endWorkingConfigReset() +{ + s_sync_suppressed = false; + s_sync_pending = false; + s_next_sync_attempt_ms = 0U; +} + +bool resetWorkingConfig() +{ + s_sync_pending = false; + s_next_sync_attempt_ms = 0U; + if (!sdAvailable()) + { + return true; + } + bool ok = true; + const char* paths[] = {kConfigTempPath, kCommitTempPath, kConfigPath, kCommitPath}; + for (const char* path : paths) + { + if (::platform::esp::arduino_common::storage::sd_exists(path)) + { + ok = ::platform::esp::arduino_common::storage::sd_remove(path) && ok; + } + } + if (!ok) + { + return false; + } + Preferences preferences; + if (preferences.begin(kReplicaNamespace, false)) + { + (void)preferences.remove(kReplicaKey); + preferences.end(); + } return true; } diff --git a/platform/esp/arduino_common/src/app_config_store.cpp b/platform/esp/arduino_common/src/app_config_store.cpp index e2c7ec32..a9a04daf 100644 --- a/platform/esp/arduino_common/src/app_config_store.cpp +++ b/platform/esp/arduino_common/src/app_config_store.cpp @@ -1609,6 +1609,7 @@ bool loadAppConfig(AppConfig& config) meta_saved ? 1U : 0U, sd_synced ? 1U : 0U); } + sd_tms::bindWorkingConfig(config); return nvs_saved; } @@ -1638,11 +1639,13 @@ bool loadAppConfig(AppConfig& config) Serial.printf("[AppCfg][SD] startup source=nvs reason=%s\n", sd_tms::loadResultName(sd_result)); } + sd_tms::bindWorkingConfig(config); return true; } bool saveAppConfig(const AppConfig& config, AppConfigChangeSet changes) { + sd_tms::bindWorkingConfig(config); Preferences prefs; const bool nvs_saved = saveAppConfigToPreferences(config, prefs, changes, true); if (!nvs_saved) diff --git a/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp b/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp new file mode 100644 index 00000000..423eab41 --- /dev/null +++ b/platform/esp/arduino_common/src/app_config_tms_settings_extension.cpp @@ -0,0 +1,692 @@ +#include "platform/esp/arduino_common/app_config_tms_settings_extension.h" + +#include "platform/ui/auto_reply_settings.h" +#include "platform/ui/device_runtime.h" +#include "platform/ui/screen_runtime.h" +#include "platform/ui/settings_store.h" +#include "platform/ui/time_runtime.h" +#include "platform/ui/wifi_runtime.h" + +#if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) +#include "platform/ui/a7682e_cellular_runtime.h" +#endif + +#include +#include + +#if defined(HAS_PSRAM) && HAS_PSRAM +#include +#endif + +namespace app::sd_tms::settings_extension +{ +namespace +{ + +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kPowerNs = "power"; +constexpr const char* kDebugNs = "debug"; +constexpr std::size_t kLocaleBytes = 48U; +constexpr std::size_t kEnabledImesBytes = 192U; +constexpr std::size_t kTimezoneTzdefBytes = 192U; +constexpr std::size_t kAutoReplyBytes = ::platform::ui::auto_reply::kTextMaxBytes; + +enum Seen : uint32_t +{ + SeenScreenTimeout = 1UL << 0U, + SeenBrightness = 1UL << 1U, + SeenSpeakerVolume = 1UL << 2U, + SeenVibration = 1UL << 3U, + SeenLocale = 1UL << 4U, + SeenEnabledImes = 1UL << 5U, + SeenTimezoneProfile = 1UL << 6U, + SeenTimezoneOffset = 1UL << 7U, + SeenTimezoneTzdef = 1UL << 8U, + SeenMessageAlerts = 1UL << 9U, + SeenContactAlerts = 1UL << 10U, + SeenAutoReplyEnabled = 1UL << 11U, + SeenAutoReplyText = 1UL << 12U, + SeenDebugLogs = 1UL << 13U, + SeenGaugeDesign = 1UL << 14U, + SeenGaugeFull = 1UL << 15U, + SeenWifiEnabled = 1UL << 16U, + SeenWifiCount = 1UL << 17U, +}; + +constexpr uint32_t kRequiredBase = SeenScreenTimeout | SeenBrightness | SeenSpeakerVolume | + SeenVibration | SeenLocale | SeenEnabledImes | + SeenTimezoneProfile | SeenTimezoneOffset | SeenTimezoneTzdef | + SeenMessageAlerts | SeenContactAlerts | SeenAutoReplyEnabled | + SeenAutoReplyText | SeenDebugLogs | SeenGaugeDesign | + SeenGaugeFull | SeenWifiEnabled | SeenWifiCount; + +struct ParseState +{ + uint32_t seen = 0U; + bool applying = false; + uint32_t screen_timeout_ms = 30000U; + int32_t screen_brightness = 0; + int32_t speaker_volume = 45; + bool vibration_enabled = true; + char locale[kLocaleBytes]{}; + char enabled_imes[kEnabledImesBytes]{}; + std::size_t enabled_imes_len = 0U; + int32_t timezone_profile = 0; + int32_t timezone_offset_minutes = 0; + uint8_t timezone_tzdef[kTimezoneTzdefBytes]{}; + std::size_t timezone_tzdef_len = 0U; + int32_t message_alerts = 1; + int32_t contact_alerts = 1; + bool auto_reply_enabled = false; + char auto_reply_text[kAutoReplyBytes + 1U]{}; + std::size_t auto_reply_text_len = 0U; + bool debug_logs_enabled = false; + uint32_t gauge_design_mah = 1500U; + uint32_t gauge_full_mah = 1500U; + bool wifi_enabled = false; + uint8_t wifi_profile_count = 0U; + bool wifi_ssid_seen[::platform::ui::wifi::kMaxSavedProfileCount]{}; + bool wifi_password_seen[::platform::ui::wifi::kMaxSavedProfileCount]{}; + ::platform::ui::wifi::Config wifi_profiles[::platform::ui::wifi::kMaxSavedProfileCount]{}; +#if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) + uint32_t cellular_seen = 0U; + ::platform::ui::a7682e::Config cellular{}; +#else + uint32_t cellular_seen = 0U; +#endif +}; + +// This is intentionally static BSS rather than an ESP task-stack allocation. +// It is the only bounded staging area needed to validate and atomically replace +// all ten Wi-Fi profiles (and, where present, cellular credentials). +#if defined(HAS_PSRAM) && HAS_PSRAM +// Arduino-ESP32 2.x exposes EXT_RAM_ATTR while ESP-IDF 5.x renamed it to +// EXT_RAM_BSS_ATTR. Keep this storage declaration portable across the +// supported Arduino toolchains without changing its static lifetime. +EXT_RAM_ATTR ParseState s_state{}; +#else +ParseState s_state{}; +#endif +char s_write_text[kEnabledImesBytes]{}; +uint8_t s_write_blob[kTimezoneTzdefBytes]{}; + +bool mark_once(uint32_t bit) +{ + if ((s_state.seen & bit) != 0U) + { + return false; + } + s_state.seen |= bit; + return true; +} + +bool key_equals(const tms::RecordReader& reader, const char* key) +{ + return std::strcmp(reader.key(), key) == 0; +} + +bool valid_screen_timeout(uint32_t value) +{ + return value == 15000U || value == 30000U || value == 60000U || value == 300000U; +} + +bool valid_timezone_offset_minutes(int32_t value) +{ + // Civil time zones range from UTC-12 through UTC+14. Keeping this + // bounded prevents a hand-edited raw value from reaching the platform + // clock runtime while retaining every real-world offset. + return value >= -720 && value <= 840; +} + +bool valid_wifi_profile_set() +{ + if (s_state.wifi_profile_count > ::platform::ui::wifi::kMaxSavedProfileCount) + { + return false; + } + for (std::size_t index = 0U; index < ::platform::ui::wifi::kMaxSavedProfileCount; ++index) + { + const bool required = index < s_state.wifi_profile_count; + if ((s_state.wifi_ssid_seen[index] || s_state.wifi_password_seen[index]) != required) + { + return false; + } + if (!required) + { + continue; + } + if (s_state.wifi_profiles[index].ssid[0] == '\0') + { + return false; + } + for (std::size_t prior = 0U; prior < index; ++prior) + { + if (std::strcmp(s_state.wifi_profiles[index].ssid, + s_state.wifi_profiles[prior].ssid) == 0) + { + return false; + } + } + } + return true; +} + +bool valid_text_blob(const char* value, std::size_t length) +{ + return value != nullptr && std::memchr(value, '\0', length) == nullptr; +} + +bool write_text_setting(tms::RecordWriter& writer, + const char* document_key, + const char* ns, + const char* store_key, + char* scratch, + std::size_t scratch_size) +{ + std::size_t length = 0U; + if (!::platform::ui::settings_store::get_string_into( + ns, store_key, scratch, scratch_size, &length)) + { + scratch[0] = '\0'; + } + return writer.text(document_key, scratch); +} + +bool write_blob_setting(tms::RecordWriter& writer, + const char* document_key, + const char* ns, + const char* store_key) +{ + std::size_t length = 0U; + if (!::platform::ui::settings_store::get_blob_into( + ns, store_key, s_write_blob, sizeof(s_write_blob), &length)) + { + length = 0U; + } + return writer.blob(document_key, s_write_blob, length); +} + +bool write_string_as_blob(tms::RecordWriter& writer, + const char* document_key, + const char* ns, + const char* store_key, + std::size_t maximum_length) +{ + if (maximum_length + 1U > sizeof(s_write_text)) + { + return false; + } + std::size_t length = 0U; + if (!::platform::ui::settings_store::get_string_into( + ns, store_key, s_write_text, maximum_length + 1U, &length)) + { + length = 0U; + } + return writer.blob(document_key, + reinterpret_cast(s_write_text), + length); +} + +bool snapshot_wifi_profile(void*, + std::size_t index, + const ::platform::ui::wifi::Config& profile) +{ + if (index >= ::platform::ui::wifi::kMaxSavedProfileCount) + { + return false; + } + s_state.wifi_profiles[index] = profile; + return true; +} + +#if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) +enum CellularSeen : uint32_t +{ + CellularEnabled = 1UL << 0U, + CellularAutoAnswer = 1UL << 1U, + CellularSpeakerGain = 1UL << 2U, + CellularMicrophoneGain = 1UL << 3U, + CellularSmtpPort = 1UL << 4U, + CellularSmtpSecurity = 1UL << 5U, + CellularApn = 1UL << 6U, + CellularApnUser = 1UL << 7U, + CellularApnPassword = 1UL << 8U, + CellularSmsc = 1UL << 9U, + CellularSmtpHost = 1UL << 10U, + CellularSmtpUser = 1UL << 11U, + CellularSmtpPassword = 1UL << 12U, + CellularSmtpFrom = 1UL << 13U, + CellularSmtpRecipient = 1UL << 14U, +}; + +constexpr uint32_t kRequiredCellular = CellularEnabled | CellularAutoAnswer | CellularSpeakerGain | + CellularMicrophoneGain | CellularSmtpPort | CellularSmtpSecurity | + CellularApn | CellularApnUser | CellularApnPassword | CellularSmsc | + CellularSmtpHost | CellularSmtpUser | CellularSmtpPassword | + CellularSmtpFrom | CellularSmtpRecipient; + +bool mark_cellular_once(uint32_t bit) +{ + if ((s_state.cellular_seen & bit) != 0U) + { + return false; + } + s_state.cellular_seen |= bit; + return true; +} + +bool valid_cellular_config(const ::platform::ui::a7682e::Config& config) +{ + return config.speaker_gain <= 15U && config.microphone_gain <= 15U && + (config.smtp_port == 25U || config.smtp_port == 465U || config.smtp_port == 587U) && + config.smtp_security <= 2U; +} + +bool write_cellular_records(tms::RecordWriter& writer) +{ + const auto& cellular = ::platform::ui::a7682e::config(); + return writer.boolean("cellular.enabled", cellular.enabled) && + writer.boolean("cellular.auto_answer", cellular.auto_answer) && + writer.u8("cellular.speaker_gain", cellular.speaker_gain) && + writer.u8("cellular.microphone_gain", cellular.microphone_gain) && + writer.u16("cellular.smtp_port", cellular.smtp_port) && + writer.u8("cellular.smtp_security", cellular.smtp_security) && + writer.text("cellular.apn", cellular.apn) && + writer.text("cellular.apn_user", cellular.apn_user) && + writer.text("cellular.apn_password", cellular.apn_password) && + writer.text("cellular.smsc", cellular.smsc) && + writer.text("cellular.smtp_host", cellular.smtp_host) && + writer.text("cellular.smtp_user", cellular.smtp_user) && + writer.text("cellular.smtp_password", cellular.smtp_password) && + writer.text("cellular.smtp_from", cellular.smtp_from) && + writer.text("cellular.smtp_default_recipient", cellular.smtp_default_recipient); +} +#endif + +} // namespace + +void beginRead(bool applying) +{ + s_state = ParseState{}; + s_state.applying = applying; +} + +tms::RecordConsumeResult consumeRecord(void*, const tms::RecordReader& reader) +{ + if (key_equals(reader, "ui.screen.timeout_ms")) + { + return mark_once(SeenScreenTimeout) && reader.u32(&s_state.screen_timeout_ms) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "ui.display.brightness")) + { + return mark_once(SeenBrightness) && reader.i32(&s_state.screen_brightness) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "ui.audio.speaker_volume")) + { + return mark_once(SeenSpeakerVolume) && reader.i32(&s_state.speaker_volume) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "ui.haptics.vibration_enabled")) + { + return mark_once(SeenVibration) && reader.boolean(&s_state.vibration_enabled) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "ui.locale")) + { + return mark_once(SeenLocale) && reader.text(s_state.locale, sizeof(s_state.locale)) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "ui.ime.enabled")) + { + return mark_once(SeenEnabledImes) && + reader.blob(reinterpret_cast(s_state.enabled_imes), + sizeof(s_state.enabled_imes) - 1U, + &s_state.enabled_imes_len) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "ui.timezone.profile")) + { + return mark_once(SeenTimezoneProfile) && reader.i32(&s_state.timezone_profile) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "ui.timezone.offset_minutes")) + { + return mark_once(SeenTimezoneOffset) && reader.i32(&s_state.timezone_offset_minutes) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "ui.timezone.tzdef")) + { + return mark_once(SeenTimezoneTzdef) && + reader.blob(s_state.timezone_tzdef, + sizeof(s_state.timezone_tzdef), + &s_state.timezone_tzdef_len) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "chat.alerts.message")) + { + return mark_once(SeenMessageAlerts) && reader.i32(&s_state.message_alerts) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "chat.alerts.contact")) + { + return mark_once(SeenContactAlerts) && reader.i32(&s_state.contact_alerts) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "chat.auto_reply.enabled")) + { + return mark_once(SeenAutoReplyEnabled) && reader.boolean(&s_state.auto_reply_enabled) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "chat.auto_reply.text")) + { + return mark_once(SeenAutoReplyText) && + reader.blob(reinterpret_cast(s_state.auto_reply_text), + kAutoReplyBytes, + &s_state.auto_reply_text_len) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "debug.sd_logs_enabled")) + { + return mark_once(SeenDebugLogs) && reader.boolean(&s_state.debug_logs_enabled) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "power.gauge.design_mah")) + { + return mark_once(SeenGaugeDesign) && reader.u32(&s_state.gauge_design_mah) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "power.gauge.full_mah")) + { + return mark_once(SeenGaugeFull) && reader.u32(&s_state.gauge_full_mah) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "wifi.enabled")) + { + return mark_once(SeenWifiEnabled) && reader.boolean(&s_state.wifi_enabled) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + if (key_equals(reader, "wifi.profile_count")) + { + return mark_once(SeenWifiCount) && + reader.u8(&s_state.wifi_profile_count, + static_cast(::platform::ui::wifi::kMaxSavedProfileCount)) + ? tms::RecordConsumeResult::Accepted + : tms::RecordConsumeResult::Invalid; + } + for (std::size_t index = 0U; index < ::platform::ui::wifi::kMaxSavedProfileCount; ++index) + { + char key[48]{}; + std::snprintf(key, sizeof(key), "wifi.profile.%u.ssid", static_cast(index)); + if (key_equals(reader, key)) + { + return !s_state.wifi_ssid_seen[index] && + reader.text(s_state.wifi_profiles[index].ssid, + sizeof(s_state.wifi_profiles[index].ssid)) + ? (s_state.wifi_ssid_seen[index] = true, + tms::RecordConsumeResult::Accepted) + : tms::RecordConsumeResult::Invalid; + } + std::snprintf(key, sizeof(key), "wifi.profile.%u.password", static_cast(index)); + if (key_equals(reader, key)) + { + return !s_state.wifi_password_seen[index] && + reader.text(s_state.wifi_profiles[index].password, + sizeof(s_state.wifi_profiles[index].password)) + ? (s_state.wifi_password_seen[index] = true, + tms::RecordConsumeResult::Accepted) + : tms::RecordConsumeResult::Invalid; + } + } + +#if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) +#define TMS_CELL_BOOL(name, field, bit) \ + if (key_equals(reader, name)) \ + return mark_cellular_once(bit) && reader.boolean(&s_state.cellular.field) \ + ? tms::RecordConsumeResult::Accepted \ + : tms::RecordConsumeResult::Invalid +#define TMS_CELL_U8(name, field, bit) \ + if (key_equals(reader, name)) \ + return mark_cellular_once(bit) && reader.u8(&s_state.cellular.field) \ + ? tms::RecordConsumeResult::Accepted \ + : tms::RecordConsumeResult::Invalid +#define TMS_CELL_U16(name, field, bit) \ + if (key_equals(reader, name)) \ + return mark_cellular_once(bit) && reader.u16(&s_state.cellular.field) \ + ? tms::RecordConsumeResult::Accepted \ + : tms::RecordConsumeResult::Invalid +#define TMS_CELL_TEXT(name, field, bit) \ + if (key_equals(reader, name)) \ + return mark_cellular_once(bit) && \ + reader.text(s_state.cellular.field, sizeof(s_state.cellular.field)) \ + ? tms::RecordConsumeResult::Accepted \ + : tms::RecordConsumeResult::Invalid + TMS_CELL_BOOL("cellular.enabled", enabled, CellularEnabled); + TMS_CELL_BOOL("cellular.auto_answer", auto_answer, CellularAutoAnswer); + TMS_CELL_U8("cellular.speaker_gain", speaker_gain, CellularSpeakerGain); + TMS_CELL_U8("cellular.microphone_gain", microphone_gain, CellularMicrophoneGain); + TMS_CELL_U16("cellular.smtp_port", smtp_port, CellularSmtpPort); + TMS_CELL_U8("cellular.smtp_security", smtp_security, CellularSmtpSecurity); + TMS_CELL_TEXT("cellular.apn", apn, CellularApn); + TMS_CELL_TEXT("cellular.apn_user", apn_user, CellularApnUser); + TMS_CELL_TEXT("cellular.apn_password", apn_password, CellularApnPassword); + TMS_CELL_TEXT("cellular.smsc", smsc, CellularSmsc); + TMS_CELL_TEXT("cellular.smtp_host", smtp_host, CellularSmtpHost); + TMS_CELL_TEXT("cellular.smtp_user", smtp_user, CellularSmtpUser); + TMS_CELL_TEXT("cellular.smtp_password", smtp_password, CellularSmtpPassword); + TMS_CELL_TEXT("cellular.smtp_from", smtp_from, CellularSmtpFrom); + TMS_CELL_TEXT("cellular.smtp_default_recipient", smtp_default_recipient, CellularSmtpRecipient); +#undef TMS_CELL_BOOL +#undef TMS_CELL_U8 +#undef TMS_CELL_U16 +#undef TMS_CELL_TEXT +#endif + + return tms::RecordConsumeResult::Unhandled; +} + +bool finishDocument(void*, bool applying, uint16_t schema_version) +{ + const bool contains_extension_records = s_state.seen != 0U || s_state.cellular_seen != 0U; + if (schema_version == 2U && !contains_extension_records) + { + // TMSET2 predates the full Settings projection. Its AppConfig is + // still authoritative; existing independent NVS values are retained + // and immediately upgraded to TMSET3 by the caller's normal sync. + return true; + } + if ((s_state.seen & kRequiredBase) != kRequiredBase || + !valid_screen_timeout(s_state.screen_timeout_ms) || + s_state.screen_brightness < 0 || s_state.screen_brightness > 255 || + s_state.speaker_volume < 0 || s_state.speaker_volume > 100 || + !valid_timezone_offset_minutes(s_state.timezone_offset_minutes) || + !valid_text_blob(s_state.enabled_imes, s_state.enabled_imes_len) || + s_state.message_alerts < 0 || s_state.message_alerts > 1 || + s_state.contact_alerts < 0 || s_state.contact_alerts > 2 || + s_state.gauge_design_mah == 0U || s_state.gauge_design_mah > 10000U || + s_state.gauge_full_mah == 0U || s_state.gauge_full_mah > 10000U || + !valid_wifi_profile_set()) + { + return false; + } +#if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) + if (s_state.cellular_seen != 0U && + (s_state.cellular_seen != kRequiredCellular || !valid_cellular_config(s_state.cellular))) + { + return false; + } +#endif + if (!applying) + { + return true; + } + + s_state.enabled_imes[s_state.enabled_imes_len] = '\0'; + s_state.auto_reply_text[s_state.auto_reply_text_len] = '\0'; + ::platform::ui::settings_store::put_uint(kSettingsNs, + "screen_timeout", + s_state.screen_timeout_ms); + ::platform::ui::settings_store::put_int(kSettingsNs, + "screen_brightness", + static_cast(s_state.screen_brightness)); + ::platform::ui::settings_store::put_int(kSettingsNs, + "speaker_volume", + static_cast(s_state.speaker_volume)); + ::platform::ui::settings_store::put_bool(kSettingsNs, + "vibration_enabled", + s_state.vibration_enabled); + bool ok = ::platform::ui::settings_store::put_string( + kSettingsNs, "display_locale", s_state.locale) && + ::platform::ui::settings_store::put_string( + kSettingsNs, "enabled_imes", s_state.enabled_imes) && + ::platform::ui::settings_store::put_blob(kSettingsNs, + "timezone_tzdef", + s_state.timezone_tzdef, + s_state.timezone_tzdef_len); + ::platform::ui::settings_store::put_int(kSettingsNs, + "timezone_profile", + static_cast(s_state.timezone_profile)); + ::platform::ui::settings_store::put_int(kSettingsNs, + "timezone_offset", + static_cast(s_state.timezone_offset_minutes)); + ::platform::ui::settings_store::put_int(kSettingsNs, + "chat_message_alerts", + static_cast(s_state.message_alerts)); + ::platform::ui::settings_store::put_int(kSettingsNs, + "chat_contact_alerts", + static_cast(s_state.contact_alerts)); + ::platform::ui::settings_store::put_bool(kSettingsNs, + ::platform::ui::auto_reply::kEnabledKey, + s_state.auto_reply_enabled); + ok = ::platform::ui::settings_store::put_string( + kSettingsNs, + ::platform::ui::auto_reply::kTextKey, + s_state.auto_reply_text) && + ok; + ::platform::ui::settings_store::put_bool(kSettingsNs, + "adv_debug", + s_state.debug_logs_enabled); + ::platform::ui::settings_store::put_uint(kPowerNs, + "gauge_design_mah", + s_state.gauge_design_mah); + ::platform::ui::settings_store::put_uint(kPowerNs, + "gauge_full_mah", + s_state.gauge_full_mah); + ok = ::platform::ui::wifi::replace_saved_profiles( + s_state.wifi_enabled, s_state.wifi_profiles, s_state.wifi_profile_count) && + ok; +#if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) + if (s_state.cellular_seen != 0U) + { + ok = ::platform::ui::a7682e::save_config(s_state.cellular) && ok; + } +#endif + return ok; +} + +bool writeRecords(void*, tms::RecordWriter& writer) +{ + if (!writer.u32("ui.screen.timeout_ms", ::platform::ui::screen::timeout_ms()) || + !writer.i32("ui.display.brightness", + ::platform::ui::settings_store::get_int( + kSettingsNs, + "screen_brightness", + static_cast(::platform::ui::device::screen_brightness()))) || + !writer.i32("ui.audio.speaker_volume", + ::platform::ui::settings_store::get_int(kSettingsNs, "speaker_volume", 45)) || + !writer.boolean("ui.haptics.vibration_enabled", + ::platform::ui::settings_store::get_bool( + kSettingsNs, "vibration_enabled", true)) || + !write_text_setting(writer, + "ui.locale", + kSettingsNs, + "display_locale", + s_write_text, + kLocaleBytes) || + !write_string_as_blob(writer, + "ui.ime.enabled", + kSettingsNs, + "enabled_imes", + kEnabledImesBytes - 1U) || + !writer.i32("ui.timezone.profile", ::platform::ui::time::timezone_profile_id()) || + !writer.i32("ui.timezone.offset_minutes", ::platform::ui::time::timezone_offset_min()) || + !write_blob_setting(writer, + "ui.timezone.tzdef", + kSettingsNs, + "timezone_tzdef") || + !writer.i32("chat.alerts.message", + ::platform::ui::settings_store::get_int(kSettingsNs, "chat_message_alerts", 1)) || + !writer.i32("chat.alerts.contact", + ::platform::ui::settings_store::get_int(kSettingsNs, "chat_contact_alerts", 1)) || + !writer.boolean("chat.auto_reply.enabled", + ::platform::ui::settings_store::get_bool( + kSettingsNs, + ::platform::ui::auto_reply::kEnabledKey, + false)) || + !write_string_as_blob(writer, + "chat.auto_reply.text", + kSettingsNs, + ::platform::ui::auto_reply::kTextKey, + kAutoReplyBytes) || + !writer.boolean("debug.sd_logs_enabled", + ::platform::ui::settings_store::get_bool(kSettingsNs, "adv_debug", false)) || + !writer.u32("power.gauge.design_mah", + ::platform::ui::settings_store::get_uint(kPowerNs, "gauge_design_mah", 1500U)) || + !writer.u32("power.gauge.full_mah", + ::platform::ui::settings_store::get_uint(kPowerNs, "gauge_full_mah", 1500U))) + { + return false; + } + + bool wifi_enabled = false; + std::size_t wifi_count = 0U; + if (!::platform::ui::wifi::visit_saved_profiles( + &wifi_enabled, &wifi_count, snapshot_wifi_profile, nullptr) || + wifi_count > ::platform::ui::wifi::kMaxSavedProfileCount || + !writer.boolean("wifi.enabled", wifi_enabled) || + !writer.u8("wifi.profile_count", static_cast(wifi_count))) + { + return false; + } + for (std::size_t index = 0U; index < wifi_count; ++index) + { + char key[48]{}; + std::snprintf(key, sizeof(key), "wifi.profile.%u.ssid", static_cast(index)); + if (!writer.text(key, s_state.wifi_profiles[index].ssid)) + { + return false; + } + std::snprintf(key, sizeof(key), "wifi.profile.%u.password", static_cast(index)); + if (!writer.text(key, s_state.wifi_profiles[index].password)) + { + return false; + } + } +#if defined(ARDUINO_T_DECK_PRO) && defined(TRAIL_MATE_TDECK_PRO_A7682E) + if (!write_cellular_records(writer)) + { + return false; + } +#endif + return true; +} + +} // namespace app::sd_tms::settings_extension diff --git a/platform/esp/arduino_common/src/app_runtime_support.cpp b/platform/esp/arduino_common/src/app_runtime_support.cpp index 8a5f33e9..3ec915c8 100644 --- a/platform/esp/arduino_common/src/app_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_runtime_support.cpp @@ -1,4 +1,5 @@ #include "platform/esp/arduino_common/app_runtime_support.h" +#include "platform/esp/arduino_common/app_config_sd_tms_runtime.h" #include #include @@ -380,6 +381,7 @@ BackgroundTaskStartResult startBackgroundTasks(LoraBoard* board, chat::IMeshAdap void tickBoundLifecycle(std::size_t max_events) { + app::sd_tms::serviceWorkingConfig(); app::IAppLifecycleFacade& lifecycle = app::lifecycleFacade(); lifecycle.updateCoreServices(); lifecycle.tickEventRuntime(); diff --git a/platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp deleted file mode 100644 index aa86ec60..00000000 --- a/platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp +++ /dev/null @@ -1,2344 +0,0 @@ -#include "platform/ui/settings_backup_runtime.h" - -#include "platform/esp/arduino_common/storage/sd_card_runtime.h" -#if defined(ARDUINO) -#include -#include -#else -#include "esp_timer.h" -#include "nvs.h" -#include "platform/esp/idf_common/bsp_runtime.h" -#endif - -#include -#include -#include -#include -#include - -#include "app/app_config.h" -#include "app/app_facade_access.h" -#include "app/tms_config_codec.h" -#if defined(ARDUINO) -#include "cJSON.h" -#else -#include "cJSON.h" -#endif -#include "chat/domain/chat_types.h" -#include "chat/infra/mesh_protocol_utils.h" -#include "platform/ui/device_runtime.h" -#include "platform/ui/reticulum_group_config_runtime.h" -#include "platform/ui/settings_store.h" - -namespace platform::ui::settings_backup -{ -namespace -{ - -namespace tms = ::app::tms; - -constexpr const char* kBackupDir = "/trailmate"; -constexpr const char* kBackupPath = "/trailmate/settings-backup.tms"; -constexpr const char* kBackupTempPath = "/trailmate/settings-backup.tms.tmp"; -constexpr const char* kLegacyBackupPath = "/trailmate/settings-backup.json"; -constexpr const char* kBackupMagic = "trail-mate-settings-backup"; -// Version 2 adds the complete Meshtastic channel presentation settings, -// Reticulum location-request policy, Reticulum group-storage owner, and -// explicit presence markers for settings_store preferences. -// Version 1 remains restore-compatible: fields absent from an older backup -// retain the current value instead of being reset. -constexpr int kBackupVersion = 2; -constexpr std::size_t kMaxBackupBytes = 24 * 1024; -constexpr std::size_t kMaxExtraBlobBytes = 128; -constexpr std::size_t kMaxExtraTextBytes = 128; - -// New TMS backup I/O is deliberately fully bounded. These are persistent -// scratch areas rather than task-stack arrays or whole-document buffers. -tms::LineScratch s_backup_line_scratch{}; -char s_extra_text_scratch[kMaxExtraTextBytes]{}; -uint8_t s_extra_blob_scratch[kMaxExtraBlobBytes]{}; -char s_extra_key_scratch[96]{}; - -uint32_t uptime_ms() -{ -#if defined(ARDUINO) - return millis(); -#else - return static_cast(esp_timer_get_time() / 1000ULL); -#endif -} - -bool storage_exists(const char* path) -{ - return ::platform::esp::arduino_common::storage::sd_exists(path); -} - -bool storage_is_directory(const char* path) -{ - return ::platform::esp::arduino_common::storage::sd_is_directory(path); -} - -bool storage_mkdir(const char* path) -{ - return ::platform::esp::arduino_common::storage::sd_mkdir(path); -} - -bool storage_remove(const char* path) -{ - return ::platform::esp::arduino_common::storage::sd_remove(path); -} - -bool storage_rename(const char* from, const char* to) -{ - return ::platform::esp::arduino_common::storage::sd_rename(from, to); -} - -enum class ValueType : uint8_t -{ - Bool, - Int, - UInt, - String, - Blob, -}; - -struct ExtraKey -{ - const char* ns; - const char* key; - const char* storage_key; - ValueType type; -}; - -constexpr ExtraKey kExtraKeys[] = { - {"settings", "screen_timeout", "screen_timeout", ValueType::UInt}, - {"settings", "screen_brightness", "screen_bright", ValueType::Int}, - {"settings", "speaker_volume", "speaker_volume", ValueType::Int}, - {"settings", "vibration_enabled", "vibe_enabled", ValueType::Bool}, - {"settings", "wifi_enabled", "wifi_enabled", ValueType::Bool}, - {"settings", "wifi_ssid", "wifi_ssid", ValueType::String}, - {"settings", "wifi_password", "wifi_password", ValueType::String}, - {"settings", "wifi_profile_count", "wifi_prof_count", ValueType::Int}, - {"settings", "wifi_ssid_0", "wifi_ssid_0", ValueType::String}, - {"settings", "wifi_password_0", "wifi_password_0", ValueType::String}, - {"settings", "wifi_ssid_1", "wifi_ssid_1", ValueType::String}, - {"settings", "wifi_password_1", "wifi_password_1", ValueType::String}, - {"settings", "wifi_ssid_2", "wifi_ssid_2", ValueType::String}, - {"settings", "wifi_password_2", "wifi_password_2", ValueType::String}, - {"settings", "wifi_ssid_3", "wifi_ssid_3", ValueType::String}, - {"settings", "wifi_password_3", "wifi_password_3", ValueType::String}, - {"settings", "wifi_ssid_4", "wifi_ssid_4", ValueType::String}, - {"settings", "wifi_password_4", "wifi_password_4", ValueType::String}, - {"settings", "wifi_ssid_5", "wifi_ssid_5", ValueType::String}, - {"settings", "wifi_password_5", "wifi_password_5", ValueType::String}, - {"settings", "wifi_ssid_6", "wifi_ssid_6", ValueType::String}, - {"settings", "wifi_password_6", "wifi_password_6", ValueType::String}, - {"settings", "wifi_ssid_7", "wifi_ssid_7", ValueType::String}, - {"settings", "wifi_password_7", "wifi_password_7", ValueType::String}, - {"settings", "wifi_ssid_8", "wifi_ssid_8", ValueType::String}, - {"settings", "wifi_password_8", "wifi_password_8", ValueType::String}, - {"settings", "wifi_ssid_9", "wifi_ssid_9", ValueType::String}, - {"settings", "wifi_password_9", "wifi_password_9", ValueType::String}, - {"settings", "display_locale", "disp_locale", ValueType::String}, - {"settings", "enabled_imes", "enabled_imes", ValueType::String}, - {"settings", "timezone_offset", "timezone_offset", ValueType::Int}, - {"settings", "timezone_profile", "timezone_prof", ValueType::Int}, - {"settings", "timezone_tzdef", "timezone_tzdef", ValueType::Blob}, - {"settings", "chat_message_alerts", "chat_msg_alert", ValueType::Int}, - {"settings", "chat_contact_alerts", "chat_ct_alert", ValueType::Int}, - {"settings", "chat_auto_reply_enabled", "chat_auto_reply", ValueType::Bool}, - {"settings", "chat_auto_reply_text", "chat_auto_txt", ValueType::String}, - {"settings", "adv_debug", "adv_debug", ValueType::Bool}, - {"power", "gauge_design_mah", "gauge_dsgn", ValueType::UInt}, - {"power", "gauge_full_mah", "gauge_full_mah", ValueType::UInt}, -}; -constexpr std::size_t kExtraKeyCount = sizeof(kExtraKeys) / sizeof(kExtraKeys[0]); - -void copy_bounded(char* out, std::size_t out_len, const char* text) -{ - if (!out || out_len == 0) - { - return; - } - std::snprintf(out, out_len, "%s", text ? text : ""); -} - -void set_status_message(Status& out, const char* message, const char* detail = nullptr) -{ - copy_bounded(out.message, sizeof(out.message), message); - copy_bounded(out.detail, sizeof(out.detail), detail); -} - -bool sd_available() -{ -#if defined(ARDUINO) - return ::platform::ui::device::card_ready() && - ::platform::esp::arduino_common::storage::sd_card_ready(); -#else - return ::platform::ui::device::card_ready() && - ::platform::esp::idf_common::bsp_runtime::sdcard_ready(); -#endif -} - -bool ensure_backup_dir() -{ - if (storage_exists(kBackupDir)) - { - return storage_is_directory(kBackupDir); - } - return storage_mkdir(kBackupDir); -} - -const char* value_type_name(ValueType type) -{ - switch (type) - { - case ValueType::Bool: - return "bool"; - case ValueType::Int: - return "int"; - case ValueType::UInt: - return "uint"; - case ValueType::String: - return "string"; - case ValueType::Blob: - return "blob"; - } - return "unknown"; -} - -bool bytes_to_hex(const uint8_t* data, std::size_t len, char* out, std::size_t out_len) -{ - if (!out || out_len == 0) - { - return false; - } - out[0] = '\0'; - if (!data && len > 0) - { - return false; - } - if (out_len < len * 2 + 1) - { - return false; - } - static constexpr char kHex[] = "0123456789ABCDEF"; - for (std::size_t index = 0; index < len; ++index) - { - out[index * 2] = kHex[(data[index] >> 4) & 0x0F]; - out[index * 2 + 1] = kHex[data[index] & 0x0F]; - } - out[len * 2] = '\0'; - return true; -} - -int hex_nibble(char ch) -{ - if (ch >= '0' && ch <= '9') - { - return ch - '0'; - } - if (ch >= 'a' && ch <= 'f') - { - return 10 + ch - 'a'; - } - if (ch >= 'A' && ch <= 'F') - { - return 10 + ch - 'A'; - } - return -1; -} - -bool hex_to_bytes_into(const char* text, - uint8_t* out, - std::size_t capacity, - std::size_t* out_len) -{ - if (out_len) - { - *out_len = 0; - } - if (!text || (!out && capacity != 0)) - { - return false; - } - const std::size_t len = std::strlen(text); - if ((len % 2) != 0) - { - return false; - } - const std::size_t byte_len = len / 2; - if (byte_len > capacity) - { - return false; - } - for (std::size_t index = 0; index < byte_len; ++index) - { - const int high = hex_nibble(text[index * 2]); - const int low = hex_nibble(text[index * 2 + 1]); - if (high < 0 || low < 0) - { - return false; - } - if (out) - { - out[index] = static_cast((high << 4) | low); - } - } - if (out_len) - { - *out_len = byte_len; - } - return true; -} - -cJSON* add_object(cJSON* parent, const char* key) -{ - cJSON* object = cJSON_AddObjectToObject(parent, key); - return object; -} - -void add_bool(cJSON* parent, const char* key, bool value) -{ - cJSON_AddBoolToObject(parent, key, value); -} - -void add_int(cJSON* parent, const char* key, int value) -{ - cJSON_AddNumberToObject(parent, key, value); -} - -void add_uint(cJSON* parent, const char* key, uint32_t value) -{ - cJSON_AddNumberToObject(parent, key, static_cast(value)); -} - -void add_float(cJSON* parent, const char* key, float value) -{ - cJSON_AddNumberToObject(parent, key, static_cast(value)); -} - -void add_string(cJSON* parent, const char* key, const char* value) -{ - cJSON_AddStringToObject(parent, key, value ? value : ""); -} - -void add_blob_hex(cJSON* parent, const char* key, const uint8_t* data, std::size_t len) -{ - char hex[chat::kMeshtasticChannelKeyMaxLen * 2 + 1] = {}; - if (bytes_to_hex(data, len, hex, sizeof(hex))) - { - add_string(parent, key, hex); - } -} - -bool json_bool(cJSON* object, const char* key, bool fallback) -{ - cJSON* item = cJSON_GetObjectItemCaseSensitive(object, key); - if (cJSON_IsBool(item)) - { - return cJSON_IsTrue(item); - } - return fallback; -} - -int json_int(cJSON* object, const char* key, int fallback) -{ - cJSON* item = cJSON_GetObjectItemCaseSensitive(object, key); - if (!cJSON_IsNumber(item)) - { - return fallback; - } - return static_cast(item->valuedouble); -} - -uint32_t json_uint(cJSON* object, const char* key, uint32_t fallback) -{ - cJSON* item = cJSON_GetObjectItemCaseSensitive(object, key); - if (!cJSON_IsNumber(item) || item->valuedouble < 0.0) - { - return fallback; - } - return static_cast(item->valuedouble); -} - -float json_float(cJSON* object, const char* key, float fallback) -{ - cJSON* item = cJSON_GetObjectItemCaseSensitive(object, key); - if (!cJSON_IsNumber(item) || !std::isfinite(item->valuedouble)) - { - return fallback; - } - return static_cast(item->valuedouble); -} - -const char* json_string(cJSON* object, const char* key) -{ - cJSON* item = cJSON_GetObjectItemCaseSensitive(object, key); - return cJSON_IsString(item) && item->valuestring ? item->valuestring : nullptr; -} - -void copy_json_string(cJSON* object, const char* key, char* out, std::size_t out_len) -{ - const char* value = json_string(object, key); - if (value) - { - copy_bounded(out, out_len, value); - } -} - -void copy_json_blob(cJSON* object, - const char* key, - uint8_t* out, - std::size_t capacity, - uint8_t* out_len = nullptr) -{ - if (!out || capacity == 0) - { - return; - } - const char* hex = json_string(object, key); - if (!hex) - { - return; - } - std::size_t bytes_len = 0; - if (!hex_to_bytes_into(hex, out, capacity, &bytes_len)) - { - return; - } - std::memset(out + bytes_len, 0, capacity - bytes_len); - if (out_len) - { - *out_len = static_cast(bytes_len); - } -} - -void add_chat_policy(cJSON* parent, const chat::ChatPolicy& policy) -{ - cJSON* object = add_object(parent, "chat_policy"); - if (!object) - { - return; - } - add_bool(object, "enable_relay", policy.enable_relay); - add_int(object, "hop_limit_default", policy.hop_limit_default); - add_bool(object, "ack_for_broadcast", policy.ack_for_broadcast); - add_bool(object, "ack_for_squad", policy.ack_for_squad); - add_int(object, "max_tx_retries", policy.max_tx_retries); - add_int(object, "max_channels", policy.max_channels); -} - -void restore_chat_policy(cJSON* object, chat::ChatPolicy& policy) -{ - if (!cJSON_IsObject(object)) - { - return; - } - policy.enable_relay = json_bool(object, "enable_relay", policy.enable_relay); - policy.hop_limit_default = static_cast( - json_int(object, "hop_limit_default", policy.hop_limit_default)); - policy.ack_for_broadcast = json_bool(object, "ack_for_broadcast", policy.ack_for_broadcast); - policy.ack_for_squad = json_bool(object, "ack_for_squad", policy.ack_for_squad); - policy.max_tx_retries = static_cast( - json_int(object, "max_tx_retries", policy.max_tx_retries)); - policy.max_channels = static_cast( - json_int(object, "max_channels", policy.max_channels)); -} - -void add_mqtt_client_config(cJSON* parent, - const char* key, - bool enabled, - bool uplink_enabled, - bool downlink_enabled, - const char* host, - uint16_t port, - const char* root, - const char* username, - const char* password) -{ - cJSON* object = add_object(parent, key); - if (!object) - { - return; - } - add_bool(object, "enabled", enabled); - add_bool(object, "uplink_enabled", uplink_enabled); - add_bool(object, "downlink_enabled", downlink_enabled); - add_string(object, "host", host); - add_int(object, "port", port != 0 ? port : 1883); - add_string(object, "root", root); - add_string(object, "username", username); - add_string(object, "password", password); -} - -void restore_mqtt_client_config(cJSON* object, - bool& enabled, - bool& uplink_enabled, - bool& downlink_enabled, - char* host, - std::size_t host_len, - uint16_t& port, - char* root, - std::size_t root_len, - char* username, - std::size_t username_len, - char* password, - std::size_t password_len) -{ - if (!cJSON_IsObject(object)) - { - return; - } - - enabled = json_bool(object, "enabled", enabled); - uplink_enabled = json_bool(object, "uplink_enabled", uplink_enabled); - downlink_enabled = json_bool(object, "downlink_enabled", downlink_enabled); - copy_json_string(object, "host", host, host_len); - const int restored_port = json_int(object, "port", port != 0 ? port : 1883); - if (restored_port > 0 && restored_port <= 65535) - { - port = static_cast(restored_port); - } - copy_json_string(object, "root", root, root_len); - copy_json_string(object, "username", username, username_len); - copy_json_string(object, "password", password, password_len); -} - -void add_meshcore_channel_config(cJSON* parent, const chat::MeshConfig& config) -{ - cJSON* channels = cJSON_AddArrayToObject(parent, "meshcore_channels"); - if (!channels) - { - return; - } - for (std::size_t index = 0; index < chat::kMeshCoreChannelMaxCount; ++index) - { - cJSON* item = cJSON_CreateObject(); - if (!item) - { - continue; - } - const chat::MeshCoreChannelConfig& channel = - config.meshCoreChannel(static_cast(index)); - add_int(item, "slot", static_cast(index)); - add_bool(item, "enabled", index == 0 ? true : channel.enabled); - add_string(item, "name", channel.name); - add_blob_hex(item, "key", channel.key, sizeof(channel.key)); - cJSON_AddItemToArray(channels, item); - } -} - -void restore_meshcore_channel_config(cJSON* parent, chat::MeshConfig& config) -{ - cJSON* channels = cJSON_GetObjectItemCaseSensitive(parent, "meshcore_channels"); - if (cJSON_IsArray(channels)) - { - const uint8_t active_slot = - chat::normalizeMeshCoreChannelSlot(config.meshcore_channel_slot); - config.resetMeshCoreChannels(); - config.meshcore_channel_slot = active_slot; - cJSON* item = nullptr; - cJSON_ArrayForEach(item, channels) - { - if (!cJSON_IsObject(item)) - { - continue; - } - const int slot_raw = json_int(item, "slot", -1); - if (slot_raw < 0 || slot_raw >= static_cast(chat::kMeshCoreChannelMaxCount)) - { - continue; - } - chat::MeshCoreChannelConfig& channel = - config.meshCoreChannel(static_cast(slot_raw)); - channel.enabled = json_bool(item, "enabled", channel.enabled); - copy_json_string(item, "name", channel.name, sizeof(channel.name)); - copy_json_blob(item, "key", channel.key, sizeof(channel.key)); - } - } - else - { - config.importMeshCoreLegacyChannelMirror(); - } - config.meshCoreChannel(0).enabled = true; - config.syncMeshCoreLegacyChannelMirror(); -} - -void add_reticulum_group_config(cJSON* parent, const chat::MeshConfig& config) -{ - cJSON* groups = cJSON_AddArrayToObject(parent, "reticulum_groups"); - if (!groups) - { - return; - } - - for (std::size_t index = 0; index < chat::kReticulumGroupDestinationMaxCount; ++index) - { - const chat::ReticulumGroupDestinationConfig& group = config.reticulum_groups[index]; - if (!chat::hasReticulumDestinationIdentity(group.identity)) - { - continue; - } - - char destination[chat::kReticulumPeerHashSize * 2 + 1] = {}; - chat::formatReticulumDestinationHashText(group.identity, - destination, - sizeof(destination)); - if (destination[0] == '\0') - { - continue; - } - - cJSON* item = cJSON_CreateObject(); - if (!item) - { - continue; - } - add_int(item, "slot", static_cast(index)); - add_bool(item, "enabled", group.enabled); - add_string(item, "name", group.name); - add_string(item, "destination", destination); - cJSON_AddItemToArray(groups, item); - } -} - -bool restore_reticulum_group_config(cJSON* parent, chat::MeshConfig& config) -{ - cJSON* groups = cJSON_GetObjectItemCaseSensitive(parent, "reticulum_groups"); - if (!cJSON_IsArray(groups)) - { - return false; - } - - for (std::size_t index = 0; index < chat::kReticulumGroupDestinationMaxCount; ++index) - { - config.reticulum_groups[index] = chat::ReticulumGroupDestinationConfig{}; - } - - cJSON* item = nullptr; - cJSON_ArrayForEach(item, groups) - { - if (!cJSON_IsObject(item)) - { - continue; - } - const int slot = json_int(item, "slot", -1); - if (slot < 0 || slot >= static_cast(chat::kReticulumGroupDestinationMaxCount)) - { - continue; - } - - const char* destination = json_string(item, "destination"); - if (!destination) - { - continue; - } - - chat::ReticulumGroupDestinationConfig restored{}; - char error[96] = {}; - if (!chat::parseReticulumDestinationHashText(destination, - &restored.identity, - error, - sizeof(error))) - { - std::printf("[SettingsBackup] skip invalid Reticulum group: %s\n", error); - continue; - } - restored.enabled = json_bool(item, "enabled", restored.enabled); - copy_json_string(item, "name", restored.name, sizeof(restored.name)); - config.reticulum_groups[static_cast(slot)] = restored; - } - return true; -} - -void add_mesh_config(cJSON* parent, - const char* key, - const chat::MeshConfig& config, - bool include_meshcore_fields, - bool include_reticulum_fields) -{ - cJSON* object = add_object(parent, key); - if (!object) - { - return; - } - add_int(object, "region", config.region); - add_bool(object, "use_preset", config.use_preset); - add_int(object, "modem_preset", config.modem_preset); - add_float(object, "bandwidth_khz", config.bandwidth_khz); - add_int(object, "spread_factor", config.spread_factor); - add_int(object, "coding_rate", config.coding_rate); - add_int(object, "tx_power", config.tx_power); - add_int(object, "hop_limit", config.hop_limit); - add_bool(object, "tx_enabled", config.tx_enabled); - add_bool(object, "override_duty_cycle", config.override_duty_cycle); - add_int(object, "channel_num", config.channel_num); - add_float(object, "frequency_offset_mhz", config.frequency_offset_mhz); - add_float(object, "override_frequency_mhz", config.override_frequency_mhz); - add_bool(object, "enable_relay", config.enable_relay); - add_bool(object, "ignore_mqtt", config.ignore_mqtt); - add_bool(object, "config_ok_to_mqtt", config.config_ok_to_mqtt); - add_string(object, "primary_channel_name", config.primary_channel_name); - add_string(object, "secondary_channel_name", config.secondary_channel_name); - add_uint(object, "primary_channel_id", config.primary_channel_id); - add_uint(object, "secondary_channel_id", config.secondary_channel_id); - add_int(object, "primary_key_len", config.primary_key_len); - add_blob_hex(object, "primary_key", config.primary_key, config.primary_key_len); - add_int(object, "secondary_key_len", config.secondary_key_len); - add_blob_hex(object, "secondary_key", config.secondary_key, config.secondary_key_len); - if (include_meshcore_fields) - { - add_int(object, "meshcore_region_preset", config.meshcore_region_preset); - add_float(object, "meshcore_freq_mhz", config.meshcore_freq_mhz); - add_float(object, "meshcore_bw_khz", config.meshcore_bw_khz); - add_int(object, "meshcore_sf", config.meshcore_sf); - add_int(object, "meshcore_cr", config.meshcore_cr); - add_bool(object, "meshcore_client_repeat", config.meshcore_client_repeat); - add_float(object, "meshcore_rx_delay_base", config.meshcore_rx_delay_base); - add_float(object, "meshcore_airtime_factor", config.meshcore_airtime_factor); - add_int(object, "meshcore_flood_max", config.meshcore_flood_max); - add_bool(object, "meshcore_multi_acks", config.meshcore_multi_acks); - add_int(object, "meshcore_send_profile", - static_cast(static_cast(config.meshcore_send_profile))); - add_int(object, "meshcore_forward_profile", - static_cast(static_cast(config.meshcore_forward_profile))); - add_int(object, "meshcore_channel_slot", config.meshcore_channel_slot); - const chat::MeshCoreChannelConfig& active_channel = - config.activeMeshCoreChannel(); - add_string(object, "meshcore_channel_name", active_channel.name); - add_blob_hex(object, "meshcore_channel_key", - active_channel.key, sizeof(active_channel.key)); - add_meshcore_channel_config(object, config); - add_mqtt_client_config(object, - "meshcore_mqtt", - config.meshcore_mqtt_enabled, - config.meshcore_mqtt_uplink_enabled, - config.meshcore_mqtt_downlink_enabled, - config.meshcore_mqtt_host, - config.meshcore_mqtt_port, - config.meshcore_mqtt_root, - config.meshcore_mqtt_username, - config.meshcore_mqtt_password); - } - if (include_reticulum_fields) - { - add_bool(object, "reticulum_lora_enabled", config.reticulum_lora_enabled); - add_bool(object, "reticulum_wifi_gateway_enabled", config.reticulum_wifi_gateway_enabled); - add_bool(object, "reticulum_wifi_auto_connect", config.reticulum_wifi_auto_connect); - add_bool(object, "reticulum_anonymous_peer", config.reticulum_anonymous_peer); - add_string(object, "reticulum_wifi_gateway_host", config.reticulum_wifi_gateway_host); - add_int(object, "reticulum_wifi_gateway_port", config.reticulum_wifi_gateway_port); - add_int(object, - "reticulum_interface_policy", - static_cast(static_cast(config.reticulum_interface_policy))); - add_bool(object, - "reticulum_allow_location_requests", - config.reticulum_allow_location_requests); - add_reticulum_group_config(object, config); - } -} - -bool restore_mesh_config(cJSON* object, - chat::MeshConfig& config, - bool include_meshcore_fields, - bool include_reticulum_fields) -{ - if (!cJSON_IsObject(object)) - { - return false; - } - config.region = static_cast(json_int(object, "region", config.region)); - config.use_preset = json_bool(object, "use_preset", config.use_preset); - config.modem_preset = static_cast(json_int(object, "modem_preset", config.modem_preset)); - config.bandwidth_khz = json_float(object, "bandwidth_khz", config.bandwidth_khz); - config.spread_factor = static_cast(json_int(object, "spread_factor", config.spread_factor)); - config.coding_rate = static_cast(json_int(object, "coding_rate", config.coding_rate)); - config.tx_power = static_cast(json_int(object, "tx_power", config.tx_power)); - config.hop_limit = static_cast(json_int(object, "hop_limit", config.hop_limit)); - config.tx_enabled = json_bool(object, "tx_enabled", config.tx_enabled); - config.override_duty_cycle = json_bool(object, "override_duty_cycle", config.override_duty_cycle); - config.channel_num = static_cast(json_int(object, "channel_num", config.channel_num)); - config.frequency_offset_mhz = json_float(object, "frequency_offset_mhz", config.frequency_offset_mhz); - config.override_frequency_mhz = json_float(object, "override_frequency_mhz", config.override_frequency_mhz); - config.enable_relay = json_bool(object, "enable_relay", config.enable_relay); - config.ignore_mqtt = json_bool(object, "ignore_mqtt", config.ignore_mqtt); - config.config_ok_to_mqtt = json_bool(object, "config_ok_to_mqtt", config.config_ok_to_mqtt); - copy_json_string(object, "primary_channel_name", config.primary_channel_name, sizeof(config.primary_channel_name)); - copy_json_string(object, "secondary_channel_name", config.secondary_channel_name, sizeof(config.secondary_channel_name)); - config.primary_channel_id = json_uint(object, "primary_channel_id", config.primary_channel_id); - config.secondary_channel_id = json_uint(object, "secondary_channel_id", config.secondary_channel_id); - uint8_t primary_key_len = static_cast( - json_int(object, "primary_key_len", config.primary_key_len)); - copy_json_blob(object, "primary_key", config.primary_key, sizeof(config.primary_key), &primary_key_len); - config.primary_key_len = chat::normalizeMeshtasticChannelKeyLen( - config.primary_key, sizeof(config.primary_key), primary_key_len); - uint8_t secondary_key_len = static_cast( - json_int(object, "secondary_key_len", config.secondary_key_len)); - copy_json_blob(object, "secondary_key", config.secondary_key, sizeof(config.secondary_key), &secondary_key_len); - config.secondary_key_len = chat::normalizeMeshtasticChannelKeyLen( - config.secondary_key, sizeof(config.secondary_key), secondary_key_len); - if (include_meshcore_fields) - { - config.meshcore_region_preset = static_cast( - json_int(object, "meshcore_region_preset", config.meshcore_region_preset)); - config.meshcore_freq_mhz = json_float(object, "meshcore_freq_mhz", config.meshcore_freq_mhz); - config.meshcore_bw_khz = json_float(object, "meshcore_bw_khz", config.meshcore_bw_khz); - config.meshcore_sf = static_cast(json_int(object, "meshcore_sf", config.meshcore_sf)); - config.meshcore_cr = static_cast(json_int(object, "meshcore_cr", config.meshcore_cr)); - config.meshcore_client_repeat = json_bool(object, "meshcore_client_repeat", config.meshcore_client_repeat); - config.meshcore_rx_delay_base = json_float(object, "meshcore_rx_delay_base", config.meshcore_rx_delay_base); - config.meshcore_airtime_factor = json_float(object, "meshcore_airtime_factor", config.meshcore_airtime_factor); - config.meshcore_flood_max = static_cast(json_int(object, "meshcore_flood_max", config.meshcore_flood_max)); - config.meshcore_multi_acks = json_bool(object, "meshcore_multi_acks", config.meshcore_multi_acks); - config.meshcore_send_profile = static_cast( - json_int(object, "meshcore_send_profile", - static_cast(static_cast(config.meshcore_send_profile)))); - config.meshcore_forward_profile = static_cast( - json_int(object, "meshcore_forward_profile", - static_cast(static_cast(config.meshcore_forward_profile)))); - config.meshcore_channel_slot = static_cast( - json_int(object, "meshcore_channel_slot", config.meshcore_channel_slot)); - copy_json_string(object, "meshcore_channel_name", config.meshcore_channel_name, sizeof(config.meshcore_channel_name)); - copy_json_blob(object, "meshcore_channel_key", config.secondary_key, sizeof(config.secondary_key)); - restore_meshcore_channel_config(object, config); - restore_mqtt_client_config(cJSON_GetObjectItemCaseSensitive(object, "meshcore_mqtt"), - config.meshcore_mqtt_enabled, - config.meshcore_mqtt_uplink_enabled, - config.meshcore_mqtt_downlink_enabled, - config.meshcore_mqtt_host, - sizeof(config.meshcore_mqtt_host), - config.meshcore_mqtt_port, - config.meshcore_mqtt_root, - sizeof(config.meshcore_mqtt_root), - config.meshcore_mqtt_username, - sizeof(config.meshcore_mqtt_username), - config.meshcore_mqtt_password, - sizeof(config.meshcore_mqtt_password)); - } - if (include_reticulum_fields) - { - config.reticulum_lora_enabled = - json_bool(object, "reticulum_lora_enabled", config.reticulum_lora_enabled); - config.reticulum_wifi_gateway_enabled = - json_bool(object, "reticulum_wifi_gateway_enabled", config.reticulum_wifi_gateway_enabled); - config.reticulum_wifi_auto_connect = - json_bool(object, "reticulum_wifi_auto_connect", config.reticulum_wifi_auto_connect); - config.reticulum_anonymous_peer = - json_bool(object, "reticulum_anonymous_peer", config.reticulum_anonymous_peer); - copy_json_string(object, - "reticulum_wifi_gateway_host", - config.reticulum_wifi_gateway_host, - sizeof(config.reticulum_wifi_gateway_host)); - config.reticulum_wifi_gateway_port = static_cast( - json_int(object, - "reticulum_wifi_gateway_port", - config.reticulum_wifi_gateway_port != 0 - ? config.reticulum_wifi_gateway_port - : 4242)); - const int policy = json_int(object, - "reticulum_interface_policy", - static_cast(static_cast( - config.reticulum_interface_policy))); - if (policy >= 0 && - policy <= static_cast(static_cast( - chat::ReticulumInterfacePolicy::WifiGatewayOnly))) - { - config.reticulum_interface_policy = - static_cast(policy); - } - config.reticulum_allow_location_requests = - json_bool(object, - "reticulum_allow_location_requests", - config.reticulum_allow_location_requests); - return restore_reticulum_group_config(object, config); - } - return false; -} - -void add_aprs_config(cJSON* parent, const app::AprsConfig& config) -{ - cJSON* object = add_object(parent, "aprs"); - if (!object) - { - return; - } - add_bool(object, "enabled", config.enabled); - add_string(object, "igate_callsign", config.igate_callsign); - add_int(object, "igate_ssid", config.igate_ssid); - add_string(object, "tocall", config.tocall); - add_string(object, "path", config.path); - add_int(object, "tx_min_interval_s", config.tx_min_interval_s); - add_int(object, "dedupe_window_s", config.dedupe_window_s); - char symbol_table[2] = {config.symbol_table, '\0'}; - char symbol_code[2] = {config.symbol_code, '\0'}; - add_string(object, "symbol_table", symbol_table); - add_string(object, "symbol_code", symbol_code); - add_int(object, "position_interval_s", config.position_interval_s); - add_bool(object, "self_enable", config.self_enable); - add_string(object, "self_callsign", config.self_callsign); -} - -void restore_aprs_config(cJSON* object, app::AprsConfig& config) -{ - if (!cJSON_IsObject(object)) - { - return; - } - config.enabled = json_bool(object, "enabled", config.enabled); - copy_json_string(object, "igate_callsign", config.igate_callsign, sizeof(config.igate_callsign)); - config.igate_ssid = static_cast(json_int(object, "igate_ssid", config.igate_ssid)); - copy_json_string(object, "tocall", config.tocall, sizeof(config.tocall)); - copy_json_string(object, "path", config.path, sizeof(config.path)); - config.tx_min_interval_s = static_cast( - json_int(object, "tx_min_interval_s", config.tx_min_interval_s)); - config.dedupe_window_s = static_cast( - json_int(object, "dedupe_window_s", config.dedupe_window_s)); - const char* symbol_table = json_string(object, "symbol_table"); - if (symbol_table && symbol_table[0] != '\0') - { - config.symbol_table = symbol_table[0]; - } - const char* symbol_code = json_string(object, "symbol_code"); - if (symbol_code && symbol_code[0] != '\0') - { - config.symbol_code = symbol_code[0]; - } - config.position_interval_s = static_cast( - json_int(object, "position_interval_s", config.position_interval_s)); - config.self_enable = json_bool(object, "self_enable", config.self_enable); - copy_json_string(object, "self_callsign", config.self_callsign, sizeof(config.self_callsign)); -} - -cJSON* create_app_config_json(const app::AppConfig& config) -{ - cJSON* object = cJSON_CreateObject(); - if (!object) - { - return nullptr; - } - add_chat_policy(object, config.chat_policy); - add_mesh_config(object, "meshtastic", config.meshtastic_config, false, false); - add_mesh_config(object, "meshcore", config.meshcore_config, true, false); - add_mesh_config(object, "reticulum", config.reticulumConfig(), false, true); - add_mqtt_client_config(object, - "meshtastic_mqtt", - config.meshtastic_mqtt_enabled, - config.meshtastic_mqtt_uplink_enabled, - config.meshtastic_mqtt_downlink_enabled, - config.meshtastic_mqtt_host, - config.meshtastic_mqtt_port, - config.meshtastic_mqtt_root, - config.meshtastic_mqtt_username, - config.meshtastic_mqtt_password); - add_int(object, "mesh_protocol", static_cast(config.mesh_protocol)); - add_string(object, "node_name", config.node_name); - add_string(object, "short_name", config.short_name); - add_bool(object, "primary_enabled", config.primary_enabled); - add_bool(object, "secondary_enabled", config.secondary_enabled); - add_bool(object, "primary_uplink_enabled", config.primary_uplink_enabled); - add_bool(object, "primary_downlink_enabled", config.primary_downlink_enabled); - add_bool(object, "secondary_uplink_enabled", config.secondary_uplink_enabled); - add_bool(object, "secondary_downlink_enabled", config.secondary_downlink_enabled); - add_bool(object, - "primary_channel_has_module_settings", - config.primary_channel_has_module_settings); - add_uint(object, - "primary_channel_position_precision", - config.primary_channel_position_precision); - add_bool(object, "primary_channel_is_muted", config.primary_channel_is_muted); - add_bool(object, - "secondary_channel_has_module_settings", - config.secondary_channel_has_module_settings); - add_uint(object, - "secondary_channel_position_precision", - config.secondary_channel_position_precision); - add_bool(object, "secondary_channel_is_muted", config.secondary_channel_is_muted); - add_bool(object, "gps_enabled", config.gps_enabled); - add_uint(object, "gps_init_baud", config.gps_init_baud); - add_uint(object, "gps_init_probe_ms", config.gps_init_probe_ms); - add_int(object, "gps_init_profile", config.gps_init_profile); - add_int(object, "gps_init_rxm_policy", config.gps_init_rxm_policy); - add_int(object, "gps_init_gnss_policy", config.gps_init_gnss_policy); - add_int(object, "gps_init_nmea_policy", config.gps_init_nmea_policy); - add_uint(object, "gps_interval_ms", config.gps_interval_ms); - add_int(object, "gps_mode", config.gps_mode); - add_int(object, "gps_sat_mask", config.gps_sat_mask); - add_int(object, "gps_strategy", config.gps_strategy); - add_int(object, "gps_alt_ref", config.gps_alt_ref); - add_int(object, "gps_coord_format", config.gps_coord_format); - add_uint(object, "motion_idle_ms", config.motion_config.idle_timeout_ms); - add_int(object, "motion_sensor_id", config.motion_config.sensor_id); - add_int(object, "external_nmea_output_hz", config.external_nmea_output_hz); - add_int(object, "external_nmea_sentence_mask", config.external_nmea_sentence_mask); - add_int(object, "map_coord_system", config.map_coord_system); - add_int(object, "map_source", config.map_source); - add_bool(object, "map_contour_enabled", config.map_contour_enabled); - add_bool(object, "map_track_enabled", config.map_track_enabled); - add_int(object, "map_track_interval", config.map_track_interval); - add_int(object, "map_track_format", config.map_track_format); - add_int(object, "chat_channel", config.chat_channel); - add_bool(object, "net_duty_cycle", config.net_duty_cycle); - add_int(object, "net_channel_util", config.net_channel_util); - add_int(object, "privacy_encrypt_mode", config.privacy_encrypt_mode); - add_bool(object, "route_enabled", config.route_enabled); - add_string(object, "route_path", config.route_path); - add_aprs_config(object, config.aprs); - return object; -} - -bool restore_app_config_json(cJSON* object, app::AppConfig& config) -{ - if (!cJSON_IsObject(object)) - { - return false; - } - restore_chat_policy(cJSON_GetObjectItemCaseSensitive(object, "chat_policy"), config.chat_policy); - restore_mesh_config(cJSON_GetObjectItemCaseSensitive(object, "meshtastic"), config.meshtastic_config, false, false); - restore_mesh_config(cJSON_GetObjectItemCaseSensitive(object, "meshcore"), config.meshcore_config, true, false); - bool restore_reticulum_groups = false; - cJSON* reticulum_object = cJSON_GetObjectItemCaseSensitive(object, "reticulum"); - if (cJSON_IsObject(reticulum_object)) - { - restore_reticulum_groups = - restore_mesh_config(reticulum_object, config.reticulumConfig(), false, true); - } - else - { - restore_reticulum_groups = - restore_mesh_config(cJSON_GetObjectItemCaseSensitive(object, "rnode"), - config.reticulumConfig(), - false, - true); - } - const int protocol = json_int(object, "mesh_protocol", static_cast(config.mesh_protocol)); - if (protocol >= 0 && protocol <= 0xFF && - chat::infra::isValidMeshProtocolValue(static_cast(protocol))) - { - config.mesh_protocol = chat::infra::meshProtocolFromRaw(static_cast(protocol)); - } - restore_mqtt_client_config(cJSON_GetObjectItemCaseSensitive(object, "meshtastic_mqtt"), - config.meshtastic_mqtt_enabled, - config.meshtastic_mqtt_uplink_enabled, - config.meshtastic_mqtt_downlink_enabled, - config.meshtastic_mqtt_host, - sizeof(config.meshtastic_mqtt_host), - config.meshtastic_mqtt_port, - config.meshtastic_mqtt_root, - sizeof(config.meshtastic_mqtt_root), - config.meshtastic_mqtt_username, - sizeof(config.meshtastic_mqtt_username), - config.meshtastic_mqtt_password, - sizeof(config.meshtastic_mqtt_password)); - copy_json_string(object, "node_name", config.node_name, sizeof(config.node_name)); - copy_json_string(object, "short_name", config.short_name, sizeof(config.short_name)); - config.ble_enabled = false; - config.primary_enabled = json_bool(object, "primary_enabled", config.primary_enabled); - config.secondary_enabled = json_bool(object, "secondary_enabled", config.secondary_enabled); - config.primary_uplink_enabled = json_bool(object, "primary_uplink_enabled", config.primary_uplink_enabled); - config.primary_downlink_enabled = json_bool(object, "primary_downlink_enabled", config.primary_downlink_enabled); - config.secondary_uplink_enabled = json_bool(object, "secondary_uplink_enabled", config.secondary_uplink_enabled); - config.secondary_downlink_enabled = json_bool(object, "secondary_downlink_enabled", config.secondary_downlink_enabled); - config.primary_channel_has_module_settings = json_bool( - object, - "primary_channel_has_module_settings", - config.primary_channel_has_module_settings); - config.primary_channel_position_precision = json_uint( - object, - "primary_channel_position_precision", - config.primary_channel_position_precision); - config.primary_channel_is_muted = - json_bool(object, "primary_channel_is_muted", config.primary_channel_is_muted); - config.secondary_channel_has_module_settings = json_bool( - object, - "secondary_channel_has_module_settings", - config.secondary_channel_has_module_settings); - config.secondary_channel_position_precision = json_uint( - object, - "secondary_channel_position_precision", - config.secondary_channel_position_precision); - config.secondary_channel_is_muted = - json_bool(object, "secondary_channel_is_muted", config.secondary_channel_is_muted); - config.gps_enabled = json_bool(object, "gps_enabled", config.gps_enabled); - config.gps_init_baud = json_uint(object, "gps_init_baud", config.gps_init_baud); - config.gps_init_probe_ms = json_uint(object, "gps_init_probe_ms", config.gps_init_probe_ms); - config.gps_init_profile = static_cast(json_int(object, "gps_init_profile", config.gps_init_profile)); - config.gps_init_rxm_policy = static_cast(json_int(object, "gps_init_rxm_policy", config.gps_init_rxm_policy)); - config.gps_init_gnss_policy = static_cast(json_int(object, "gps_init_gnss_policy", config.gps_init_gnss_policy)); - config.gps_init_nmea_policy = static_cast(json_int(object, "gps_init_nmea_policy", config.gps_init_nmea_policy)); - config.gps_interval_ms = json_uint(object, "gps_interval_ms", config.gps_interval_ms); - config.gps_mode = static_cast(json_int(object, "gps_mode", config.gps_mode)); - config.gps_sat_mask = static_cast(json_int(object, "gps_sat_mask", config.gps_sat_mask)); - config.gps_strategy = static_cast(json_int(object, "gps_strategy", config.gps_strategy)); - config.gps_alt_ref = static_cast(json_int(object, "gps_alt_ref", config.gps_alt_ref)); - config.gps_coord_format = static_cast(json_int(object, "gps_coord_format", config.gps_coord_format)); - config.motion_config.idle_timeout_ms = json_uint(object, "motion_idle_ms", config.motion_config.idle_timeout_ms); - config.motion_config.sensor_id = static_cast( - json_int(object, "motion_sensor_id", config.motion_config.sensor_id)); - config.external_nmea_output_hz = static_cast( - json_int(object, "external_nmea_output_hz", config.external_nmea_output_hz)); - config.external_nmea_sentence_mask = static_cast( - json_int(object, "external_nmea_sentence_mask", config.external_nmea_sentence_mask)); - config.map_coord_system = static_cast(json_int(object, "map_coord_system", config.map_coord_system)); - config.map_source = static_cast(json_int(object, "map_source", config.map_source)); - config.map_contour_enabled = json_bool(object, "map_contour_enabled", config.map_contour_enabled); - config.map_track_enabled = json_bool(object, "map_track_enabled", config.map_track_enabled); - config.map_track_interval = static_cast(json_int(object, "map_track_interval", config.map_track_interval)); - config.map_track_format = static_cast(json_int(object, "map_track_format", config.map_track_format)); - config.chat_channel = static_cast(json_int(object, "chat_channel", config.chat_channel)); - config.net_duty_cycle = json_bool(object, "net_duty_cycle", config.net_duty_cycle); - config.net_channel_util = static_cast(json_int(object, "net_channel_util", config.net_channel_util)); - config.privacy_encrypt_mode = static_cast( - json_int(object, "privacy_encrypt_mode", config.privacy_encrypt_mode)); - config.route_enabled = json_bool(object, "route_enabled", config.route_enabled); - copy_json_string(object, "route_path", config.route_path, sizeof(config.route_path)); - restore_aprs_config(cJSON_GetObjectItemCaseSensitive(object, "aprs"), config.aprs); - return restore_reticulum_groups; -} - -bool extra_preference_exists(const ExtraKey& key) -{ -#if defined(ARDUINO) - Preferences prefs; - if (!prefs.begin(key.ns, true)) - { - return false; - } - const PreferenceType type = prefs.getType(key.storage_key ? key.storage_key : key.key); - prefs.end(); - return type != PT_INVALID; -#else - nvs_handle_t handle = 0; - if (nvs_open(key.ns, NVS_READONLY, &handle) != ESP_OK) - { - return false; - } - - const char* storage_key = key.storage_key ? key.storage_key : key.key; - esp_err_t err = ESP_ERR_NVS_NOT_FOUND; - switch (key.type) - { - case ValueType::Bool: - { - uint8_t value = 0; - err = nvs_get_u8(handle, storage_key, &value); - break; - } - case ValueType::Int: - { - int32_t value = 0; - err = nvs_get_i32(handle, storage_key, &value); - break; - } - case ValueType::UInt: - { - uint32_t value = 0; - err = nvs_get_u32(handle, storage_key, &value); - break; - } - case ValueType::String: - { - std::size_t size = 0; - err = nvs_get_str(handle, storage_key, nullptr, &size); - break; - } - case ValueType::Blob: - { - std::size_t size = 0; - err = nvs_get_blob(handle, storage_key, nullptr, &size); - break; - } - } - nvs_close(handle); - return err == ESP_OK; -#endif -} - -void add_extra_value(cJSON* parent, const ExtraKey& key) -{ - cJSON* ns_object = cJSON_GetObjectItemCaseSensitive(parent, key.ns); - if (!cJSON_IsObject(ns_object)) - { - ns_object = cJSON_AddObjectToObject(parent, key.ns); - } - if (!ns_object) - { - return; - } - - cJSON* value_object = cJSON_AddObjectToObject(ns_object, key.key); - if (!value_object) - { - return; - } - add_string(value_object, "type", value_type_name(key.type)); - - // A portable snapshot must preserve both an explicit value and the - // absence of a value. The latter means that this device is using the - // setting's code-defined default; restore must clear a stale target-side - // override instead of silently retaining it. - const bool present = extra_preference_exists(key); - add_bool(value_object, "present", present); - if (!present) - { - return; - } - - switch (key.type) - { - case ValueType::Bool: - add_bool(value_object, - "value", - ::platform::ui::settings_store::get_bool(key.ns, key.key, false)); - break; - case ValueType::Int: - add_int(value_object, - "value", - ::platform::ui::settings_store::get_int(key.ns, key.key, 0)); - break; - case ValueType::UInt: - add_uint(value_object, - "value", - ::platform::ui::settings_store::get_uint(key.ns, key.key, 0)); - break; - case ValueType::String: - { - std::string value; - if (::platform::ui::settings_store::get_string(key.ns, key.key, value)) - { - add_string(value_object, "value", value.c_str()); - } - break; - } - case ValueType::Blob: - { - uint8_t value[kMaxExtraBlobBytes] = {}; - std::size_t value_len = 0; - if (::platform::ui::settings_store::get_blob_into(key.ns, - key.key, - value, - sizeof(value), - &value_len)) - { - char hex[kMaxExtraBlobBytes * 2 + 1] = {}; - if (bytes_to_hex(value, value_len, hex, sizeof(hex))) - { - add_string(value_object, "value", hex); - } - } - break; - } - } -} - -void restore_extra_value(const ExtraKey& key, cJSON* value_object) -{ - if (!cJSON_IsObject(value_object)) - { - return; - } - - cJSON* present = cJSON_GetObjectItemCaseSensitive(value_object, "present"); - if (cJSON_IsFalse(present)) - { - // `remove_keys` accepts logical names and applies the physical NVS - // alias internally. Do not erase `storage_key` directly here. - const char* keys[] = {key.key}; - ::platform::ui::settings_store::remove_keys(key.ns, keys, 1); - return; - } - - // Version 1 documents do not contain `present`; their existing values - // must still import and omitted entries must leave the target unchanged. - cJSON* value = cJSON_GetObjectItemCaseSensitive(value_object, "value"); - switch (key.type) - { - case ValueType::Bool: - if (cJSON_IsBool(value)) - { - ::platform::ui::settings_store::put_bool(key.ns, key.key, cJSON_IsTrue(value)); - } - break; - case ValueType::Int: - if (cJSON_IsNumber(value)) - { - ::platform::ui::settings_store::put_int(key.ns, key.key, static_cast(value->valuedouble)); - } - break; - case ValueType::UInt: - if (cJSON_IsNumber(value) && value->valuedouble >= 0.0) - { - ::platform::ui::settings_store::put_uint(key.ns, key.key, static_cast(value->valuedouble)); - } - break; - case ValueType::String: - if (cJSON_IsString(value) && value->valuestring) - { - (void)::platform::ui::settings_store::put_string(key.ns, key.key, value->valuestring); - } - break; - case ValueType::Blob: - if (cJSON_IsString(value) && value->valuestring) - { - std::size_t bytes_len = 0; - if (hex_to_bytes_into(value->valuestring, - s_extra_blob_scratch, - sizeof(s_extra_blob_scratch), - &bytes_len)) - { - (void)::platform::ui::settings_store::put_blob( - key.ns, - key.key, - bytes_len == 0 ? nullptr : s_extra_blob_scratch, - bytes_len); - } - } - break; - } -} - -void add_extra_settings(cJSON* root) -{ - cJSON* extra = cJSON_AddObjectToObject(root, "extra_settings"); - if (!extra) - { - return; - } - for (const ExtraKey& key : kExtraKeys) - { - add_extra_value(extra, key); - } -} - -void restore_extra_settings(cJSON* root) -{ - cJSON* extra = cJSON_GetObjectItemCaseSensitive(root, "extra_settings"); - if (!cJSON_IsObject(extra)) - { - return; - } - for (const ExtraKey& key : kExtraKeys) - { - cJSON* ns_object = cJSON_GetObjectItemCaseSensitive(extra, key.ns); - cJSON* value_object = cJSON_IsObject(ns_object) - ? cJSON_GetObjectItemCaseSensitive(ns_object, key.key) - : nullptr; - restore_extra_value(key, value_object); - } -} - -uint32_t tms_crc32_update(uint32_t crc, const uint8_t* data, std::size_t length) -{ - for (std::size_t index = 0U; index < length; ++index) - { - crc ^= data[index]; - for (uint8_t bit = 0U; bit < 8U; ++bit) - { - crc = (crc & 1U) != 0U ? (crc >> 1U) ^ 0xEDB88320UL : crc >> 1U; - } - } - return crc; -} - -struct TmsBackupOutput -{ - ::platform::esp::arduino_common::storage::SdRuntimeFile* file = nullptr; - const app::AppConfig* config = nullptr; - uint32_t running_crc = 0xFFFFFFFFUL; -}; - -bool write_tms_output(void* context, const char* data, std::size_t length) -{ - auto* output = static_cast(context); - if (!output || !output->file || !data || length == 0U || - output->file->write(data, length) != length) - { - return false; - } - output->running_crc = tms_crc32_update( - output->running_crc, reinterpret_cast(data), length); - return true; -} - -bool format_extra_record_key(const ExtraKey& key, const char* suffix) -{ - const int written = std::snprintf(s_extra_key_scratch, - sizeof(s_extra_key_scratch), - "extra.%s.%s.%s", - key.ns, - key.key, - suffix); - return written > 0 && static_cast(written) < sizeof(s_extra_key_scratch); -} - -bool write_extra_records(void* context, tms::RecordWriter& writer) -{ - auto* output = static_cast(context); - if (!output) - { - return false; - } - for (const ExtraKey& key : kExtraKeys) - { - const bool present = extra_preference_exists(key); - if (!format_extra_record_key(key, "present") || - !writer.boolean(s_extra_key_scratch, present)) - { - return false; - } - if (!present) - { - continue; - } - if (!format_extra_record_key(key, "value")) - { - return false; - } - switch (key.type) - { - case ValueType::Bool: - if (!writer.boolean(s_extra_key_scratch, - ::platform::ui::settings_store::get_bool(key.ns, key.key, false))) - { - return false; - } - break; - case ValueType::Int: - if (!writer.i32(s_extra_key_scratch, - static_cast(::platform::ui::settings_store::get_int( - key.ns, key.key, 0)))) - { - return false; - } - break; - case ValueType::UInt: - if (!writer.u32(s_extra_key_scratch, - ::platform::ui::settings_store::get_uint(key.ns, key.key, 0U))) - { - return false; - } - break; - case ValueType::String: - { - std::size_t length = 0U; - if (!::platform::ui::settings_store::get_string_into(key.ns, - key.key, - s_extra_text_scratch, - sizeof(s_extra_text_scratch), - &length) || - !writer.text(s_extra_key_scratch, s_extra_text_scratch)) - { - return false; - } - break; - } - case ValueType::Blob: - { - std::size_t length = 0U; - if (!::platform::ui::settings_store::get_blob_into(key.ns, - key.key, - s_extra_blob_scratch, - sizeof(s_extra_blob_scratch), - &length) || - !writer.blob(s_extra_key_scratch, s_extra_blob_scratch, length)) - { - return false; - } - break; - } - } - } - if (!output->config) - { - return false; - } - const chat::MeshConfig& reticulum = output->config->reticulumConfig(); - for (std::size_t slot = 0U; slot < chat::kReticulumGroupDestinationMaxCount; ++slot) - { - const chat::ReticulumGroupDestinationConfig& group = reticulum.reticulum_groups[slot]; - const bool present = chat::hasReticulumDestinationIdentity(group.identity); - const int present_len = std::snprintf(s_extra_key_scratch, - sizeof(s_extra_key_scratch), - "reticulum_group.%u.present", - static_cast(slot)); - if (present_len <= 0 || static_cast(present_len) >= sizeof(s_extra_key_scratch) || - !writer.boolean(s_extra_key_scratch, present)) - { - return false; - } - if (!present) - { - continue; - } - if (std::snprintf(s_extra_key_scratch, - sizeof(s_extra_key_scratch), - "reticulum_group.%u.enabled", - static_cast(slot)) <= 0 || - !writer.boolean(s_extra_key_scratch, group.enabled) || - std::snprintf(s_extra_key_scratch, - sizeof(s_extra_key_scratch), - "reticulum_group.%u.name", - static_cast(slot)) <= 0 || - !writer.text(s_extra_key_scratch, group.name) || - std::snprintf(s_extra_key_scratch, - sizeof(s_extra_key_scratch), - "reticulum_group.%u.destination", - static_cast(slot)) <= 0 || - !writer.blob(s_extra_key_scratch, - group.identity.destination_hash, - chat::kReticulumPeerHashSize)) - { - return false; - } - } - // The checksum covers each previous physical line, including its newline. - // It is intentionally emitted immediately before END so a portable backup - // can be verified without an allocation-proportional side structure. - return writer.u32("checksum.crc32", output->running_crc ^ 0xFFFFFFFFUL); -} - -bool replace_tms_backup(const char* temporary_path, const char* path) -{ - if (storage_exists(path) && !storage_remove(path)) - { - return false; - } - if (storage_rename(temporary_path, path)) - { - return true; - } - (void)storage_remove(temporary_path); - return false; -} - -bool write_tms_backup() -{ - if (!ensure_backup_dir()) - { - return false; - } - if (storage_exists(kBackupTempPath)) - { - (void)storage_remove(kBackupTempPath); - } - ::platform::esp::arduino_common::storage::SdRuntimeFile file; - if (!file.open(kBackupTempPath, "w")) - { - return false; - } - TmsBackupOutput output{&file, &app::appFacade().readConfig()}; - tms::DocumentInfo info{}; - const bool wrote = tms::writeDocument(*output.config, - tms::DocumentKind::Backup, - {&output, write_tms_output}, - s_backup_line_scratch, - &info, - write_extra_records, - &output); - const bool flushed = file.flush(); - file.close(); - if (!wrote || !flushed) - { - (void)storage_remove(kBackupTempPath); - return false; - } - return replace_tms_backup(kBackupTempPath, kBackupPath); -} - -struct ExtraRestoreState -{ - uint64_t seen_present = 0U; - uint64_t present_values = 0U; - uint64_t seen_values = 0U; - bool saw_checksum = false; -}; - -struct ReticulumGroupRestoreState -{ - uint8_t seen_present = 0U; - uint8_t present_values = 0U; - uint8_t seen_enabled = 0U; - uint8_t seen_name = 0U; - uint8_t seen_destination = 0U; -}; - -static_assert(kExtraKeyCount < 64U, - "TMS backup extra-field validation uses one bounded bitset"); - -int base64_value(char value) -{ - if (value >= 'A' && value <= 'Z') return value - 'A'; - if (value >= 'a' && value <= 'z') return value - 'a' + 26; - if (value >= '0' && value <= '9') return value - '0' + 52; - if (value == '+') return 62; - if (value == '/') return 63; - return -1; -} - -bool decode_tms_text(const char* value, char* out, std::size_t capacity) -{ - if (!value || !out || capacity == 0U) - { - return false; - } - std::size_t written = 0U; - for (const char* cursor = value; *cursor != '\0'; ++cursor) - { - unsigned char decoded = static_cast(*cursor); - if (*cursor == '%') - { - const int high = hex_nibble(cursor[1]); - const int low = hex_nibble(cursor[2]); - if (high < 0 || low < 0 || (high == 0 && low == 0)) - { - return false; - } - decoded = static_cast((high << 4) | low); - cursor += 2; - } - if (written + 1U >= capacity) - { - return false; - } - out[written++] = static_cast(decoded); - } - out[written] = '\0'; - return true; -} - -bool decode_tms_base64(const char* value, - uint8_t* out, - std::size_t capacity, - std::size_t* out_len) -{ - if (out_len) - { - *out_len = 0U; - } - if (!value || !out) - { - return false; - } - const std::size_t length = std::strlen(value); - if (length == 0U) - { - return true; - } - if ((length % 4U) != 0U) - { - return false; - } - std::size_t padding = value[length - 1U] == '=' ? 1U : 0U; - if (padding == 1U && value[length - 2U] == '=') - { - ++padding; - } - if (padding > 2U || (padding == 1U && value[length - 2U] == '=') || - (padding == 2U && value[length - 3U] == '=')) - { - return false; - } - const std::size_t decoded_length = length / 4U * 3U - padding; - if (decoded_length > capacity) - { - return false; - } - std::size_t written = 0U; - for (std::size_t index = 0U; index < length; index += 4U) - { - const bool last = index + 4U == length; - const int a = base64_value(value[index]); - const int b = base64_value(value[index + 1U]); - const int c = value[index + 2U] == '=' ? -2 : base64_value(value[index + 2U]); - const int d = value[index + 3U] == '=' ? -2 : base64_value(value[index + 3U]); - if (a < 0 || b < 0 || c == -1 || d == -1 || (!last && (c < 0 || d < 0)) || - (c == -2 && d != -2)) - { - return false; - } - const uint32_t bits = (static_cast(a) << 18U) | - (static_cast(b) << 12U) | - (static_cast(c < 0 ? 0 : c) << 6U) | - static_cast(d < 0 ? 0 : d); - out[written++] = static_cast((bits >> 16U) & 0xFFU); - if (c != -2) out[written++] = static_cast((bits >> 8U) & 0xFFU); - if (d != -2) out[written++] = static_cast(bits & 0xFFU); - } - if (out_len) - { - *out_len = decoded_length; - } - return written == decoded_length; -} - -bool parse_tms_bool(const char* type, const char* value, bool* out) -{ - if (!type || !value || std::strcmp(type, "bool") != 0 || - (std::strcmp(value, "0") != 0 && std::strcmp(value, "1") != 0)) - { - return false; - } - if (out) *out = value[0] == '1'; - return true; -} - -bool parse_tms_u32(const char* type, const char* value, uint32_t* out) -{ - if (!type || !value || std::strcmp(type, "u32") != 0 || value[0] == '\0') - { - return false; - } - uint32_t parsed = 0U; - for (const char* cursor = value; *cursor != '\0'; ++cursor) - { - if (*cursor < '0' || *cursor > '9') - { - return false; - } - const uint32_t digit = static_cast(*cursor - '0'); - if (parsed > (UINT32_MAX - digit) / 10U) - { - return false; - } - parsed = parsed * 10U + digit; - } - if (out) *out = parsed; - return true; -} - -bool parse_tms_i32(const char* type, const char* value, int* out) -{ - if (!type || !value || std::strcmp(type, "i32") != 0 || value[0] == '\0') - { - return false; - } - char* end = nullptr; - const long parsed = std::strtol(value, &end, 10); - if (end == value || *end != '\0' || parsed < INT32_MIN || parsed > INT32_MAX) - { - return false; - } - if (out) *out = static_cast(parsed); - return true; -} - -int find_extra_key(const char* ns, const char* key) -{ - for (std::size_t index = 0U; index < kExtraKeyCount; ++index) - { - if (std::strcmp(kExtraKeys[index].ns, ns) == 0 && - std::strcmp(kExtraKeys[index].key, key) == 0) - { - return static_cast(index); - } - } - return -1; -} - -bool consume_extra_record(char* line, bool apply, ExtraRestoreState* state) -{ - if (!line || !state || std::strncmp(line, "extra.", 6U) != 0) - { - return false; - } - char* equal = std::strchr(line, '='); - if (!equal || equal == line) - { - return false; - } - *equal = '\0'; - char* type = equal + 1; - char* colon = std::strchr(type, ':'); - if (!colon || colon == type) - { - return false; - } - *colon = '\0'; - char* value = colon + 1; - char* ns = line + 6U; - char* dot = std::strchr(ns, '.'); - if (!dot) - { - return false; - } - *dot = '\0'; - char* setting = dot + 1; - dot = std::strrchr(setting, '.'); - if (!dot) - { - return false; - } - *dot = '\0'; - const char* suffix = dot + 1; - const int index = find_extra_key(ns, setting); - if (index < 0) - { - return true; // forward-compatible extra setting - } - const uint64_t bit = 1ULL << static_cast(index); - const ExtraKey& descriptor = kExtraKeys[index]; - if (std::strcmp(suffix, "present") == 0) - { - bool present = false; - if (!parse_tms_bool(type, value, &present)) - { - return false; - } - state->seen_present |= bit; - if (present) - { - state->present_values |= bit; - } - else if (apply) - { - const char* keys[] = {descriptor.key}; - ::platform::ui::settings_store::remove_keys(descriptor.ns, keys, 1U); - } - return true; - } - if (std::strcmp(suffix, "value") != 0 || (state->seen_present & bit) == 0U || - (state->present_values & bit) == 0U) - { - return false; - } - state->seen_values |= bit; - switch (descriptor.type) - { - case ValueType::Bool: - { - bool parsed = false; - if (!parse_tms_bool(type, value, &parsed)) return false; - if (apply) ::platform::ui::settings_store::put_bool(descriptor.ns, descriptor.key, parsed); - return true; - } - case ValueType::Int: - { - int parsed = 0; - if (!parse_tms_i32(type, value, &parsed)) return false; - if (apply) ::platform::ui::settings_store::put_int(descriptor.ns, descriptor.key, parsed); - return true; - } - case ValueType::UInt: - { - uint32_t parsed = 0U; - if (!parse_tms_u32(type, value, &parsed)) return false; - if (apply) ::platform::ui::settings_store::put_uint(descriptor.ns, descriptor.key, parsed); - return true; - } - case ValueType::String: - if (!decode_tms_text(value, s_extra_text_scratch, sizeof(s_extra_text_scratch)) || - std::strcmp(type, "str") != 0) - return false; - return !apply || ::platform::ui::settings_store::put_string( - descriptor.ns, descriptor.key, s_extra_text_scratch); - case ValueType::Blob: - { - std::size_t length = 0U; - if (std::strcmp(type, "b64") != 0 || - !decode_tms_base64(value, - s_extra_blob_scratch, - sizeof(s_extra_blob_scratch), - &length)) - return false; - return !apply || ::platform::ui::settings_store::put_blob( - descriptor.ns, - descriptor.key, - length == 0U ? nullptr : s_extra_blob_scratch, - length); - } - default: - return false; - } - -} - -bool consume_reticulum_group_record(char* line, - app::AppConfig* target, - ReticulumGroupRestoreState* state) -{ - static constexpr const char* kPrefix = "reticulum_group."; - if (!line || !state || std::strncmp(line, kPrefix, std::strlen(kPrefix)) != 0) - { - return false; - } - char* equal = std::strchr(line, '='); - if (!equal || equal == line) - { - return false; - } - *equal = '\0'; - char* type = equal + 1; - char* colon = std::strchr(type, ':'); - if (!colon || colon == type) - { - return false; - } - *colon = '\0'; - char* value = colon + 1; - const char* slot_text = line + std::strlen(kPrefix); - if (slot_text[0] < '0' || - slot_text[0] >= '0' + static_cast(chat::kReticulumGroupDestinationMaxCount) || - slot_text[1] != '.') - { - return false; - } - const uint8_t slot = static_cast(slot_text[0] - '0'); - const uint8_t bit = static_cast(1U << slot); - const char* suffix = slot_text + 2; - chat::ReticulumGroupDestinationConfig* group = - target ? &target->reticulumConfig().reticulum_groups[slot] : nullptr; - if (std::strcmp(suffix, "present") == 0) - { - bool present = false; - if (!parse_tms_bool(type, value, &present)) - { - return false; - } - state->seen_present |= bit; - if (present) - { - state->present_values |= bit; - } - else if (group) - { - *group = chat::ReticulumGroupDestinationConfig{}; - } - return true; - } - if ((state->seen_present & bit) == 0U || (state->present_values & bit) == 0U) - { - return false; - } - if (std::strcmp(suffix, "enabled") == 0) - { - bool enabled = false; - if (!parse_tms_bool(type, value, &enabled)) return false; - if (group) group->enabled = enabled; - state->seen_enabled |= bit; - return true; - } - if (std::strcmp(suffix, "name") == 0) - { - if (std::strcmp(type, "str") != 0 || - !decode_tms_text(value, s_extra_text_scratch, sizeof(s_extra_text_scratch)) || - std::strlen(s_extra_text_scratch) >= chat::kReticulumGroupNameMaxLen) - return false; - if (group) - { - std::snprintf(group->name, sizeof(group->name), "%s", s_extra_text_scratch); - } - state->seen_name |= bit; - return true; - } - if (std::strcmp(suffix, "destination") == 0) - { - std::size_t length = 0U; - if (std::strcmp(type, "b64") != 0 || - !decode_tms_base64(value, - s_extra_blob_scratch, - sizeof(s_extra_blob_scratch), - &length) || - length != chat::kReticulumPeerHashSize) - return false; - if (group) - { - group->identity = chat::makeReticulumDestinationIdentity(s_extra_blob_scratch); - } - state->seen_destination |= bit; - return true; - } - return false; -} - -bool consume_tms_checksum(char* line, uint32_t crc_before_line, ExtraRestoreState* state) -{ - if (!line || !state || std::strncmp(line, "checksum.crc32=", 15U) != 0) - { - return false; - } - char* equal = std::strchr(line, '='); - char* colon = equal ? std::strchr(equal + 1, ':') : nullptr; - if (!equal || !colon) - { - return false; - } - *colon = '\0'; - uint32_t expected = 0U; - if (!parse_tms_u32(equal + 1, colon + 1, &expected) || state->saw_checksum || - expected != (crc_before_line ^ 0xFFFFFFFFUL)) - { - return false; - } - state->saw_checksum = true; - return true; -} - -bool finish_extra_restore(const ExtraRestoreState& state) -{ - return state.saw_checksum && state.seen_present == ((1ULL << kExtraKeyCount) - 1ULL) && - state.seen_values == state.present_values; -} - -bool finish_reticulum_group_restore(const ReticulumGroupRestoreState& state) -{ - const uint8_t all_slots = static_cast( - (1U << chat::kReticulumGroupDestinationMaxCount) - 1U); - return state.seen_present == all_slots && - state.seen_enabled == state.present_values && - state.seen_name == state.present_values && - state.seen_destination == state.present_values; -} - -bool read_tms_backup(app::AppConfig* target, bool apply_extra) -{ - if (!storage_exists(kBackupPath)) - { - return false; - } - ::platform::esp::arduino_common::storage::SdRuntimeFile file; - if (!file.open(kBackupPath, "r") || file.size() == 0U || - file.size() > tms::kMaxDocumentBytes) - { - file.close(); - return false; - } - tms::Decoder decoder(target, tms::DocumentKind::Backup); - ExtraRestoreState extra{}; - ReticulumGroupRestoreState groups{}; - uint32_t running_crc = 0xFFFFFFFFUL; - uint32_t line_crc_start = running_crc; - std::size_t line_length = 0U; - const uint64_t size = file.size(); - bool ok = true; - for (uint64_t index = 0U; index < size; ++index) - { - if (line_length == 0U) - { - line_crc_start = running_crc; - } - const int raw = file.read_byte(); - if (raw < 0) - { - ok = false; - break; - } - const uint8_t byte = static_cast(raw); - running_crc = tms_crc32_update(running_crc, &byte, 1U); - if (byte != '\n') - { - if (line_length + 1U >= sizeof(s_backup_line_scratch.bytes)) - { - ok = false; - break; - } - s_backup_line_scratch.bytes[line_length++] = static_cast(byte); - continue; - } - if (line_length > 0U && s_backup_line_scratch.bytes[line_length - 1U] == '\r') - { - --line_length; - } - s_backup_line_scratch.bytes[line_length] = '\0'; - if (extra.saw_checksum && std::strcmp(s_backup_line_scratch.bytes, "END") != 0) - { - ok = false; - break; - } - if (std::strncmp(s_backup_line_scratch.bytes, "extra.", 6U) == 0) - { - ok = consume_extra_record(s_backup_line_scratch.bytes, apply_extra, &extra); - } - else if (std::strncmp(s_backup_line_scratch.bytes, "reticulum_group.", 16U) == 0) - { - ok = consume_reticulum_group_record(s_backup_line_scratch.bytes, target, &groups); - } - else if (std::strncmp(s_backup_line_scratch.bytes, "checksum.crc32=", 15U) == 0) - { - ok = consume_tms_checksum(s_backup_line_scratch.bytes, line_crc_start, &extra); - } - else - { - ok = decoder.consumeLine(s_backup_line_scratch.bytes); - } - if (!ok) break; - line_length = 0U; - } - file.close(); - return ok && line_length == 0U && decoder.finish() && finish_extra_restore(extra) && - finish_reticulum_group_restore(groups); -} - -bool restore_tms_backup() -{ - if (!read_tms_backup(nullptr, false)) - { - return false; - } - app::IAppFacade& facade = app::appFacade(); - auto edit = facade.beginConfigEdit(); - if (!edit || !read_tms_backup(&edit.config(), true)) - { - return false; - } - const ::platform::ui::reticulum_groups::Status group_submit = - ::platform::ui::reticulum_groups::submit( - edit.config().reticulumConfig().reticulum_groups, - chat::kReticulumGroupDestinationMaxCount); - if (!group_submit.queued) - { - return false; - } - const ::platform::ui::reticulum_groups::Status group_save = - ::platform::ui::reticulum_groups::flushPending(); - if (!group_save.saved) - { - return false; - } - edit.commit(app::AppConfigChangeSet::allPersisted()); - return true; -} - -cJSON* create_backup_document() -{ - app::IAppFacade& facade = app::appFacade(); - cJSON* root = cJSON_CreateObject(); - if (!root) - { - return nullptr; - } - add_string(root, "magic", kBackupMagic); - add_int(root, "version", kBackupVersion); - add_string(root, "firmware", ::platform::ui::device::firmware_version()); - add_uint(root, "created_ms", uptime_ms()); - - cJSON* app_config = create_app_config_json(facade.readConfig()); - if (!app_config) - { - cJSON_Delete(root); - return nullptr; - } - cJSON_AddItemToObject(root, "app_config", app_config); - add_extra_settings(root); - return root; -} - -bool write_text_atomic(const char* path, const char* temp_path, const char* text, std::size_t len) -{ - if (!path || !temp_path || !text) - { - return false; - } - if (storage_exists(temp_path)) - { - storage_remove(temp_path); - } - ::platform::esp::arduino_common::storage::SdRuntimeFile file; - if (!file.open(temp_path, "w")) - { - return false; - } - const bool wrote = file.write(reinterpret_cast(text), len) == len; - const bool flushed = file.flush(); - file.close(); - if (!wrote || !flushed) - { - storage_remove(temp_path); - return false; - } - if (storage_exists(path)) - { - storage_remove(path); - } - if (!storage_rename(temp_path, path)) - { - storage_remove(temp_path); - return false; - } - return true; -} - -bool read_file_text(const char* path, std::string& out) -{ - out.clear(); - ::platform::esp::arduino_common::storage::SdRuntimeFile file; - if (!file.open(path, "r")) - { - return false; - } - const std::size_t size = file.size(); - if (size == 0 || size > kMaxBackupBytes) - { - file.close(); - return false; - } - out.resize(size); - const std::size_t read = file.read_bytes(&out[0], size); - file.close(); - if (read != size) - { - out.clear(); - return false; - } - return true; -} - -bool validate_document(cJSON* root, char* error, std::size_t error_len) -{ - if (!cJSON_IsObject(root)) - { - copy_bounded(error, error_len, "Invalid backup document"); - return false; - } - const char* magic = json_string(root, "magic"); - if (!magic || std::strcmp(magic, kBackupMagic) != 0) - { - copy_bounded(error, error_len, "Not a Trail-Mate settings backup"); - return false; - } - const int version = json_int(root, "version", 0); - if (version <= 0 || version > kBackupVersion) - { - copy_bounded(error, error_len, "Unsupported backup version"); - return false; - } - if (!cJSON_IsObject(cJSON_GetObjectItemCaseSensitive(root, "app_config"))) - { - copy_bounded(error, error_len, "Backup missing app_config"); - return false; - } - return true; -} - -} // namespace - -bool is_supported() -{ - return true; -} - -Status status() -{ - Status out{}; - out.supported = true; - out.sd_present = sd_available(); - out.has_backup = out.sd_present && storage_exists(kBackupPath); - out.busy = false; - if (!out.sd_present) - { - set_status_message(out, "SD not detected", kBackupPath); - } - else if (out.has_backup) - { - set_status_message(out, "Backup found", kBackupPath); - } - else - { - set_status_message(out, "No backup found", kBackupPath); - } - return out; -} - -bool backup() -{ - if (!sd_available()) - { - return false; - } - return write_tms_backup(); -} - -bool restore() -{ - if (!sd_available()) - { - return false; - } - if (storage_exists(kBackupPath)) - { - return restore_tms_backup(); - } - // JSON is retained only as an explicit one-release compatibility import. - // The normal backup path never creates it and never allocates a JSON tree. - if (!storage_exists(kLegacyBackupPath)) - { - return false; - } - std::string text; - if (!read_file_text(kLegacyBackupPath, text)) - { - return false; - } - cJSON* root = cJSON_ParseWithLength(text.c_str(), text.size()); - char error[96] = {}; - if (!validate_document(root, error, sizeof(error))) - { - if (root) - { - cJSON_Delete(root); - } - std::printf("[SettingsBackup] restore validation failed: %s\n", error); - return false; - } - - app::IAppFacade& facade = app::appFacade(); - auto edit = facade.beginConfigEdit(); - if (!edit) - { - cJSON_Delete(root); - return false; - } - const bool restore_reticulum_groups = - restore_app_config_json(cJSON_GetObjectItemCaseSensitive(root, "app_config"), - edit.config()); - - if (restore_reticulum_groups) - { - const ::platform::ui::reticulum_groups::Status group_submit = - ::platform::ui::reticulum_groups::submit( - edit.config().reticulumConfig().reticulum_groups, - chat::kReticulumGroupDestinationMaxCount); - if (!group_submit.queued) - { - std::printf("[SettingsBackup] Reticulum group restore was not queued: %s\n", - group_submit.message); - cJSON_Delete(root); - return false; - } - } - - if (restore_reticulum_groups) - { - const ::platform::ui::reticulum_groups::Status group_save = - ::platform::ui::reticulum_groups::flushPending(); - if (!group_save.saved) - { - std::printf("[SettingsBackup] Reticulum group restore failed: %s\n", - group_save.message); - cJSON_Delete(root); - return false; - } - } - - // The SD-backed owner has succeeded, so no NVS state has been changed on - // an SD write failure. NVS still does not provide a transaction spanning - // its independent AppConfig and settings_store owners. - restore_extra_settings(root); - cJSON_Delete(root); - edit.commit(app::AppConfigChangeSet::allPersisted()); - return true; -} - -bool remove() -{ - if (!sd_available()) - { - return false; - } - if (storage_exists(kBackupTempPath)) - { - storage_remove(kBackupTempPath); - } - return !storage_exists(kBackupPath) || storage_remove(kBackupPath); -} - -} // namespace platform::ui::settings_backup diff --git a/platform/esp/arduino_common/src/ui_common.cpp b/platform/esp/arduino_common/src/ui_common.cpp index 87648550..00a7ca44 100644 --- a/platform/esp/arduino_common/src/ui_common.cpp +++ b/platform/esp/arduino_common/src/ui_common.cpp @@ -66,6 +66,7 @@ int ui_get_timezone_offset_min() void ui_set_timezone_offset_min(int offset_min) { + ::platform::ui::settings_store::ScopedChangeBatch changes; s_tz_offset_min = offset_min; s_tz_profile_id = ::platform::ui::time::timezone_profile_id_for_fixed_offset(offset_min); s_tz_loaded = true; @@ -81,6 +82,7 @@ int ui_get_timezone_profile_id() void ui_set_timezone_profile_id(int profile_id) { + ::platform::ui::settings_store::ScopedChangeBatch changes; const auto* profile = ::platform::ui::time::timezone_profile_by_id(profile_id); if (!profile) { diff --git a/platform/esp/arduino_common/tests/test_settings_backup_coverage_contract.cpp b/platform/esp/arduino_common/tests/test_settings_backup_coverage_contract.cpp deleted file mode 100644 index b366af34..00000000 --- a/platform/esp/arduino_common/tests/test_settings_backup_coverage_contract.cpp +++ /dev/null @@ -1,62 +0,0 @@ -#include -#include -#include -#include - -namespace -{ - -std::string read_file(const char* root, const char* relative_path) -{ - std::string path(root); - path += "/"; - path += relative_path; - - std::ifstream file(path, std::ios::binary); - assert(file.is_open()); - return std::string((std::istreambuf_iterator(file)), - std::istreambuf_iterator()); -} - -} // namespace - -int main(int argc, char** argv) -{ - assert(argc == 2); - - const std::string source = read_file( - argv[1], - "platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp"); - - // The default backup is a bounded line-oriented TMS document. JSON only - // remains as an explicit compatibility restore input; it must not be the - // writer or normal restore path. - assert(source.find("/trailmate/settings-backup.tms") != std::string::npos); - assert(source.find("/trailmate/settings-backup.json") != std::string::npos); - assert(source.find("tms::writeDocument(") != std::string::npos); - assert(source.find("tms::DocumentKind::Backup") != std::string::npos); - assert(source.find("write_extra_records") != std::string::npos); - assert(source.find("checksum.crc32") != std::string::npos); - assert(source.find("read_tms_backup") != std::string::npos); - assert(source.find("restore_tms_backup") != std::string::npos); - assert(source.find("if (storage_exists(kBackupPath))") != std::string::npos); - assert(source.find("read_file_text(kLegacyBackupPath, text)") != std::string::npos); - - // The new ESP path must keep text/blob retrieval bounded and retain the - // existing present-marker semantics so a backup clears stale destination - // overrides rather than merely omitting them. - assert(source.find("get_string_into") != std::string::npos); - assert(source.find("s_extra_text_scratch") != std::string::npos); - assert(source.find("s_extra_blob_scratch") != std::string::npos); - assert(source.find("state->seen_present") != std::string::npos); - assert(source.find("settings_store::remove_keys") != std::string::npos); - - // Keep the historic JSON codec isolated from the new default writer. - const std::size_t backup_begin = source.find("bool backup()\n{"); - const std::size_t restore_begin = source.find("bool restore()\n{"); - assert(backup_begin != std::string::npos && restore_begin != std::string::npos); - const std::string backup_body = source.substr(backup_begin, restore_begin - backup_begin); - assert(backup_body.find("cJSON_") == std::string::npos); - assert(backup_body.find("std::string") == std::string::npos); - return 0; -} diff --git a/platform/esp/common/include/platform/esp/common/settings_store_impl.h b/platform/esp/common/include/platform/esp/common/settings_store_impl.h index ece78ed1..ca013e63 100644 --- a/platform/esp/common/include/platform/esp/common/settings_store_impl.h +++ b/platform/esp/common/include/platform/esp/common/settings_store_impl.h @@ -15,6 +15,24 @@ namespace platform::esp::common::settings_store_detail constexpr std::size_t kMaxNvsKeyLength = 15; +inline ::platform::ui::settings_store::ChangeObserver s_change_observer = nullptr; +inline void* s_change_observer_context = nullptr; +inline uint16_t s_change_batch_depth = 0U; +inline bool s_change_pending = false; + +inline void notify_change(const char* ns, const char* key) +{ + if (s_change_batch_depth != 0U) + { + s_change_pending = true; + return; + } + if (s_change_observer) + { + s_change_observer(s_change_observer_context, ns, key); + } +} + struct StorageKeyAlias { const char* key; @@ -143,6 +161,37 @@ inline bool erase_key_if_present(nvs_handle_t handle, const char* storage_key) namespace platform::ui::settings_store { +void set_change_observer(ChangeObserver observer, void* context) +{ + using namespace ::platform::esp::common::settings_store_detail; + s_change_observer = observer; + s_change_observer_context = context; +} + +void begin_change_batch() +{ + using namespace ::platform::esp::common::settings_store_detail; + if (s_change_batch_depth != UINT16_MAX) + { + ++s_change_batch_depth; + } +} + +void end_change_batch() +{ + using namespace ::platform::esp::common::settings_store_detail; + if (s_change_batch_depth == 0U) + { + return; + } + --s_change_batch_depth; + if (s_change_batch_depth == 0U && s_change_pending) + { + s_change_pending = false; + notify_change(nullptr, nullptr); + } +} + void put_int(const char* ns, const char* key, int value) { using namespace ::platform::esp::common::settings_store_detail; @@ -181,6 +230,10 @@ void put_int(const char* ns, const char* key, int value) safe_label(storage_key), value, bool_label(ok)); + if (ok) + { + notify_change(ns, key); + } } void put_bool(const char* ns, const char* key, bool value) @@ -221,6 +274,10 @@ void put_bool(const char* ns, const char* key, bool value) safe_label(storage_key), bool_label(value), bool_label(ok)); + if (ok) + { + notify_change(ns, key); + } } void put_uint(const char* ns, const char* key, uint32_t value) @@ -261,6 +318,10 @@ void put_uint(const char* ns, const char* key, uint32_t value) safe_label(storage_key), static_cast(value), bool_label(ok)); + if (ok) + { + notify_change(ns, key); + } } bool put_string(const char* ns, const char* key, const char* value) @@ -303,6 +364,10 @@ bool put_string(const char* ns, const char* key, const char* value) safe_label(key), safe_label(storage_key), bool_label(ok)); + if (ok) + { + notify_change(ns, key); + } return ok; } @@ -323,6 +388,10 @@ bool put_string(const char* ns, const char* key, const char* value) static_cast(std::strlen(value)), ::platform::ui::settings::diagnostic_value(key, value), bool_label(ok)); + if (ok) + { + notify_change(ns, key); + } return ok; } @@ -366,6 +435,10 @@ bool put_blob(const char* ns, const char* key, const void* data, std::size_t len safe_label(key), safe_label(storage_key), bool_label(ok)); + if (ok) + { + notify_change(ns, key); + } return ok; } @@ -385,6 +458,10 @@ bool put_blob(const char* ns, const char* key, const void* data, std::size_t len safe_label(storage_key), static_cast(len), bool_label(ok)); + if (ok) + { + notify_change(ns, key); + } return ok; } @@ -790,6 +867,10 @@ void remove_keys(const char* ns, const char* const* keys, std::size_t key_count) { logf("[CfgStore][REMOVE][ERR] ns=%s commit=false\n", safe_label(ns)); } + else + { + notify_change(ns, nullptr); + } return; } @@ -820,6 +901,10 @@ void clear_namespace(const char* ns) logf("[CfgStore][CLEAR] ns=%s ok=%s\n", safe_label(ns), bool_label(ok)); + if (ok) + { + notify_change(ns, nullptr); + } } } // namespace platform::ui::settings_store diff --git a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h index 7500fb95..28e6ec14 100644 --- a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h +++ b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h @@ -48,7 +48,7 @@ constexpr const char* kWifiEnabledKey = "wifi_enabled"; constexpr const char* kWifiSsidKey = "wifi_ssid"; constexpr const char* kWifiPasswordKey = "wifi_password"; constexpr const char* kWifiProfileCountKey = "wifi_prof_count"; -constexpr std::size_t kWifiProfileCapacity = 10; +constexpr std::size_t kWifiProfileCapacity = ::platform::ui::wifi::kMaxSavedProfileCount; constexpr uint16_t kWifiAutoScanMaxRecords = 16; constexpr uint32_t kBleRetryDelayMs = 180; constexpr int kWifiTxBufferTypeStatic = 0; @@ -1410,6 +1410,7 @@ bool load_config(Config& out) bool save_config(const Config& config) { + ::platform::ui::settings_store::ScopedChangeBatch changes; if (!s_runtime.profiles_cached) { Config ignored{}; @@ -1451,6 +1452,82 @@ bool find_saved_config(const char* ssid, Config& out) return false; } +bool visit_saved_profiles(bool* enabled, + std::size_t* count, + SavedProfileVisitor visitor, + void* context) +{ + if (!enabled || !count || !visitor) + { + return false; + } + if (!s_runtime.profiles_cached) + { + Config ignored{}; + (void)read_config_from_store(ignored); + } + *enabled = s_runtime.config_cached ? s_runtime.config.enabled : false; + *count = s_runtime.profile_count; + for (std::size_t index = 0U; index < s_runtime.profile_count; ++index) + { + if (!visitor(context, index, s_runtime.profiles[index])) + { + return false; + } + } + return true; +} + +bool replace_saved_profiles(bool enabled, const Config* profiles, std::size_t count) +{ + ::platform::ui::settings_store::ScopedChangeBatch changes; + if (count > kWifiProfileCapacity || (count != 0U && !profiles)) + { + return false; + } + for (std::size_t index = 0U; index < count; ++index) + { + if (!has_saved_credentials(profiles[index])) + { + return false; + } + for (std::size_t previous = 0U; previous < index; ++previous) + { + if (same_ssid(profiles[index], profiles[previous])) + { + return false; + } + } + } + + clear_profiles(); + for (std::size_t index = 0U; index < count; ++index) + { + s_runtime.profiles[index] = profiles[index]; + s_runtime.profiles[index].enabled = true; + } + s_runtime.profile_count = count; + s_runtime.next_profile_index = 0U; + s_runtime.profiles_cached = true; + + Config primary{}; + primary.enabled = enabled; + if (count > 0U) + { + primary = s_runtime.profiles[0]; + primary.enabled = enabled; + } + const bool profiles_ok = persist_profiles(); + const bool ssid_ok = + ::platform::ui::settings_store::put_string(kSettingsNs, kWifiSsidKey, primary.ssid); + const bool password_ok = + ::platform::ui::settings_store::put_string(kSettingsNs, kWifiPasswordKey, primary.password); + ::platform::ui::settings_store::put_bool(kSettingsNs, kWifiEnabledKey, enabled); + cache_config(primary); + refresh_runtime_status_message(); + return profiles_ok && ssid_ok && password_ok; +} + bool apply_enabled(bool enabled) { if (!is_supported()) diff --git a/platform/linux/common/src/platform/ui/settings_backup_runtime.cpp b/platform/linux/common/src/platform/ui/settings_backup_runtime.cpp deleted file mode 100644 index 972a0867..00000000 --- a/platform/linux/common/src/platform/ui/settings_backup_runtime.cpp +++ /dev/null @@ -1,36 +0,0 @@ -#include "platform/ui/settings_backup_runtime.h" - -#include - -namespace platform::ui::settings_backup -{ - -bool is_supported() -{ - return false; -} - -Status status() -{ - Status out{}; - out.supported = false; - std::snprintf(out.message, sizeof(out.message), "%s", "Settings backup unsupported on Linux"); - return out; -} - -bool backup() -{ - return false; -} - -bool restore() -{ - return false; -} - -bool remove() -{ - return false; -} - -} // namespace platform::ui::settings_backup diff --git a/platform/nrf52/arduino_common/src/platform_ui_settings_backup_runtime.cpp b/platform/nrf52/arduino_common/src/platform_ui_settings_backup_runtime.cpp deleted file mode 100644 index 105fbdb2..00000000 --- a/platform/nrf52/arduino_common/src/platform_ui_settings_backup_runtime.cpp +++ /dev/null @@ -1,36 +0,0 @@ -#include "platform/ui/settings_backup_runtime.h" - -#include - -namespace platform::ui::settings_backup -{ - -bool is_supported() -{ - return false; -} - -Status status() -{ - Status out{}; - out.supported = false; - std::snprintf(out.message, sizeof(out.message), "%s", "Settings backup unsupported"); - return out; -} - -bool backup() -{ - return false; -} - -bool restore() -{ - return false; -} - -bool remove() -{ - return false; -} - -} // namespace platform::ui::settings_backup diff --git a/tests/reticulum_conformance/check_scope_guard.py b/tests/reticulum_conformance/check_scope_guard.py index b6942032..933e0de1 100644 --- a/tests/reticulum_conformance/check_scope_guard.py +++ b/tests/reticulum_conformance/check_scope_guard.py @@ -212,15 +212,10 @@ PRODUCT_RETICULUM_CONFIG_FILES = [ Path("modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp"), Path("platform/esp/arduino_common/include/app/app_context.h"), Path("platform/esp/arduino_common/src/app_context.cpp"), - Path("platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp"), Path("platform/esp/arduino_common/src/rnode_kiss/rnode_kiss_service.cpp"), Path("platform/linux/common/src/app/linux_app_services.cpp"), ] -ESP_SETTINGS_BACKUP_RUNTIME = Path( - "platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp" -) - RETICULUM_ADAPTER_HEADER = Path( "platform/esp/arduino_common/include/platform/esp/arduino_common/" "chat/infra/reticulum/reticulum_adapter.h" @@ -372,19 +367,6 @@ def check_product_reticulum_config_accessor_boundary() -> list[str]: f"instead of direct rnode_config storage access: {relative.as_posix()}" ) - backup = ROOT / ESP_SETTINGS_BACKUP_RUNTIME - if backup.is_file(): - text = backup.read_text(encoding="utf-8") - if 'add_mesh_config(object, "rnode"' in text: - errors.append( - "Settings backup must not write product Reticulum config under " - 'the legacy "rnode" field' - ) - if 'add_mesh_config(object, "reticulum"' not in text: - errors.append( - 'Settings backup must write product Reticulum config under "reticulum"' - ) - return errors