#include "WarmNodeStore.h" #if WARM_NODE_COUNT > 0 #include "FSCommon.h" #include "SPILock.h" #include "SafeFile.h" #include "configuration.h" #include "memory/MemAudit.h" #include "power/PowerHAL.h" #include #include #if defined(NRF52840_XXAA) #include "flash/flash_nrf5x.h" #define WARM_RING_MAGIC 0x334E5257u // "WRN3" - v3: last_heard low bits carry role + protected + signer #define WARM_RING_MAGIC_V2 0x324E5257u // "WRN2" - v2: role + protected only; bit 6 was still timestamp. #define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" - v1: last_heard was a plain timestamp. // Older pages are still read on upgrade: v1 keeps identity + key but discards last_heard, // v2 keeps the word but clears bit 6, which was still part of the timestamp then. // A tombstone is an entry record whose last_heard is all-ones - getTime() // (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is // detected via num == 0xFFFFFFFF before last_heard is ever inspected. #define WARM_RING_TOMBSTONE 0xFFFFFFFFu #else // warm.dat layout: this header followed by count packed WarmNodeEntry records. struct WarmStoreHeader { uint32_t magic; // WARM_STORE_MAGIC uint32_t reserved; // 0; kept so the header stays 16 B uint16_t count; // entries persisted uint16_t entrySize; // sizeof(WarmNodeEntry), format guard uint32_t crc; // crc32 over count * entrySize bytes }; static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format"); #define WARM_STORE_MAGIC 0x334D5257u // "WRM3" - v3: last_heard low bits carry role + protected + signer #define WARM_STORE_MAGIC_V2 \ 0x324D5257u // "WRM2" - v2: role + protected only; bit 6 was still timestamp. On upgrade // we clear the signer bit, then rewrite as v3. #define WARM_STORE_MAGIC_V1 \ 0x314D5257u // "WRM1" - v1: last_heard was a plain timestamp. On upgrade we keep // identity + key but discard last_heard, then rewrite as v3. #ifdef FSCom static const char *warmFileName = "/prefs/warm.dat"; #endif #endif // NRF52840_XXAA static inline bool keyIsSet(const uint8_t key[32]) { for (int i = 0; i < 32; i++) if (key[i]) return true; return false; } WarmNodeStore::WarmNodeStore() { #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) entries = static_cast(ps_calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry))); if (!entries) { LOG_WARN("WarmStore: PSRAM alloc failed, using heap"); entries = static_cast(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry))); } #else entries = static_cast(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry))); #endif memaudit::set("warm", entries ? WARM_NODE_COUNT * sizeof(WarmNodeEntry) : 0); #if defined(NRF52840_XXAA) memset(pageOf, kNoPage, sizeof(pageOf)); #endif } WarmNodeStore::~WarmNodeStore() { free(entries); // always malloc-family (calloc / ps_calloc) entries = nullptr; memaudit::set("warm", 0); } WarmNodeEntry *WarmNodeStore::find(NodeNum num) const { if (!entries || !num) return nullptr; for (size_t i = 0; i < WARM_NODE_COUNT; i++) if (entries[i].num == num) return &entries[i]; return nullptr; } // Slot placement with the keyed-first admission policy. Shared by absorb() // and the ring replay, so the policy is applied identically in both paths. WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8_t *key32) { if (!entries || !num) return nullptr; const bool candidateKeyed = key32 && keyIsSet(key32); WarmNodeEntry *slot = find(num); const bool sameNode = slot != nullptr; if (!slot) { // Pick a victim: any empty slot, else the oldest keyless entry, else // (only for keyed candidates) the oldest keyed entry. WarmNodeEntry *oldestKeyless = nullptr, *oldestKeyed = nullptr; for (size_t i = 0; i < WARM_NODE_COUNT; i++) { WarmNodeEntry &e = entries[i]; if (!e.num) { slot = &e; break; } // Compare on the time bits only - the low metadata bits (role/protected) must // not perturb LRU victim selection. if (keyIsSet(e.public_key)) { if (!oldestKeyed || warmTimeOf(e) < warmTimeOf(*oldestKeyed)) oldestKeyed = &e; } else { if (!oldestKeyless || warmTimeOf(e) < warmTimeOf(*oldestKeyless)) oldestKeyless = &e; } } if (!slot) slot = oldestKeyless ? oldestKeyless : (candidateKeyed ? oldestKeyed : nullptr); if (!slot) return nullptr; // store full of keyed entries and the candidate has no key } slot->num = num; slot->last_heard = lastHeard; if (candidateKeyed) memcpy(slot->public_key, key32, 32); else if (!sameNode) // Repurposing a victim slot for a different node: clear its stale key. // A keyless refresh of a node already here keeps the key we learned. memset(slot->public_key, 0, 32); return slot; } bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat, bool signer) { // Pack role + protected category + signer into the low bits of last_heard. place() and // ring replay store the raw word verbatim, so the metadata round-trips through flash. const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat, signer); const WarmNodeEntry *slot = place(num, packed, key32); if (!slot) return false; persistEntry(*slot); LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u signer=%u (now %u/%u)", (unsigned)num, keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat, signer ? 1u : 0u, (unsigned)count(), (unsigned)capacity()); return true; } bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const { const WarmNodeEntry *e = find(num); if (!e) return false; role = warmRoleOf(*e); protectedCat = warmProtOf(*e); return true; } bool WarmNodeStore::isVerifiedSigner(NodeNum num) const { const WarmNodeEntry *e = find(num); return e && warmSignerOf(*e); } bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out) { WarmNodeEntry *e = find(num); if (!e) return false; out = *e; const int idx = static_cast(e - entries); memset(e, 0, sizeof(*e)); persistRemove(num, idx); LOG_MIGRATION("WarmStore take(rehydrate) 0x%08x key=%d (now %u/%u)", (unsigned)num, keyIsSet(out.public_key) ? 1 : 0, (unsigned)count(), (unsigned)capacity()); return true; } #if MESHTASTIC_NODEDB_MIGRATION_VERBOSE void WarmNodeStore::dumpToLog(const char *reason) const { if (!entries) { LOG_MIGRATION("WarmStore dump (%s): backend not allocated", reason); return; } LOG_MIGRATION("WarmStore dump (%s): %u live / %u cap ==>", reason, (unsigned)count(), (unsigned)capacity()); unsigned shown = 0; for (size_t i = 0; i < WARM_NODE_COUNT; i++) { const WarmNodeEntry &e = entries[i]; if (e.num == 0) continue; LOG_MIGRATION(" warm[%3u] 0x%08x last_heard=%u key=%d", (unsigned)i, (unsigned)e.num, (unsigned)e.last_heard, keyIsSet(e.public_key) ? 1 : 0); shown++; } LOG_MIGRATION("WarmStore dump (%s): <== end (%u entries)", reason, shown); } #endif // MESHTASTIC_NODEDB_MIGRATION_VERBOSE bool WarmNodeStore::copyKey(NodeNum num, uint8_t out[32]) const { const WarmNodeEntry *e = find(num); if (!e || !keyIsSet(e->public_key)) return false; memcpy(out, e->public_key, 32); return true; } bool WarmNodeStore::contains(NodeNum num) const { return find(num) != nullptr; } void WarmNodeStore::remove(NodeNum num) { WarmNodeEntry *e = find(num); if (e) { const int idx = static_cast(e - entries); memset(e, 0, sizeof(*e)); persistRemove(num, idx); } } void WarmNodeStore::clear() { if (!entries) return; memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry)); #if defined(NRF52840_XXAA) memset(pageOf, kNoPage, sizeof(pageOf)); #endif persistClear(); } size_t WarmNodeStore::count() const { size_t n = 0; if (entries) for (size_t i = 0; i < WARM_NODE_COUNT; i++) if (entries[i].num) n++; return n; } bool WarmNodeStore::saveIfDirty() { if (!dirty) return true; bool ok = save(); if (ok) dirty = false; return ok; } #if defined(NRF52840_XXAA) // Raw-flash record-ring backend (nRF52840). // 3 × 4 KB pages below LittleFS. Mutations append 40 B records (entry snapshot, // or tombstone with last_heard == 0xFFFFFFFF) via the shared flash_nrf5x page // cache; saveIfDirty() is the durability point. A full page reclaims the oldest // (stranded live entries re-appended, then erased). Flash access holds spiLock - // the page cache is shared with InternalFS/LittleFS. bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, WarmFormat *fmt) const { flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h)); if (h.seq == 0xFFFFFFFFu) return false; // erased page if (h.magic == WARM_RING_MAGIC) { if (fmt) *fmt = WarmFormat::Current; return true; } if (h.magic == WARM_RING_MAGIC_V2) { if (fmt) *fmt = WarmFormat::V2; // replay it, but clear the signer bit (see WARM_RING_MAGIC_V2) return true; } if (h.magic == WARM_RING_MAGIC_V1) { if (fmt) *fmt = WarmFormat::V1; // replay it, but discard last_heard (see WARM_RING_MAGIC_V1) return true; } return false; } // Caller holds spiLock. void WarmNodeStore::ringOpenPage(uint8_t page) { // Drop any cached state for the page before the real erase, so a later // cache flush can't resurrect stale bytes. flash_nrf5x_flush(); flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(page)); WarmPageHeader h; h.magic = WARM_RING_MAGIC; h.seq = nextSeq++; flash_nrf5x_write(WARM_FLASH_PAGE_ADDR(page), &h, sizeof(h)); activePage = page; writeSlot = 0; } // Caller holds spiLock. May recurse once via ringAppend if the stranded set // fills the fresh page exactly - bounded by WARM_NODE_COUNT <= 2*kRecordsPerPage. void WarmNodeStore::ringRotate() { uint8_t target = 0; if (activePage != kNoPage) { // Lowest-seq valid page, preferring erased pages; never the active one uint32_t bestSeq = 0; bool found = false; for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) { if (p == activePage) continue; WarmPageHeader h; if (!ringReadHeader(p, h)) { target = p; // erased/invalid page: free real estate, take it found = true; break; } if (!found || static_cast(h.seq - bestSeq) < 0) { target = p; bestSeq = h.seq; found = true; } } } // Capture live entries stranded in the page we're about to erase int stranded[WARM_NODE_COUNT] = {}; int nStranded = 0; for (size_t i = 0; i < WARM_NODE_COUNT; i++) { if (entries[i].num && pageOf[i] == target) stranded[nStranded++] = static_cast(i); if (pageOf[i] == target) pageOf[i] = kNoPage; } ringOpenPage(target); for (int k = 0; k < nStranded; k++) ringAppend(entries[stranded[k]], stranded[k]); } // Caller holds spiLock. void WarmNodeStore::ringAppend(const WarmNodeEntry &rec, int storeSlot) { if (activePage == kNoPage || writeSlot >= kRecordsPerPage) ringRotate(); const uint32_t addr = WARM_FLASH_PAGE_ADDR(activePage) + sizeof(WarmPageHeader) + static_cast(writeSlot) * sizeof(WarmNodeEntry); flash_nrf5x_write(addr, &rec, sizeof(rec)); writeSlot++; if (storeSlot >= 0) pageOf[storeSlot] = activePage; dirty = true; } void WarmNodeStore::persistEntry(const WarmNodeEntry &e) { concurrency::LockGuard g(spiLock); ringAppend(e, static_cast(&e - entries)); } void WarmNodeStore::persistRemove(NodeNum num, int storeSlot) { if (storeSlot >= 0 && storeSlot < static_cast(WARM_NODE_COUNT)) pageOf[storeSlot] = 0xFF; WarmNodeEntry tomb; memset(&tomb, 0, sizeof(tomb)); tomb.num = num; tomb.last_heard = WARM_RING_TOMBSTONE; concurrency::LockGuard g(spiLock); ringAppend(tomb, -1); } void WarmNodeStore::persistClear() { concurrency::LockGuard g(spiLock); flash_nrf5x_flush(); for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(p)); activePage = 0xFF; writeSlot = 0; nextSeq = 1; dirty = false; // the erased ring already reflects the empty store } void WarmNodeStore::load() { if (!entries) return; concurrency::LockGuard g(spiLock); // Order valid pages by ascending seq so replay applies oldest first uint8_t order[WARM_FLASH_PAGES] = {}; uint32_t seqs[WARM_FLASH_PAGES] = {}; WarmFormat fmtOf[WARM_FLASH_PAGES] = {}; // per-page on-flash format; older ones are normalised on replay uint8_t nValid = 0; uint8_t nCorrupt = 0; for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) { WarmPageHeader h; WarmFormat fmt = WarmFormat::Current; if (!ringReadHeader(p, h, &fmt)) { // An erased page reads back all-ones; any other magic is a // partially-written or bit-rotted header we're dropping, so flag it // rather than silently treating the loss as a clean empty ring. if (h.magic != 0xFFFFFFFFu) nCorrupt++; continue; } fmtOf[p] = fmt; uint8_t pos = nValid; while (pos > 0 && static_cast(h.seq - seqs[pos - 1]) < 0) { order[pos] = order[pos - 1]; seqs[pos] = seqs[pos - 1]; pos--; } order[pos] = p; seqs[pos] = h.seq; nValid++; } if (nValid == 0) { activePage = 0xFF; writeSlot = 0; nextSeq = 1; if (nCorrupt) LOG_WARN("WarmStore: ring unreadable (%u bad page(s)), empty", nCorrupt); else LOG_INFO("WarmStore: ring empty, starting fresh"); return; } uint32_t replayed = 0; uint32_t migrated = 0; for (uint8_t k = 0; k < nValid; k++) { const uint8_t p = order[k]; const WarmFormat fmt = fmtOf[p]; uint16_t slot = 0; for (; slot < kRecordsPerPage; slot++) { WarmNodeEntry rec; flash_nrf5x_read(&rec, WARM_FLASH_PAGE_ADDR(p) + sizeof(WarmPageHeader) + (uint32_t)slot * sizeof(rec), sizeof(rec)); if (rec.num == 0xFFFFFFFFu) break; // erased space: end of this page's records (append-only) if (rec.num == 0) continue; // unexpected; skip defensively replayed++; if (rec.last_heard == WARM_RING_TOMBSTONE) { WarmNodeEntry *e = find(rec.num); if (e) { pageOf[e - entries] = 0xFF; memset(e, 0, sizeof(*e)); } } else { // Normalise older records: v1's timestamp would be misread as role/protected, // and v2's bit 6 as a signer we never verified. uint32_t lh = rec.last_heard; if (fmt == WarmFormat::V1) { lh = 0; migrated++; } else if (fmt == WarmFormat::V2) { lh &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT); migrated++; } const WarmNodeEntry *e = place(rec.num, lh, rec.public_key); if (e) pageOf[e - entries] = p; } } if (k == nValid - 1) { // newest page becomes the active head activePage = p; writeSlot = slot; nextSeq = seqs[k] + 1; // Rotate rather than append into an older-format page: a later load would // normalise the new records to that page's format and drop metadata. if (fmt != WarmFormat::Current) writeSlot = kRecordsPerPage; } } if (nCorrupt) LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt); if (migrated) LOG_INFO("WarmStore: migrated %u legacy record(s) to the current format", (unsigned)migrated); LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(), activePage, writeSlot); } bool WarmNodeStore::save() { if (!powerHAL_isPowerLevelSafe()) { LOG_ERROR("Error: trying to save WarmStore on unsafe device power level."); return false; } concurrency::LockGuard g(spiLock); flash_nrf5x_flush(); return true; } #else // !NRF52840_XXAA -------------------- void WarmNodeStore::persistEntry(const WarmNodeEntry &e) { (void)e; dirty = true; } void WarmNodeStore::persistRemove(NodeNum num, int storeSlot) { (void)num; (void)storeSlot; dirty = true; } void WarmNodeStore::persistClear() { dirty = true; } #ifdef FSCom // ---- File persistence: /prefs/warm.dat snapshots ---------------------------- // Compact occupied slots to the front of `dst`; returns the count. static uint16_t packEntries(const WarmNodeEntry *src, WarmNodeEntry *dst) { uint16_t n = 0; for (size_t i = 0; i < WARM_NODE_COUNT; i++) if (src[i].num) dst[n++] = src[i]; return n; } void WarmNodeStore::load() { if (!entries) return; // Clear first - all failure paths below then correctly represent "empty", // even if load() is called on an already-used instance. memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry)); concurrency::LockGuard g(spiLock); auto f = FSCom.open(warmFileName, FILE_O_READ); if (!f) return; WarmStoreHeader h; if ((size_t)f.read((uint8_t *)&h, sizeof(h)) != sizeof(h)) { f.close(); LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName); return; } // Older snapshots are still accepted: same record size, fewer metadata bits in // last_heard. Both are normalised to the current format below. const bool known = h.magic == WARM_STORE_MAGIC || h.magic == WARM_STORE_MAGIC_V2 || h.magic == WARM_STORE_MAGIC_V1; const WarmFormat fmt = h.magic == WARM_STORE_MAGIC_V1 ? WarmFormat::V1 : h.magic == WARM_STORE_MAGIC_V2 ? WarmFormat::V2 : WarmFormat::Current; const bool legacy = fmt != WarmFormat::Current; if (!known || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) { f.close(); LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic, h.entrySize, h.count); return; } if (h.count) { const size_t len = (size_t)h.count * sizeof(WarmNodeEntry); const bool readOk = (size_t)f.read((uint8_t *)entries, len) == len; f.close(); if (!readOk) { LOG_WARN("WarmStore: %s entries read failed, starting empty", warmFileName); return; } // CRC covers the bytes as written (v1 still has the old last_heard), so check before migrating. if (crc32Buffer(entries, len) != h.crc) { LOG_WARN("WarmStore: %s CRC mismatch, starting empty", warmFileName); memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry)); return; } // Normalise older records, then mark dirty so save() rewrites in the current format: // v1's timestamp would be misread as role/protected, v2's bit 6 as an unverified signer. if (fmt == WarmFormat::V1) { for (size_t i = 0; i < WARM_NODE_COUNT; i++) if (entries[i].num) entries[i].last_heard = 0; dirty = true; } else if (fmt == WarmFormat::V2) { for (size_t i = 0; i < WARM_NODE_COUNT; i++) if (entries[i].num) entries[i].last_heard &= ~(WARM_SIGNER_MASK << WARM_SIGNER_SHIFT); dirty = true; } } else { f.close(); } LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName, legacy ? " (migrated from an older format)" : ""); } bool WarmNodeStore::save() { if (!entries) return false; if (!powerHAL_isPowerLevelSafe()) { LOG_ERROR("Error: trying to save WarmStore on unsafe device power level."); return false; } std::vector packed(WARM_NODE_COUNT); WarmStoreHeader h; h.magic = WARM_STORE_MAGIC; h.reserved = 0; h.count = packEntries(entries, packed.data()); h.entrySize = sizeof(WarmNodeEntry); h.crc = crc32Buffer(packed.data(), h.count * sizeof(WarmNodeEntry)); // SafeFile already does its own spiLock in its constructor and close(). // Avoid nesting spiLocks, as this will hang until watchdog reset! { concurrency::LockGuard g(spiLock); FSCom.mkdir("/prefs"); } auto f = SafeFile(warmFileName, false); { concurrency::LockGuard g(spiLock); f.write((const uint8_t *)&h, sizeof(h)); f.write((const uint8_t *)packed.data(), h.count * sizeof(WarmNodeEntry)); } bool ok = f.close(); if (!ok) LOG_ERROR("WarmStore: can't write %s", warmFileName); else LOG_DEBUG("WarmStore: saved %u warm nodes to %s", h.count, warmFileName); return ok; } #else void WarmNodeStore::load() {} bool WarmNodeStore::save() { return true; } #endif // FSCom #endif // NRF52840_XXAA #endif // WARM_NODE_COUNT > 0