mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-08-27 21:19:46 +00:00
feat(config): complete SD working configuration
This commit is contained in:
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Back up, restore or reset device settings</title></head><body><main data-praxis-anchor="use-case:backup-restore-settings" data-praxis-kind="design_use_case_diagram" data-praxis-status="confirmed" data-praxis-source-md="docs/design/use-case-diagrams/backup-restore-settings.md"><h1>Back up, restore or reset device settings</h1><p>Backup uses temporary files and atomic replacement; restore first verify; four types of reset have different damage scopes.</p><ul><li><a href="backup-restore-settings/activity.html">Activity</a></li><li><a href="backup-restore-settings/sequences/sequence-backup-restore-settings.html">Sequence</a></li></ul></main></body></html>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Activity: Settings Protection</title></head><body><main data-praxis-kind="design_activity_diagram" data-praxis-status="confirmed"><h1>Backup, Restore and Reset</h1><pre class="mermaid">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"]</pre></main></body></html>
|
||||
@@ -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.
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Sequence:Settings Backup</title></head><body><main data-praxis-kind="design_sequence_diagram" data-praxis-status="confirmed"><h1>Settings Backup Store</h1><pre class="mermaid">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</pre></main></body></html>
|
||||
+24
-25
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<uint8_t>&)` 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<uint8_t>`, 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<uint8_t>` 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ExtensionState*>(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<ExtensionState*>(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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+18
@@ -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
|
||||
|
||||
+26
@@ -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
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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 <Arduino.h>
|
||||
#include <Preferences.h>
|
||||
|
||||
#include <cstdio>
|
||||
@@ -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<int32_t>(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<unsigned long>(meta.revision),
|
||||
static_cast<unsigned long>(meta.last_sd_crc),
|
||||
static_cast<unsigned>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#if defined(HAS_PSRAM) && HAS_PSRAM
|
||||
#include <esp_attr.h>
|
||||
#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<const uint8_t*>(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<uint8_t*>(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<uint8_t*>(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<uint8_t>(::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<unsigned>(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<unsigned>(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<int>(s_state.screen_brightness));
|
||||
::platform::ui::settings_store::put_int(kSettingsNs,
|
||||
"speaker_volume",
|
||||
static_cast<int>(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<int>(s_state.timezone_profile));
|
||||
::platform::ui::settings_store::put_int(kSettingsNs,
|
||||
"timezone_offset",
|
||||
static_cast<int>(s_state.timezone_offset_minutes));
|
||||
::platform::ui::settings_store::put_int(kSettingsNs,
|
||||
"chat_message_alerts",
|
||||
static_cast<int>(s_state.message_alerts));
|
||||
::platform::ui::settings_store::put_int(kSettingsNs,
|
||||
"chat_contact_alerts",
|
||||
static_cast<int>(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<int>(::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<uint8_t>(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<unsigned>(index));
|
||||
if (!writer.text(key, s_state.wifi_profiles[index].ssid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::snprintf(key, sizeof(key), "wifi.profile.%u.password", static_cast<unsigned>(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
|
||||
@@ -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 <cstdint>
|
||||
#include <cstdio>
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
{
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
#include <cassert>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
|
||||
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<char>(file)),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
} // 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;
|
||||
}
|
||||
@@ -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<unsigned long>(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<unsigned long>(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<unsigned long>(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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
#include "platform/ui/settings_backup_runtime.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
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
|
||||
@@ -1,36 +0,0 @@
|
||||
#include "platform/ui/settings_backup_runtime.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user