diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c2989c7..c476914 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1114,6 +1114,17 @@ Codes `0x80`–`0x90` (`PUSH_CODE_*` in `app/CompanionMesh.h`). Most used: | `/lfs/repeater/regions2` | Region map | Header + 164B × N entries | | `storage_partition` (NVS, 0xD0000 nRF52) | BLE bonds + Zephyr settings | NVS settings backend (≥1.16.2; old `/lfs/settings` file detected by self-heal) | +> **Roles are not interchangeable.** Each role formats the whole volume on its first boot if the +> volume holds no data for that role: the companion checks `/lfs/new_prefs` +> (`ZephyrDataStore::hasPrefs()`), the repeater/room-server/observer check `/lfs/repeater/prefs` +> and `/lfs/repeater/_main.id` (`RepeaterDataStore::hasRoleData()`). So flashing a repeater over +> a companion — or the reverse — erases the previous role's identity, prefs and contacts, plus +> `storage_partition` and QSPI. Export your identity before switching roles. The roles' files +> never overlap physically (one LittleFS volume, one allocator); the reason for the wipe is that +> they share 128 KB and the other role's data crowds out writes. Repeater, room server and +> observer share `/lfs/repeater/` and the same prefs layout, so switching among *those three* +> preserves the identity. + ### Preferences Binary Layouts Two distinct field-by-field serializations (NOT raw struct dumps), both Arduino-compatible diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index 233720e..8f6362a 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -127,7 +127,17 @@ Regions control which flood packets the repeater forwards. The region tree is hi | `log stop` | Disable packet logging | | `log erase` | Erase the log file | | `log` | *(USB only)* Dump the full log file to USB serial | -| `erase` | *(USB only)* Format the entire filesystem | +| `erase` | *(USB only)* Factory reset: erase the entire LittleFS volume, the BLE-bond NVS, and external QSPI flash, then reboot | + +> **`erase` is a true factory reset.** It flattens `lfs_partition` (identity, prefs, ACL, +> region map, logs), `storage_partition` (BLE bonds) and `qspi_storage_partition` where +> present — not just the files under `/lfs/repeater/`. The node comes back with a new +> identity and default prefs. Erasing the volume rather than unlinking files is what makes +> it able to recover a volume another firmware has written into: on nRF52840 the Adafruit +> core's filesystem (used by Arduino MeshCore and Meshtastic) sits at 0xED000, inside our +> `lfs_partition`, and its format scribbles the top 7 blocks of our volume. Switching +> between Arduino-core firmware and ZephCore on nRF52840 needs an erase in **both** +> directions — `tools/formatter` or a full chip erase. --- diff --git a/releasenotes/RELEASE_NOTES_1.17.4-zephcore.md b/releasenotes/RELEASE_NOTES_1.17.4-zephcore.md new file mode 100644 index 0000000..1b21f91 --- /dev/null +++ b/releasenotes/RELEASE_NOTES_1.17.4-zephcore.md @@ -0,0 +1,107 @@ +# ZephCore 1.17.4-zephcore + +Storage housekeeping. The repeater's `erase` command never actually erased anything, a node flashed +from another firmware could start out with somebody else's leftovers underneath it, and switching a +node between companion and repeater firmware quietly let the two share the same 128 KB. All three are +fixed, and the last one is now deliberate and loud rather than quiet. + +> [!IMPORTANT] +> **Read the role-switching section before you flash a different role onto an existing node.** A +> companion that gets repeater firmware — or the reverse — now erases itself on first boot. That is +> intentional, but it is new. Export your identity first if you want to keep it. + +> [!NOTE] +> A normal upgrade is unaffected. Repeater to repeater, or companion to companion, keeps your +> identity, settings, contacts and phone pairing exactly as before. + +--- + +## `erase` now erases + +On a repeater or room server, `erase` promised to format the entire filesystem. It did not. It deleted +the handful of files it had put in its own folder and cleared the phone pairings, and left everything +else exactly where it was. + +Most of the time nobody noticed, because on a healthy node those files *are* everything that matters. +It mattered when the node was not healthy — which is precisely when somebody reaches for `erase`. If +anything had written into the storage area from outside, deleting our own files could not undo it, and +the command reported success while the problem stayed. + +`erase` now wipes the storage area itself, the phone-pairing store, and external flash where a board has +it, then reboots. The node comes back with a new identity and default settings, exactly like a node out +of the box. + +> [!IMPORTANT] +> **This is a genuine factory reset now, and it takes the identity with it.** Anyone who had your node +> in their contacts will need to add it again, and an admin password, ACL and region map all go too. +> That was always what the command claimed to do; it is now what it does. + +The companion's `erase` already worked this way. Repeater, room server and observer share one +implementation with it now, so there is one behaviour to remember instead of two. + +--- + +## Switching a node between roles now wipes it + +Companion firmware and repeater firmware kept their files in separate folders, and until now each left +the other's alone. Flashing back and forth preserved both sets. + +That sounds generous and was not. The two roles share a single 128 KB storage area. A companion that has +collected a few hundred contacts and a full advert cache leaves noticeably less room for a repeater's +region map and access list, and a write that no longer fits simply fails. The node is not corrupted — +the two roles' files never sit on top of each other — it just runs out of space for reasons its owner +cannot see, because half of what is stored belongs to firmware that is not running. + +They are also two quite different kinds of node, and treating one machine as quietly holding both was +never worth the space it cost. + +From 1.17.4, each role checks on first boot whether the storage belongs to it, and formats everything if +not. So a repeater flashed onto a former companion starts empty, and a companion flashed onto a former +repeater starts empty. + +> [!IMPORTANT] +> **Export your identity before switching roles.** The node's identity, settings, contacts, channels, +> access list, region map and phone pairings all go. There is no undo and no warning prompt — the first +> boot on the new firmware has already done it by the time you see anything. + +> [!NOTE] +> **Repeater, room server and observer still share.** Those three keep their files in the same place and +> use the same settings layout, so moving between them keeps the node's identity and configuration. It +> is the companion that is now separate. + +--- + +## A node coming from other firmware starts clean + +Flashing ZephCore onto hardware that was running something else is a normal thing to do, and it used to +leave more behind than anyone expected. + +Nothing about installing firmware erases storage. Dragging a UF2 file writes the program and nothing +else, and each firmware only ever clears the piece of flash it believes is its own. On the nRF52840 +boards, Arduino MeshCore's storage sits inside the same region ZephCore uses, so the two overlap — and +whichever one boots first tidies up its own corner and leaves the rest of the other's files sitting +there. The result on one Seeed Solar Node was a repeater that came up looking perfectly healthy and +silently refused to forward anything, because a single setting deep inside its configuration had been +overwritten by bytes that belonged to a different firmware. + +Each role now checks on first boot whether the storage is its own and, if not, clears the whole lot — +the storage area, the phone-pairing store, and external flash — before writing anything. A node arriving +from another firmware, or from a factory-fresh chip, starts from a known state instead of an inherited +one. + +> [!IMPORTANT] +> **Moving between Arduino MeshCore and ZephCore still needs an erase in both directions.** ZephCore now +> cleans up on the way in, but it cannot clean up on the way out — going back to Arduino MeshCore leaves +> ZephCore's files inside the area Arduino will use. Run the formatter UF2, or a full chip erase, when +> you switch either way. This is not new advice; it is now written down. + +--- + +## Also in this release + +Nothing here changes how a node behaves. + +- **A wasted erase on the companion.** Running `erase` from the companion's USB console formatted the + storage, then formatted it a second time on the reboot that followed, because the marker saying "this + node has been set up" went out with everything else. The pairing-based factory reset never had this + problem. Both paths behave the same way now. diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 09db560..3385463 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -629,6 +629,10 @@ target_sources(app PRIVATE helpers/buzzer_gate.c # QSPI bring-up probe. Self-stubs unless CONFIG_ZEPHCORE_QSPI_RDID_PROBE. helpers/qspi_probe.c + # Factory format of every storage region. Compiled for all roles: the + # companion builds ZephyrDataStore, the other three build + # RepeaterDataStore, and both call into this. + adapters/datastore/ZephyrFsFormat.c ) # Boot-time hardware-RTC auto-discovery (compact raw-I2C). Always compiled so diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index bbe05e1..50b8164 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -7,6 +7,7 @@ */ #include "ZephyrDataStore.h" +#include "ZephyrFsFormat.h" #include // ADV_TYPE_NONE (transient/anon contacts) #include #include @@ -371,84 +372,14 @@ void ZephyrDataStore::checkAdvBlobFile() bool ZephyrDataStore::formatFileSystem() { - LOG_INF("formatFileSystem: starting..."); + /* The erase/remount itself lives in ZephyrFsFormat.c so the repeater, + * room server and observer — which build RepeaterDataStore and never + * compile this file — get the identical implementation. */ + bool ext_mounted = false; + bool mounted = zephcore_fs_format_all(&ext_mounted); - /* Properly unmount from Zephyr's VFS before erasing flash. - * The old unmount() only cleared flags — Zephyr still held /lfs mounted, - * so flash_area_flatten silently destroyed the on-flash superblock while - * LittleFS considered itself active. Every subsequent file op then hit - * the erased blocks and logged "Corrupted dir pair at {0x0, 0x1}". - * FS_FSTAB_DECLARE_ENTRY exposes the non-static mount struct generated - * from the DTS fstab; fs_mount() on a blank partition auto-formats - * (littlefs_fs.c: lfs_mount fail → lfs_format → lfs_mount). */ - FS_FSTAB_DECLARE_ENTRY(DT_NODELABEL(lfs)); - fs_unmount(&FS_FSTAB_ENTRY(DT_NODELABEL(lfs))); - lfs_mounted = false; - -#if DT_NODE_EXISTS(DT_NODELABEL(qspi_lfs)) - FS_FSTAB_DECLARE_ENTRY(DT_NODELABEL(qspi_lfs)); - fs_unmount(&FS_FSTAB_ENTRY(DT_NODELABEL(qspi_lfs))); -#endif - ext_lfs_mounted = false; - - const struct flash_area *fap; - int rc; - -#if FIXED_PARTITION_EXISTS(lfs_partition) - rc = flash_area_open(PARTITION_ID(lfs_partition), &fap); - if (rc == 0) { - LOG_INF("Formatting LFS partition (%u bytes)", (unsigned)fap->fa_size); - flash_area_flatten(fap, 0, fap->fa_size); - flash_area_close(fap); - } -#endif - -#if FIXED_PARTITION_EXISTS(storage_partition) - rc = flash_area_open(PARTITION_ID(storage_partition), &fap); - if (rc == 0) { - LOG_INF("Formatting NVS storage (%u bytes)", (unsigned)fap->fa_size); - flash_area_flatten(fap, 0, fap->fa_size); - flash_area_close(fap); - } -#endif - -#if FIXED_PARTITION_EXISTS(qspi_storage_partition) - /* QSPI if present (any platform) */ - rc = flash_area_open(PARTITION_ID(qspi_storage_partition), &fap); - if (rc == 0) { - LOG_INF("Formatting QSPI (%u bytes, may take a while)", (unsigned)fap->fa_size); - flash_area_flatten(fap, 0, fap->fa_size); - flash_area_close(fap); - } -#endif - - /* Remount: littlefs_mount() auto-formats on blank flash, then mounts. */ - rc = fs_mount(&FS_FSTAB_ENTRY(DT_NODELABEL(lfs))); - bool mounted = (rc == 0); - if (mounted) { - lfs_mounted = true; - } - -#if DT_NODE_EXISTS(DT_NODELABEL(qspi_lfs)) - /* Remount external QSPI too. We unmounted it above and flattened its - * partition, so it must be re-mounted here — otherwise a runtime format - * (factory reset, or the first-boot "no prefs" auto-format) leaves /ext - * unmounted for the rest of the session. begin() then reads - * ext_lfs_mounted=false and the store falls back to internal /lfs, so - * contacts/channels save to /lfs and get needlessly migrated back to /ext - * on the next boot ("Migrating contacts to external storage" churn). */ - { - FS_FSTAB_DECLARE_ENTRY(DT_NODELABEL(qspi_lfs)); - int ext_rc = fs_mount(&FS_FSTAB_ENTRY(DT_NODELABEL(qspi_lfs))); - if (is_mounted(extMountPoint())) { - ext_lfs_mounted = true; - LOG_INF("formatFileSystem: /ext remounted (rc=%d)", ext_rc); - } else { - ext_lfs_mounted = false; - LOG_ERR("formatFileSystem: /ext remount failed (rc=%d)", ext_rc); - } - } -#endif + lfs_mounted = mounted; + ext_lfs_mounted = ext_mounted; LOG_INF("formatFileSystem: mount() returned %d", mounted ? 1 : 0); return mounted; diff --git a/zephcore/adapters/datastore/ZephyrFsFormat.c b/zephcore/adapters/datastore/ZephyrFsFormat.c new file mode 100644 index 0000000..67996f9 --- /dev/null +++ b/zephcore/adapters/datastore/ZephyrFsFormat.c @@ -0,0 +1,117 @@ +/* + * SPDX-License-Identifier: MIT + * ZephyrFsFormat - shared factory-format of every ZephCore storage region. + */ + +#include "ZephyrFsFormat.h" + +#include +#include +#include +#include + +LOG_MODULE_REGISTER(zephcore_fs_format, CONFIG_ZEPHCORE_DATASTORE_LOG_LEVEL); + +#define LFS_MNT_POINT "/lfs" +#define EXT_MNT_POINT "/ext" + +/* fs_mount() can return 0 having mounted nothing useful, and a remount that + * silently failed would leave the caller reporting a healthy store over an + * unmounted volume. Ask the VFS instead of trusting the return code. */ +static bool is_mounted(const char *mount_point) +{ + struct fs_statvfs stat; + + return fs_statvfs(mount_point, &stat) == 0; +} + +static void flatten(uint8_t id, const char *tag) +{ + const struct flash_area *fap; + int rc = flash_area_open(id, &fap); + + if (rc != 0) { + LOG_WRN("format: flash_area_open(%s) failed: %d", tag, rc); + return; + } + LOG_INF("format: erasing %s (%u bytes)", tag, (unsigned)fap->fa_size); + rc = flash_area_flatten(fap, 0, fap->fa_size); + if (rc != 0) { + LOG_ERR("format: flatten(%s) failed: %d", tag, rc); + } + flash_area_close(fap); +} + +bool zephcore_fs_format_all(bool *out_ext_mounted) +{ + LOG_INF("zephcore_fs_format_all: starting..."); + + if (out_ext_mounted) { + *out_ext_mounted = false; + } + + /* Properly unmount from Zephyr's VFS before erasing flash. Clearing a + * local "mounted" flag is not enough — Zephyr would still hold /lfs + * mounted, so flash_area_flatten destroys the on-flash superblock while + * LittleFS considers itself active. Every subsequent file op then hits + * the erased blocks and logs "Corrupted dir pair at {0x0, 0x1}". + * FS_FSTAB_DECLARE_ENTRY exposes the non-static mount struct generated + * from the DTS fstab; fs_mount() on a blank partition auto-formats + * (littlefs_fs.c: lfs_mount fail -> lfs_format -> lfs_mount). */ + FS_FSTAB_DECLARE_ENTRY(DT_NODELABEL(lfs)); + fs_unmount(&FS_FSTAB_ENTRY(DT_NODELABEL(lfs))); + +#if DT_NODE_EXISTS(DT_NODELABEL(qspi_lfs)) + FS_FSTAB_DECLARE_ENTRY(DT_NODELABEL(qspi_lfs)); + fs_unmount(&FS_FSTAB_ENTRY(DT_NODELABEL(qspi_lfs))); +#endif + +#if FIXED_PARTITION_EXISTS(lfs_partition) + flatten(PARTITION_ID(lfs_partition), "lfs_partition"); +#endif + +#if FIXED_PARTITION_EXISTS(storage_partition) + /* BLE bonds (NVS). A factory reset should clear them too; the caller + * reboots so NVS and the BT stack re-init clean. */ + flatten(PARTITION_ID(storage_partition), "storage_partition"); +#endif + +#if FIXED_PARTITION_EXISTS(qspi_storage_partition) + flatten(PARTITION_ID(qspi_storage_partition), "qspi_storage_partition"); +#endif + + /* Remount: littlefs_mount() auto-formats blank flash, then mounts. */ + int rc = fs_mount(&FS_FSTAB_ENTRY(DT_NODELABEL(lfs))); + bool mounted = is_mounted(LFS_MNT_POINT); + + if (mounted) { + LOG_INF("format: %s remounted (rc=%d)", LFS_MNT_POINT, rc); + } else { + LOG_ERR("format: %s remount FAILED (rc=%d)", LFS_MNT_POINT, rc); + } + +#if DT_NODE_EXISTS(DT_NODELABEL(qspi_lfs)) + /* Remount external QSPI too. We unmounted it above and flattened its + * partition, so it must be re-mounted here — otherwise a runtime format + * (factory reset, or the first-boot "no prefs" auto-format) leaves /ext + * unmounted for the rest of the session. begin() then reads + * ext_lfs_mounted=false and the store falls back to internal /lfs, so + * contacts/channels save to /lfs and get needlessly migrated back to + * /ext on the next boot ("Migrating contacts to external storage"). */ + { + int ext_rc = fs_mount(&FS_FSTAB_ENTRY(DT_NODELABEL(qspi_lfs))); + bool ext_mounted = is_mounted(EXT_MNT_POINT); + + if (ext_mounted) { + LOG_INF("format: %s remounted (rc=%d)", EXT_MNT_POINT, ext_rc); + } else { + LOG_ERR("format: %s remount failed (rc=%d)", EXT_MNT_POINT, ext_rc); + } + if (out_ext_mounted) { + *out_ext_mounted = ext_mounted; + } + } +#endif + + return mounted; +} diff --git a/zephcore/adapters/datastore/ZephyrFsFormat.h b/zephcore/adapters/datastore/ZephyrFsFormat.h new file mode 100644 index 0000000..c003970 --- /dev/null +++ b/zephcore/adapters/datastore/ZephyrFsFormat.h @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: MIT + * ZephyrFsFormat - shared factory-format of every ZephCore storage region. + * + * Lives outside ZephyrDataStore because the repeater, room server and observer + * build RepeaterDataStore instead (see CMakeLists role blocks) and must not + * each grow their own half-implementation of this. + */ + +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Erase every ZephCore storage region and remount the filesystems. + * + * Unmounts /lfs (and /ext when the board has QSPI), flattens lfs_partition, + * storage_partition (BLE bonds NVS) and qspi_storage_partition where each + * exists, then remounts. A blank LittleFS partition is auto-formatted by + * fs_mount(), so the volume comes back empty rather than corrupt. + * + * This is the only path that erases the LittleFS *volume*. Deleting files + * cannot recover a volume another firmware has written into — on nRF52840 the + * Adafruit core's InternalFileSystem lives at 0xED000, inside our 0xD4000 + * lfs_partition, and its format() scribbles our top 7 blocks. + * + * @param out_ext_mounted optional; receives whether /ext came back mounted. + * Set to false on boards with no QSPI. + * @return true if /lfs is mounted afterwards. + */ +bool zephcore_fs_format_all(bool *out_ext_mounted); + +#ifdef __cplusplus +} +#endif diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 569e159..15c8d87 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -4,8 +4,9 @@ */ #include "RepeaterDataStore.h" +#include "../adapters/datastore/ZephyrFsFormat.h" #include -#include +#include #include #include #include @@ -37,6 +38,37 @@ bool RepeaterDataStore::begin() { const char* RepeaterDataStore::getBasePath() const { return BASE_PATH; } +static bool fileExists(const char* path) { + struct fs_dirent entry; + return fs_stat(path, &entry) == 0; +} + +bool RepeaterDataStore::hasRoleData() const { + char path[64]; + + /* Only THIS role's files count. A companion volume does not: the roles + * are deliberately not interchangeable, and a companion's contacts and + * blob cache would eat into the same 128 KB the repeater needs, so a + * repeater booting onto a companion volume formats it. The reverse + * already happens — ZephyrDataStore::hasPrefs() tests /lfs/new_prefs, + * which a repeater volume never has. + * + * Repeater, room server and observer DO share this store and base path; + * they use the same prefs layout, so switching among them keeps the + * node's identity, which is what an operator wants. + * + * Self-limiting: loadPrefs() persists defaults on boot 1 and main_*.cpp + * saves a generated identity on the same boot, so after one successful + * boot at least one of these exists and the check never fires again. */ + static const char* const ours[] = { "prefs", "_main.id" }; + for (size_t i = 0; i < ARRAY_SIZE(ours); i++) { + snprintf(path, sizeof(path), "%s/%s", BASE_PATH, ours[i]); + if (fileExists(path)) return true; + } + + return false; +} + const char* RepeaterDataStore::getAclPath() const { static char buf[48]; snprintf(buf, sizeof(buf), "%s/acl", BASE_PATH); @@ -412,37 +444,28 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { } bool RepeaterDataStore::formatFileSystem() { - LOG_WRN("Factory reset: erasing repeater data at %s", BASE_PATH); + LOG_WRN("Factory reset: erasing all storage"); - struct fs_dir_t dir; - fs_dir_t_init(&dir); - - int ret = fs_opendir(&dir, BASE_PATH); - if (ret < 0) { - LOG_WRN("No repeater directory to erase"); - return true; + /* Erase the LittleFS *volume*, not just our files. The old loop walked + * /lfs/repeater/ with fs_unlink, which left the volume itself untouched: + * it could not recover a volume another firmware had written into (on + * nRF52840 the Adafruit core's filesystem overlaps the top of ours), and + * it left /lfs/settings, stale companion files and all of /ext behind. + * Shared with the companion so all four roles erase the same regions. */ + bool mounted = zephcore_fs_format_all(nullptr); + if (!mounted) { + LOG_ERR("Factory reset: /lfs did not remount"); + return false; } - struct fs_dirent entry; - char path[280]; - - while (fs_readdir(&dir, &entry) == 0 && entry.name[0] != '\0') { - snprintf(path, sizeof(path), "%s/%s", BASE_PATH, entry.name); - LOG_INF("Deleting %s", path); - fs_unlink(path); + /* The format took /lfs/repeater with it. Re-create it now rather than + * relying on the reboot: the CLI defers the reset so the reply can be + * transmitted, and anything that saves in that window needs the dir. */ + _initialized = false; + if (!begin()) { + LOG_ERR("Factory reset: could not re-create %s", BASE_PATH); + return false; } - fs_closedir(&dir); - -#if FIXED_PARTITION_EXISTS(storage_partition) - /* Erase the NVS bonds partition too — a factory reset should clear BLE - * bonds, not just repeater files. Caller reboots so NVS re-inits clean. */ - const struct flash_area *fap; - if (flash_area_open(PARTITION_ID(storage_partition), &fap) == 0) { - LOG_INF("Formatting NVS storage (%u bytes)", (unsigned)fap->fa_size); - flash_area_flatten(fap, 0, fap->fa_size); - flash_area_close(fap); - } -#endif LOG_INF("Repeater data erased"); return true; diff --git a/zephcore/app/RepeaterDataStore.h b/zephcore/app/RepeaterDataStore.h index 26a020f..cf897de 100644 --- a/zephcore/app/RepeaterDataStore.h +++ b/zephcore/app/RepeaterDataStore.h @@ -2,8 +2,15 @@ * SPDX-License-Identifier: MIT * RepeaterDataStore - Filesystem storage for repeater * - * Uses /lfs/repeater/ prefix to keep data separate from companion. - * This allows flashing back and forth between roles without corruption. + * Uses /lfs/repeater/ prefix to keep data separate from companion, so the + * two prefs layouts (301 B here, 163 B there, different field order) can + * never be read through each other. + * + * The roles are NOT interchangeable: booting a repeater onto a companion + * volume formats it, and vice versa. They are different kinds of node, and + * they share one 128 KB LittleFS volume - a companion's contacts and blob + * cache would crowd out repeater writes (-ENOSPC), not corrupt them. Save + * your identity before switching roles. */ #pragma once @@ -39,6 +46,13 @@ public: /* Factory reset - erase all repeater data */ bool formatFileSystem(); + /* True if this LittleFS volume already holds THIS role's data. False + * means the volume belongs to something else - a fresh chip, a companion, + * or a node that was running Arduino MeshCore, whose nRF52 filesystems + * overlap our lfs_partition (devdocs/HANDOVER_lfs_arduino_overlap.md). + * Callers format on false; see the note on BASE_PATH below. */ + bool hasRoleData() const; + /* Get base path for repeater storage */ const char* getBasePath() const; diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index 0c9b115..2bc2ae6 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -32,6 +32,7 @@ LOG_MODULE_REGISTER(zephcore_observer_main, CONFIG_ZEPHCORE_MAIN_LOG_LEVEL); #include +#include "../adapters/datastore/ZephyrFsFormat.h" #include #include #include @@ -306,6 +307,24 @@ int main(void) } #endif + /* First boot on a volume that is not this role's - a fresh chip, a + * companion, or a node that was running Arduino MeshCore, whose nRF52 + * filesystems overlap our lfs_partition + * (devdocs/HANDOVER_lfs_arduino_overlap.md). Erase everything so we + * start from a known state: Zephyr's automount only + * auto-formats the LittleFS volume when it fails to mount, and never + * touches storage_partition (BLE bonds NVS) or QSPI. + * + * Self-limiting, so it needs no "done" marker: the identity is generated + * and saved a few lines below, and loadPrefs() persists defaults on the + * same boot, so the next boot sees this role's data and skips this. */ + if (!data_store.hasRoleData()) { + LOG_WRN("Volume holds no data for this role - formatting before first boot"); + if (!zephcore_fs_format_all(nullptr)) { + LOG_ERR("First-boot format failed - /lfs is not mounted"); + } + } + /* Initialize LittleFS data store */ if (!data_store.begin()) { LOG_ERR("RepeaterDataStore init failed"); diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 897433d..4964b34 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -708,7 +708,17 @@ public: const char* getFirmwareVer() override { return FIRMWARE_VERSION; } const char* getBuildDate() override { return FIRMWARE_BUILD_DATE; } const char* getRole() override { return "companion"; } - bool formatFileSystem() override { return data_store.formatFileSystem(); } + /* CLI `erase`. Re-stamp the init marker on success, exactly as + * factoryReset() does for the BLE opcode path: the format takes + * /lfs/_zc_init with it, so without this the post-reboot first-boot check + * sees "no marker, no prefs" and runs a second, pointless full format. */ + bool formatFileSystem() override { + bool ok = data_store.formatFileSystem(); + if (ok) { + data_store.writeInitMarker(); + } + return ok; + } /* Advert — the companion can originate its own self-advert. delay_millis is * unused (companion sends flood at 0 ms / zero-hop immediately, matching the diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index 383245d..3a8e6ba 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -50,6 +50,7 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #endif #include +#include "../adapters/datastore/ZephyrFsFormat.h" #include #include #include @@ -643,6 +644,24 @@ int main(void) } #endif + /* First boot on a volume that is not this role's - a fresh chip, a + * companion, or a node that was running Arduino MeshCore, whose nRF52 + * filesystems overlap our lfs_partition + * (devdocs/HANDOVER_lfs_arduino_overlap.md). Erase everything so we + * start from a known state: Zephyr's automount only + * auto-formats the LittleFS volume when it fails to mount, and never + * touches storage_partition (BLE bonds NVS) or QSPI. + * + * Self-limiting, so it needs no "done" marker: the identity is generated + * and saved a few lines below, and loadPrefs() persists defaults on the + * same boot, so the next boot sees this role's data and skips this. */ + if (!data_store.hasRoleData()) { + LOG_WRN("Volume holds no data for this role - formatting before first boot"); + if (!zephcore_fs_format_all(nullptr)) { + LOG_ERR("First-boot format failed - /lfs is not mounted"); + } + } + /* Initialize repeater data store */ if (!data_store.begin()) { LOG_ERR("RepeaterDataStore init failed"); diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index 3471be2..0bb1882 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -50,6 +50,7 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line) #endif #include +#include "../adapters/datastore/ZephyrFsFormat.h" #include #include #include @@ -529,6 +530,24 @@ int main(void) } #endif + /* First boot on a volume that is not this role's - a fresh chip, a + * companion, or a node that was running Arduino MeshCore, whose nRF52 + * filesystems overlap our lfs_partition + * (devdocs/HANDOVER_lfs_arduino_overlap.md). Erase everything so we + * start from a known state: Zephyr's automount only + * auto-formats the LittleFS volume when it fails to mount, and never + * touches storage_partition (BLE bonds NVS) or QSPI. + * + * Self-limiting, so it needs no "done" marker: the identity is generated + * and saved a few lines below, and loadPrefs() persists defaults on the + * same boot, so the next boot sees this role's data and skips this. */ + if (!data_store.hasRoleData()) { + LOG_WRN("Volume holds no data for this role - formatting before first boot"); + if (!zephcore_fs_format_all(nullptr)) { + LOG_ERR("First-boot format failed - /lfs is not mounted"); + } + } + /* Initialize repeater data store */ if (!data_store.begin()) { LOG_ERR("RepeaterDataStore init failed");