From 6b04772182450c88e8e07f9232d82e18deba98b2 Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:57:07 +0200 Subject: [PATCH] fix import regression caused by vcontact --- PROVIDER_CATALOG.md | 14 ++++++ zephcore/Kconfig | 5 +- .../adapters/datastore/ZephyrDataStore.cpp | 48 +++++++++++++++++++ zephcore/app/CompanionMesh.cpp | 48 +++++++++++++++++-- zephcore/app/CompanionMesh.h | 3 ++ 5 files changed, 113 insertions(+), 5 deletions(-) diff --git a/PROVIDER_CATALOG.md b/PROVIDER_CATALOG.md index f9839bf..56a935c 100644 --- a/PROVIDER_CATALOG.md +++ b/PROVIDER_CATALOG.md @@ -65,6 +65,20 @@ Notes on the mapping: boards without dedicated art fall back to a neutral LoRa icon. Swap in ZephCore-branded art by pointing `img` at your own absolute HTTPS URLs. +> [!WARNING] +> **The configurator's "Erase Flash" is not ZephCore-aware — do not use it on a ZephCore node.** +> `erase` is a MeshCore device-level field, not a provider field, so a folded nRF52 device +> inherits the *base* device's formatter (e.g. Wio Tracker L1 → `WioTrackerL1_QSPIFlash_Format`). +> That formatter targets MeshCore/Ripple's flash layout, which does not match ZephCore's +> LittleFS — so it performs a partial, inconsistent wipe (observed: nukes `channels`, leaves +> identity/prefs/contacts) and can leave the filesystem half-corrupted. New tiles inherit no +> `erase` at all, so Erase Flash is a no-op there. +> +> To factory-reset a ZephCore node, use ZephCore's own mechanism, which formats **all** of +> `/lfs` + `/ext` (and the NVS bond partition) and reboots: the `erase` command over the USB +> serial CLI, the companion app's factory reset, or simply reflash (ZephCore auto-formats on +> first boot when it detects an incompatible/blank FS). + ## Local test ``` diff --git a/zephcore/Kconfig b/zephcore/Kconfig index d5d774b..a1e3ad2 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -278,11 +278,14 @@ config ZEPHCORE_BLE_PASSKEY config ZEPHCORE_BLE_QUEUE_SIZE int "BLE TX/RX message queue depth" - default 12 + default 24 range 4 64 help Number of frames that can be queued for BLE TX/RX. Larger = better buffering for bursty traffic, more RAM. + 24 absorbs the app's bursty CMD_GET_CHANNEL pipelining during + channel sync (it fires up to MAX_CHANNELS requests back-to-back); + at 12 the recv queue overran and dropped requests, stalling sync. config ZEPHCORE_BLE_CONN_MIN_INTERVAL int "BLE minimum connection interval (units: 1.25ms)" diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 11919f4..09af16b 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -102,8 +102,28 @@ bool ZephyrDataStore::mount() ext_lfs_mounted = true; LOG_INF("External QSPI LittleFS at %s (automounted, 100 blobs)", extMountPoint()); } else { +#if DT_NODE_EXISTS(DT_NODELABEL(qspi_lfs)) + /* Boot-time automount can miss the QSPI on the first boot after a + * factory-erase (blank flash) or an early-boot timing race with QSPI + * init. Retry the mount explicitly (fs_mount auto-formats blank flash, + * and mounts valid data without touching it) so contacts/channels land + * on /ext. Without this the store silently falls back to internal /lfs, + * and the next boot that does mount /ext runs a needless contact + * migration — the "Migrating contacts to external storage" churn. */ + FS_FSTAB_DECLARE_ENTRY(DT_NODELABEL(qspi_lfs)); + int rc = fs_mount(&FS_FSTAB_ENTRY(DT_NODELABEL(qspi_lfs))); + if (is_mounted(extMountPoint())) { + ext_lfs_mounted = true; + LOG_INF("External QSPI LittleFS at %s (mounted on retry, rc=%d)", + extMountPoint(), rc); + } else { + ext_lfs_mounted = false; + LOG_WRN("External QSPI mount retry failed (rc=%d) - using internal only (20 blobs)", rc); + } +#else ext_lfs_mounted = false; LOG_INF("External QSPI NOT mounted at %s - using internal only (20 blobs)", extMountPoint()); +#endif } return true; @@ -370,6 +390,28 @@ bool ZephyrDataStore::formatFileSystem() 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 + LOG_INF("formatFileSystem: mount() returned %d", mounted ? 1 : 0); return mounted; } @@ -378,6 +420,12 @@ void ZephyrDataStore::factoryReset() { LOG_INF("=== FACTORY RESET STARTING ==="); if (formatFileSystem()) { + /* Mark the freshly-formatted FS as ZephCore-initialised so the + * post-reboot first-boot check (no prefs → format) doesn't format it a + * SECOND time. That redundant format re-ran formatFileSystem() without + * a following mount(), which is what used to leave /ext unmounted and + * push contacts/channels onto internal flash. */ + writeInitMarker(); LOG_INF("=== FACTORY RESET COMPLETE - REBOOT REQUIRED ==="); } else { LOG_ERR("=== FACTORY RESET FAILED ==="); diff --git a/zephcore/app/CompanionMesh.cpp b/zephcore/app/CompanionMesh.cpp index 7d92c95..838fd87 100644 --- a/zephcore/app/CompanionMesh.cpp +++ b/zephcore/app/CompanionMesh.cpp @@ -228,10 +228,7 @@ void CompanionMesh::begin() /* Derive the v-contact pubkey from our identity: stable per node, unique * per device. Deliberately NOT a real keypair — no private key exists * anywhere, so nothing addressed to this key is decryptable by anyone. */ - static const char vc_salt[] = "zc-vcontact"; - mesh::Utils::sha256(_vcontact_pubkey, PUB_KEY_SIZE, - (const uint8_t *)vc_salt, sizeof(vc_salt) - 1, - self_id.pub_key, PUB_KEY_SIZE); + deriveVContactKey(); /* Stamp lastmod only if a time source already ran (hardware RTC restore * happens before begin()). Otherwise stay deferred (lastmod = 0) until * vcontactClockSynced() — an advert stamped now would show as 1970. */ @@ -978,6 +975,17 @@ void CompanionMesh::vcontactNotify(const char *text) vcontactQueueText(text); } +void CompanionMesh::deriveVContactKey() +{ + /* v-contact pubkey = SHA256("zc-vcontact" || self pubkey). Re-run whenever + * the identity changes (boot, CMD_IMPORT_PRIVATE_KEY) so the key always + * tracks the current identity. */ + static const char vc_salt[] = "zc-vcontact"; + mesh::Utils::sha256(_vcontact_pubkey, PUB_KEY_SIZE, + (const uint8_t *)vc_salt, sizeof(vc_salt) - 1, + self_id.pub_key, PUB_KEY_SIZE); +} + void CompanionMesh::vcontactPushAdvert() { if (!isVContactEnabled()) return; @@ -1096,6 +1104,27 @@ bool CompanionMesh::vcontactHandleFrame(const uint8_t *data, size_t len) } return false; + case CMD_GET_ADVERT_PATH: + /* The v-contact appears in the app's contact list, so the app queries + * its advert path (at connect, and around a config/identity import). The + * v-contact has no over-the-air advert, so the real handler's + * findAdvertPath() misses and returns ERR_NOT_FOUND — which the app + * surfaces as a fatal "not found" that aborts the whole operation. The + * v-contact IS this node (loopback), so its path is direct (zero hops): + * answer that here, before the real handler runs. Frame layout matches + * the real handler: [cmd][reserved][7-byte pubkey prefix]. */ + if (len >= 2 + 7 && isVContactKey(&data[2], 7)) { + uint8_t rsp[6]; + size_t i = 0; + rsp[i++] = PACKET_ADVERT_PATH; + put_le32(&rsp[i], _vcontact_lastmod ? _vcontact_lastmod + : (uint32_t)getRTCClock()->getCurrentTime()); i += 4; + rsp[i++] = 0; // path_len = 0 → direct (zero hop) + writeFrame(rsp, i); + return true; + } + return false; + case CMD_ADD_UPDATE_CONTACT: case CMD_RESET_PATH: /* Never let the v-contact into the real contacts table (it must stay @@ -2858,7 +2887,18 @@ bool CompanionMesh::handleProtocolFrame(const uint8_t *data, size_t len) mesh::LocalIdentity new_identity; new_identity.readFrom(&data[1], PRV_KEY_SIZE); if (_store->saveMainIdentity(new_identity)) { + /* The v-contact key is derived from our identity, so it + * changes with the new key. Tell the connected app to drop + * the old v-contact (old key), re-derive, then re-advertise + * the new one — otherwise the app's cached loopback contact + * points at a key we no longer recognise (messaging + its + * advert-path query break until a reboot re-syncs). Order + * matters: delete uses the CURRENT (old) key. */ + bool vc_was_enabled = isVContactEnabled(); + if (vc_was_enabled) vcontactPushDeleted(); self_id = new_identity; + deriveVContactKey(); + if (vc_was_enabled) vcontactPushAdvert(); /* Reload contacts to invalidate ECDH shared secrets */ resetContacts(); _store->loadContacts(this); diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index 6a99c0b..5d0e40e 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -523,6 +523,9 @@ private: bool vcontactReady() { return isVContactEnabled() && _vcontact_lastmod != 0; } void buildVContact(ContactInfo &c) const; bool isVContactKey(const uint8_t *key, int prefix_len) const; + /** (Re)derive _vcontact_pubkey from the current identity. Call on boot and + * whenever the identity changes (CMD_IMPORT_PRIVATE_KEY). */ + void deriveVContactKey(); /** Intercept protocol frames addressed to the v-contact. Returns true when * the frame was fully handled (response already written). */ bool vcontactHandleFrame(const uint8_t *data, size_t len);