diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 34e0d72a..635244f2 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -1,6 +1,11 @@ #include +#include #include "DataStore.h" +#if defined(NRF52_PLATFORM) +#include +#endif + #if defined(EXTRAFS) || defined(QSPIFLASH) #define MAX_BLOBRECS 100 #else @@ -43,7 +48,11 @@ static File openWrite(FILESYSTEM* fs, const char* filename) { } #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - static uint32_t _ContactsChannelsTotalBlocks = 0; +static bool validateLfsFilesystem(FILESYSTEM* fs); +#endif +#if defined(NRF52_PLATFORM) +static bool recoverPrimaryFilesystem(FILESYSTEM* fs); +static void cleanupAtomicTempFiles(FILESYSTEM* fs); #endif void DataStore::begin() { @@ -52,11 +61,26 @@ void DataStore::begin() { #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) - _ContactsChannelsTotalBlocks = _getContactsChannelsFS()->_getFS()->cfg->block_count; - checkAdvBlobFile(); +#if defined(NRF52_PLATFORM) + bool primary_ready = validateLfsFilesystem(_fs); + if (!primary_ready) { + MESH_DEBUG_PRINTLN("DataStore: primary LittleFS metadata is corrupt; rebuilding before first write"); + primary_ready = recoverPrimaryFilesystem(_fs); + } + if (_fsExtra != nullptr && !validateLfsFilesystem(_fsExtra)) { + // Do not erase removable/external storage automatically. Keep it intact + // for recovery and boot from the known-good internal filesystem. + MESH_DEBUG_PRINTLN("DataStore: secondary LittleFS metadata is corrupt; using internal storage"); + _fsExtra = nullptr; + } + if (primary_ready) cleanupAtomicTempFiles(_fs); + if (_fsExtra != nullptr) cleanupAtomicTempFiles(_fsExtra); + resetContactPageState(); +#endif #if defined(EXTRAFS) || defined(QSPIFLASH) - migrateToSecondaryFS(); + if (_fsExtra != nullptr) migrateToSecondaryFS(); #endif + checkAdvBlobFile(); #else // init 'blob store' support _fs->mkdir("/bl"); @@ -79,24 +103,76 @@ void DataStore::begin() { #endif #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +struct LfsTraversalState { + lfs_size_t visited; + lfs_size_t block_count; +}; + int _countLfsBlock(void *p, lfs_block_t block){ - if (block > _ContactsChannelsTotalBlocks) { - MESH_DEBUG_PRINTLN("ERROR: Block %d exceeds filesystem bounds - CORRUPTION DETECTED!", block); - return LFS_ERR_CORRUPT; // return error to abort lfs_traverse() gracefully - } - lfs_size_t *size = (lfs_size_t*) p; - *size += 1; - return 0; + LfsTraversalState* state = (LfsTraversalState*)p; + // Valid blocks are [0, block_count). A traversal longer than block_count + // indicates a metadata cycle even if each individual block number is valid. + if (block >= state->block_count || state->visited >= state->block_count) { + MESH_DEBUG_PRINTLN("ERROR: LittleFS traversal out of bounds/cyclic at block %lu", + (unsigned long)block); + return LFS_ERR_CORRUPT; + } + state->visited++; + return 0; } lfs_ssize_t _getLfsUsedBlockCount(FILESYSTEM* fs) { - lfs_size_t size = 0; - int err = lfs_traverse(fs->_getFS(), _countLfsBlock, &size); + LfsTraversalState state = {0, fs->_getFS()->cfg->block_count}; + int err = lfs_traverse(fs->_getFS(), _countLfsBlock, &state); if (err) { MESH_DEBUG_PRINTLN("ERROR: lfs_traverse() error: %d", err); - return 0; + return -1; + } + return state.visited; +} + +static bool validateLfsFilesystem(FILESYSTEM* fs) { + return fs != nullptr && _getLfsUsedBlockCount(fs) >= 0; +} +#endif + +#if defined(NRF52_PLATFORM) +static bool recoverPrimaryFilesystem(FILESYSTEM* fs) { + // Once traversal has found a bad pointer or metadata cycle, even read-only + // path lookup on this mounted filesystem can enter the same unbounded walk. + // Do not try to copy identity/preferences out through the corrupted metadata: + // rebuild immediately so the next mutation cannot hard-fault or hang forever. + const bool formatted = fs->format(); + if (!formatted) { + MESH_DEBUG_PRINTLN("DataStore: primary LittleFS rebuild failed"); + } else { + MESH_DEBUG_PRINTLN("DataStore: primary LittleFS rebuilt; corrupt contents discarded"); + } + return formatted; +} + +static void cleanupAtomicTempFiles(FILESYSTEM* fs) { + if (fs == nullptr) return; + + static const char* fixed_temp_paths[] = { + "/_main.id.tmp", "/new_prefs.tmp", "/channels2.tmp", + "/contacts3.tmp", "/contacts4.mig.tmp", "/adv_blobs.tmp"}; + for (size_t i = 0; i < sizeof(fixed_temp_paths) / sizeof(fixed_temp_paths[0]); i++) { + if (fs->exists(fixed_temp_paths[i])) fs->remove(fixed_temp_paths[i]); + } + + // Include the old ten-bucket range as well as the current five-bucket + // layout so interrupted preview builds cannot strand full LittleFS blocks. + for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) { + char path[28]; + snprintf(path, sizeof(path), "/contacts4_%02u.tmp", (unsigned)page); + if (fs->exists(path)) fs->remove(path); + } + for (uint8_t bucket = 0; bucket < 10; bucket++) { + char path[24]; + snprintf(path, sizeof(path), "/adv4_%02u.tmp", (unsigned)bucket); + if (fs->exists(path)) fs->remove(path); } - return size; } #endif @@ -111,6 +187,7 @@ uint32_t DataStore::getStorageUsedKb() const { #elif defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) const lfs_config* config = _getContactsChannelsFS()->_getFS()->cfg; int usedBlockCount = _getLfsUsedBlockCount(_getContactsChannelsFS()); + if (usedBlockCount < 0) return 0; int usedBytes = config->block_size * usedBlockCount; return usedBytes / 1024; #else @@ -165,6 +242,9 @@ bool DataStore::removeFile(FILESYSTEM* fs, const char* filename) { bool DataStore::formatFileSystem() { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + #if defined(NRF52_PLATFORM) + resetContactPageState(); + #endif if (_fsExtra == nullptr) { return _fs->format(); } else { @@ -194,8 +274,9 @@ void DataStore::loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon) loadPrefsInt("/new_prefs", prefs, node_lat, node_lon); // new filename } else if (_fs->exists("/node_prefs")) { loadPrefsInt("/node_prefs", prefs, node_lat, node_lon); - savePrefs(prefs, node_lat, node_lon); // save to new filename - _fs->remove("/node_prefs"); // remove old + if (savePrefs(prefs, node_lat, node_lon)) { + _fs->remove("/node_prefs"); // remove old only after verified replacement + } } } @@ -241,82 +322,413 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no } } -void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) { +bool DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_lon) { +#if defined(NRF52_PLATFORM) + mesh::AtomicFileWriter file(_fs, "/new_prefs"); +#else File file = openWrite(_fs, "/new_prefs"); +#endif if (file) { uint8_t pad[8]; memset(pad, 0, sizeof(pad)); - file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)); // 0 - file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)); // 4 - file.write(pad, 4); // 36 - file.write((uint8_t *)&node_lat, sizeof(node_lat)); // 40 - file.write((uint8_t *)&node_lon, sizeof(node_lon)); // 48 - file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)); // 56 - file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)); // 60 - file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)); // 61 - file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)); // 62 - file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)); // 63 - file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)); // 64 - file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)); // 68 - file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)); // 69 - file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)); // 70 - file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)); // 71 - file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)); // 72 - file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)); // 76 - file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)); // 77 - file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)); // 78 - file.write(pad, 1); // 79 - file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)); // 80 - file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)); // 84 - file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)); // 85 - file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)); // 86 - file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)); // 87 - file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)); // 88 - file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)); // 89 - file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)); // 90 - file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)); // 121 - file.write((uint8_t *)&_prefs.radio_fem_rxgain, sizeof(_prefs.radio_fem_rxgain)); // 122 - file.write((uint8_t *)&_prefs.radio_fem_rxgain_override, - sizeof(_prefs.radio_fem_rxgain_override)); // 123 + bool success = file.write((uint8_t *)&_prefs.airtime_factor, sizeof(float)) == sizeof(float); // 0 + success = success && file.write((uint8_t *)_prefs.node_name, sizeof(_prefs.node_name)) == sizeof(_prefs.node_name); // 4 + success = success && file.write(pad, 4) == 4; // 36 + success = success && file.write((uint8_t *)&node_lat, sizeof(node_lat)) == sizeof(node_lat); // 40 + success = success && file.write((uint8_t *)&node_lon, sizeof(node_lon)) == sizeof(node_lon); // 48 + success = success && file.write((uint8_t *)&_prefs.freq, sizeof(_prefs.freq)) == sizeof(_prefs.freq); // 56 + success = success && file.write((uint8_t *)&_prefs.sf, sizeof(_prefs.sf)) == sizeof(_prefs.sf); // 60 + success = success && file.write((uint8_t *)&_prefs.cr, sizeof(_prefs.cr)) == sizeof(_prefs.cr); // 61 + success = success && file.write((uint8_t *)&_prefs.client_repeat, sizeof(_prefs.client_repeat)) == sizeof(_prefs.client_repeat); // 62 + success = success && file.write((uint8_t *)&_prefs.manual_add_contacts, sizeof(_prefs.manual_add_contacts)) == sizeof(_prefs.manual_add_contacts); // 63 + success = success && file.write((uint8_t *)&_prefs.bw, sizeof(_prefs.bw)) == sizeof(_prefs.bw); // 64 + success = success && file.write((uint8_t *)&_prefs.tx_power_dbm, sizeof(_prefs.tx_power_dbm)) == sizeof(_prefs.tx_power_dbm); // 68 + success = success && file.write((uint8_t *)&_prefs.telemetry_mode_base, sizeof(_prefs.telemetry_mode_base)) == sizeof(_prefs.telemetry_mode_base); // 69 + success = success && file.write((uint8_t *)&_prefs.telemetry_mode_loc, sizeof(_prefs.telemetry_mode_loc)) == sizeof(_prefs.telemetry_mode_loc); // 70 + success = success && file.write((uint8_t *)&_prefs.telemetry_mode_env, sizeof(_prefs.telemetry_mode_env)) == sizeof(_prefs.telemetry_mode_env); // 71 + success = success && file.write((uint8_t *)&_prefs.rx_delay_base, sizeof(_prefs.rx_delay_base)) == sizeof(_prefs.rx_delay_base); // 72 + success = success && file.write((uint8_t *)&_prefs.advert_loc_policy, sizeof(_prefs.advert_loc_policy)) == sizeof(_prefs.advert_loc_policy); // 76 + success = success && file.write((uint8_t *)&_prefs.multi_acks, sizeof(_prefs.multi_acks)) == sizeof(_prefs.multi_acks); // 77 + success = success && file.write((uint8_t *)&_prefs.path_hash_mode, sizeof(_prefs.path_hash_mode)) == sizeof(_prefs.path_hash_mode); // 78 + success = success && file.write(pad, 1) == 1; // 79 + success = success && file.write((uint8_t *)&_prefs.ble_pin, sizeof(_prefs.ble_pin)) == sizeof(_prefs.ble_pin); // 80 + success = success && file.write((uint8_t *)&_prefs.buzzer_quiet, sizeof(_prefs.buzzer_quiet)) == sizeof(_prefs.buzzer_quiet); // 84 + success = success && file.write((uint8_t *)&_prefs.gps_enabled, sizeof(_prefs.gps_enabled)) == sizeof(_prefs.gps_enabled); // 85 + success = success && file.write((uint8_t *)&_prefs.gps_interval, sizeof(_prefs.gps_interval)) == sizeof(_prefs.gps_interval); // 86 + success = success && file.write((uint8_t *)&_prefs.autoadd_config, sizeof(_prefs.autoadd_config)) == sizeof(_prefs.autoadd_config); // 87 + success = success && file.write((uint8_t *)&_prefs.autoadd_max_hops, sizeof(_prefs.autoadd_max_hops)) == sizeof(_prefs.autoadd_max_hops); // 88 + success = success && file.write((uint8_t *)&_prefs.rx_boosted_gain, sizeof(_prefs.rx_boosted_gain)) == sizeof(_prefs.rx_boosted_gain); // 89 + success = success && file.write((uint8_t *)_prefs.default_scope_name, sizeof(_prefs.default_scope_name)) == sizeof(_prefs.default_scope_name); // 90 + success = success && file.write((uint8_t *)_prefs.default_scope_key, sizeof(_prefs.default_scope_key)) == sizeof(_prefs.default_scope_key); // 121 + success = success && file.write((uint8_t *)&_prefs.radio_fem_rxgain, sizeof(_prefs.radio_fem_rxgain)) == sizeof(_prefs.radio_fem_rxgain); // 122 + success = success && file.write((uint8_t *)&_prefs.radio_fem_rxgain_override, + sizeof(_prefs.radio_fem_rxgain_override)) == sizeof(_prefs.radio_fem_rxgain_override); // 123 +#if defined(NRF52_PLATFORM) + success = file.commit(success); + if (!success) MESH_DEBUG_PRINTLN("DataStore: atomic preferences write failed"); +#else file.close(); +#endif + return success; + } + return false; +} + +static void serializeContactRecord(const ContactInfo& c, + uint8_t out[mesh::storage::CONTACT_RECORD_SIZE]) { + size_t offset = 0; + const uint8_t unused = 0; + memcpy(&out[offset], c.id.pub_key, 32); offset += 32; + memcpy(&out[offset], c.name, 32); offset += 32; + out[offset++] = c.type; + out[offset++] = c.flags; + out[offset++] = unused; + memcpy(&out[offset], &c.sync_since, 4); offset += 4; + out[offset++] = c.out_path_len; + memcpy(&out[offset], &c.last_advert_timestamp, 4); offset += 4; + memcpy(&out[offset], c.out_path, 64); offset += 64; + memcpy(&out[offset], &c.lastmod, 4); offset += 4; + memcpy(&out[offset], &c.gps_lat, 4); offset += 4; + memcpy(&out[offset], &c.gps_lon, 4); +} + +static bool deserializeContactRecord( + const uint8_t in[mesh::storage::CONTACT_RECORD_SIZE], ContactInfo& c) { + size_t offset = 0; + uint8_t pub_key[32]; + memcpy(pub_key, &in[offset], 32); offset += 32; + memcpy(c.name, &in[offset], 32); offset += 32; + c.name[sizeof(c.name) - 1] = 0; + c.type = in[offset++]; + c.flags = in[offset++]; + offset++; // reserved + memcpy(&c.sync_since, &in[offset], 4); offset += 4; + c.out_path_len = in[offset++]; + memcpy(&c.last_advert_timestamp, &in[offset], 4); offset += 4; + memcpy(c.out_path, &in[offset], 64); offset += 64; + memcpy(&c.lastmod, &in[offset], 4); offset += 4; + memcpy(&c.gps_lat, &in[offset], 4); offset += 4; + memcpy(&c.gps_lon, &in[offset], 4); + c.id = mesh::Identity(pub_key); + c.shared_secret_valid = false; + return c.out_path_len == OUT_PATH_UNKNOWN || c.out_path_len <= MAX_PATH_SIZE; +} + +#if defined(NRF52_PLATFORM) +static const char* CONTACT_MIGRATION_MARKER = "/contacts4.mig"; + +static void makeContactPagePath(uint8_t page, char path[24]) { + snprintf(path, 24, "/contacts4_%02u", (unsigned)page); +} + +static void discardInvalidContactPage(FILESYSTEM* fs, const char* path, + uint8_t page) { + // The page has already failed size/format/CRC validation and cannot be a + // recovery source. Remove it so the atomic replacement only needs one free + // page; retaining full-size .bad copies can otherwise exhaust a 100 KiB + // ExtraFS and make self-repair impossible. + if (!fs->remove(path)) { + MESH_DEBUG_PRINTLN("DataStore: could not remove invalid contact page %u", page); } } -void DataStore::loadContacts(DataStoreHost* host) { -File file = openRead(_getContactsChannelsFS(), "/contacts3"); - if (file) { - bool full = false; - while (!full) { - ContactInfo c; - uint8_t pub_key[32]; - uint8_t unused; - - bool success = (file.read(pub_key, 32) == 32); - success = success && (file.read((uint8_t *)&c.name, 32) == 32); - success = success && (file.read(&c.type, 1) == 1); - success = success && (file.read(&c.flags, 1) == 1); - success = success && (file.read(&unused, 1) == 1); - success = success && (file.read((uint8_t *)&c.sync_since, 4) == 4); // was 'reserved' - success = success && (file.read((uint8_t *)&c.out_path_len, 1) == 1); - success = success && (file.read((uint8_t *)&c.last_advert_timestamp, 4) == 4); - success = success && (file.read(c.out_path, 64) == 64); - success = success && (file.read((uint8_t *)&c.lastmod, 4) == 4); - success = success && (file.read((uint8_t *)&c.gps_lat, 4) == 4); - success = success && (file.read((uint8_t *)&c.gps_lon, 4) == 4); - - if (!success) break; // EOF - - c.id = mesh::Identity(pub_key); - if (!host->onContactLoaded(c)) full = true; - } - file.close(); - } +void DataStore::resetContactPageState() { + _contact_slots.clear(); + _dirty_contact_pages.clearAll(); + memset(_contact_page_generations, 0, sizeof(_contact_page_generations)); + _legacy_contacts_pending_cleanup = false; + _legacy_migration_ready = false; + _legacy_contact_count = 0; } -void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { +bool DataStore::prepareLegacyContactMigration() { + FILESYSTEM* fs = _getContactsChannelsFS(); + if (fs->exists(CONTACT_MIGRATION_MARKER)) { + _legacy_migration_ready = true; + return true; + } + + // With no marker, page files can be leftovers from a newer firmware followed + // by a downgrade that rewrote /contacts3. The complete legacy file remains + // authoritative while these are removed, so a reset at any point is safe. + bool clean = true; + for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) { + char path[24]; + makeContactPagePath(page, path); + if (fs->exists(path) && !fs->remove(path)) clean = false; + + char temp_path[28]; + snprintf(temp_path, sizeof(temp_path), "%s.tmp", path); + if (fs->exists(temp_path) && !fs->remove(temp_path)) clean = false; + } + if (!clean) { + MESH_DEBUG_PRINTLN("DataStore: could not clear stale contact pages before migration"); + return false; + } + + mesh::AtomicFileWriter marker(fs, CONTACT_MIGRATION_MARKER); + _legacy_migration_ready = marker.commit(true); + if (!_legacy_migration_ready) { + MESH_DEBUG_PRINTLN("DataStore: could not create contact migration marker"); + } + return _legacy_migration_ready; +} + +bool DataStore::loadContactPages(DataStoreHost* host, uint16_t minimum_slot) { + bool any_page_file = false; + FILESYSTEM* fs = _getContactsChannelsFS(); + + for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) { + char path[24]; + makeContactPagePath(page, path); + if (!fs->exists(path)) continue; + any_page_file = true; + + File file = openRead(fs, path); + if (!file || file.size() != mesh::storage::CONTACT_PAGE_FILE_SIZE) { + MESH_DEBUG_PRINTLN("DataStore: ignoring invalid contact page %u", page); + if (file) file.close(); + discardInvalidContactPage(fs, path, page); + _dirty_contact_pages.mark(page); + continue; + } + + uint8_t raw_header[mesh::storage::CONTACT_PAGE_HEADER_SIZE]; + mesh::storage::ContactPageHeader header; + bool valid = file.read(raw_header, sizeof(raw_header)) == sizeof(raw_header) + && mesh::storage::decodeContactPageHeader(raw_header, page, header); + + uint32_t crc = 0xFFFFFFFFUL; + uint16_t remaining = mesh::storage::CONTACT_PAGE_PAYLOAD_SIZE; + uint8_t chunk[64]; + while (valid && remaining > 0) { + const uint16_t count = remaining < sizeof(chunk) ? remaining : sizeof(chunk); + if (file.read(chunk, count) != count) { + valid = false; + break; + } + crc = mesh::storage::updateCRC32(crc, chunk, count); + remaining -= count; + } + + if (!valid || crc != header.payload_crc) { + MESH_DEBUG_PRINTLN("DataStore: contact page %u failed CRC/format validation", page); + file.close(); + discardInvalidContactPage(fs, path, page); + _dirty_contact_pages.mark(page); + continue; + } + + _contact_page_generations[page] = header.generation; + for (uint8_t index = 0; index < mesh::storage::CONTACTS_PER_PAGE; index++) { + if ((header.occupied & (1UL << index)) == 0) continue; + + const uint16_t slot = (uint16_t)page * mesh::storage::CONTACTS_PER_PAGE + index; + if (!mesh::storage::loadSlotFromMigratedPage(slot, minimum_slot)) continue; + uint8_t record[mesh::storage::CONTACT_RECORD_SIZE]; + if (!file.seek(mesh::storage::CONTACT_PAGE_HEADER_SIZE + + (uint32_t)index * mesh::storage::CONTACT_RECORD_SIZE) + || file.read(record, sizeof(record)) != sizeof(record)) { + MESH_DEBUG_PRINTLN("DataStore: contact page %u slot %u could not be read", page, index); + continue; + } + + ContactInfo contact; + if (!deserializeContactRecord(record, contact) || !_contact_slots.reserve(slot)) { + MESH_DEBUG_PRINTLN("DataStore: contact page %u slot %u is invalid/duplicate", page, index); + _dirty_contact_pages.mark(page); + continue; + } + contact.storage_slot = slot; + if (!host->onContactLoaded(contact)) { + _contact_slots.release(slot); + file.close(); + return true; + } + } + file.close(); + } + return any_page_file; +} + +bool DataStore::writeContactPage(DataStoreHost* host, uint8_t page, + bool (*filter)(const ContactInfo& c)) { + if (page >= mesh::storage::CONTACT_PAGE_COUNT) return false; + + // The nRF52 Arduino loop task has only a 4 KiB stack. Keep pointers to this + // page's contacts and stream one 152-byte record at a time instead of + // allocating the complete 3.8 KiB payload on that stack. + ContactInfo* page_contacts[mesh::storage::CONTACTS_PER_PAGE]; + memset(page_contacts, 0, sizeof(page_contacts)); + uint32_t occupied = 0; + + for (uint32_t index = 0;; index++) { + ContactInfo* contact = host->getContactForStore(index); + if (contact == NULL) break; + if ((filter && !filter(*contact)) + || contact->storage_slot == mesh::storage::CONTACT_SLOT_NONE + || contact->storage_slot / mesh::storage::CONTACTS_PER_PAGE != page) { + continue; + } + + const uint8_t page_slot = contact->storage_slot % mesh::storage::CONTACTS_PER_PAGE; + page_contacts[page_slot] = contact; + occupied |= 1UL << page_slot; + } + + uint32_t payload_crc = 0xFFFFFFFFUL; + uint8_t record[mesh::storage::CONTACT_RECORD_SIZE]; + for (uint8_t slot = 0; slot < mesh::storage::CONTACTS_PER_PAGE; slot++) { + memset(record, 0, sizeof(record)); + if (page_contacts[slot] != nullptr) { + serializeContactRecord(*page_contacts[slot], record); + } + payload_crc = mesh::storage::updateCRC32(payload_crc, record, sizeof(record)); + } + + mesh::storage::ContactPageHeader header; + header.page_index = page; + header.occupied = occupied; + header.generation = _contact_page_generations[page] + 1; + header.payload_crc = payload_crc; + uint8_t raw_header[mesh::storage::CONTACT_PAGE_HEADER_SIZE]; + mesh::storage::encodeContactPageHeader(raw_header, header); + + char path[24]; + makeContactPagePath(page, path); + mesh::AtomicFileWriter writer(_getContactsChannelsFS(), path); + bool wrote = writer + && writer.write(raw_header, sizeof(raw_header)) == sizeof(raw_header); + for (uint8_t slot = 0; wrote && slot < mesh::storage::CONTACTS_PER_PAGE; slot++) { + memset(record, 0, sizeof(record)); + if (page_contacts[slot] != nullptr) { + serializeContactRecord(*page_contacts[slot], record); + } + wrote = writer.write(record, sizeof(record)) == sizeof(record); + } + if (!writer.commit(wrote)) { + MESH_DEBUG_PRINTLN("DataStore: atomic contact page %u write failed", page); + return false; + } + + _contact_page_generations[page] = header.generation; + return true; +} +#endif + +void DataStore::loadContacts(DataStoreHost* host) { +#if defined(NRF52_PLATFORM) + // loadContacts() is also used after an identity import. Rebuild runtime + // slot ownership from disk so stale pointers/slots from the previous in-RAM + // contact table cannot collide with the reload. + resetContactPageState(); + FILESYSTEM* contacts_fs = _getContactsChannelsFS(); + bool any_page_file = false; + for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) { + char path[24]; + makeContactPagePath(page, path); + if (contacts_fs->exists(path)) { + any_page_file = true; + } + } + const mesh::storage::ContactStoreSource source = + mesh::storage::chooseContactStoreSource( + contacts_fs->exists("/contacts3"), any_page_file); + if (source == mesh::storage::ContactStoreSource::PAGED) { + // A reset after the final legacy removal may leave this harmless marker. + if (contacts_fs->exists(CONTACT_MIGRATION_MARKER)) { + contacts_fs->remove(CONTACT_MIGRATION_MARKER); + } + loadContactPages(host, 0); + return; + } + if (source == mesh::storage::ContactStoreSource::EMPTY) { + if (contacts_fs->exists(CONTACT_MIGRATION_MARKER)) { + contacts_fs->remove(CONTACT_MIGRATION_MARKER); + } + return; + } + + // Migrate /contacts3 from the tail so the legacy prefix and completed pages + // never need enough room to coexist in full. A page is committed first, + // then the corresponding legacy tail is truncated. On a reset between + // those operations the still-present legacy prefix wins overlapping slots. + _legacy_contacts_pending_cleanup = true; + _legacy_migration_ready = contacts_fs->exists(CONTACT_MIGRATION_MARKER); +#endif + + File file = openRead(_getContactsChannelsFS(), "/contacts3"); +#if defined(NRF52_PLATFORM) + if (!file) { + // Never delete or truncate a legacy source that could not be opened. + MESH_DEBUG_PRINTLN("DataStore: legacy contacts exist but could not be read"); + _legacy_contacts_pending_cleanup = false; + return; + } + if (!mesh::storage::trustMigratedContactPages( + true, _legacy_migration_ready)) { + prepareLegacyContactMigration(); + } +#endif + if (file) { + bool full = false; +#if defined(NRF52_PLATFORM) + _legacy_contact_count = mesh::storage::legacyContactCountForSize(file.size()); + uint16_t record_index = 0; +#endif + while (!full +#if defined(NRF52_PLATFORM) + && record_index < _legacy_contact_count +#endif + ) { + uint8_t record[mesh::storage::CONTACT_RECORD_SIZE]; + if (file.read(record, sizeof(record)) != sizeof(record)) break; + + ContactInfo contact; + if (!deserializeContactRecord(record, contact)) { + // Preserve the contact while containing corrupt legacy routing data. + contact.out_path_len = OUT_PATH_UNKNOWN; + } +#if defined(NRF52_PLATFORM) + const uint16_t slot = record_index++; + if (!_contact_slots.reserve(slot)) break; + contact.storage_slot = slot; +#endif + if (!host->onContactLoaded(contact)) { + full = true; +#if defined(NRF52_PLATFORM) + _contact_slots.release(slot); +#endif + } + } + file.close(); + } + +#if defined(NRF52_PLATFORM) + // Pages at and beyond the remaining legacy prefix have already committed. + // Loading both sources this way resumes safely after every possible reset + // point, including a reset after page rename but before legacy truncation. + if (_legacy_migration_ready) { + loadContactPages(host, _legacy_contact_count); + } +#endif +} + +bool DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c)) { +#if defined(NRF52_PLATFORM) + bool success = true; + for (uint32_t idx = 0;; idx++) { + ContactInfo* contact = host->getContactForStore(idx); + if (contact == NULL) break; + if (filter && !filter(*contact)) continue; + success = markContactDirty(*contact) && success; + } + return flushContactWrites(host, filter) && success; +#else File file = openWrite(_getContactsChannelsFS(), "/contacts3"); + bool success = (bool)file; if (file) { uint32_t idx = 0; ContactInfo c; @@ -327,7 +739,7 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn idx++; // advance to next contact continue; } - bool success = (file.write(c.id.pub_key, 32) == 32); + success = (file.write(c.id.pub_key, 32) == 32); success = success && (file.write((uint8_t *)&c.name, 32) == 32); success = success && (file.write(&c.type, 1) == 1); success = success && (file.write(&c.flags, 1) == 1); @@ -346,6 +758,136 @@ void DataStore::saveContacts(DataStoreHost* host, bool (*filter)(const ContactIn } file.close(); } + return success; +#endif +} + +bool DataStore::markContactDirty(const ContactInfo& contact) { +#if defined(NRF52_PLATFORM) + uint16_t slot = contact.storage_slot; + if (!_contact_slots.isUsed(slot)) { + slot = _contact_slots.allocate(); + if (slot == mesh::storage::CONTACT_SLOT_NONE) return false; + contact.storage_slot = slot; + } + return _dirty_contact_pages.mark(slot / mesh::storage::CONTACTS_PER_PAGE); +#else + (void)contact; + return true; +#endif +} + +bool DataStore::releaseContact(const ContactInfo& contact) { +#if defined(NRF52_PLATFORM) + const uint16_t slot = contact.storage_slot; + if (!_contact_slots.release(slot)) return false; + contact.storage_slot = mesh::storage::CONTACT_SLOT_NONE; + return _dirty_contact_pages.mark(slot / mesh::storage::CONTACTS_PER_PAGE); +#else + (void)contact; + return true; +#endif +} + +#if defined(NRF52_PLATFORM) +bool DataStore::truncateLegacyContacts(uint16_t remaining_contacts) { + FILESYSTEM* fs = _getContactsChannelsFS(); + if (remaining_contacts == 0) { + const bool removed = !fs->exists("/contacts3") || fs->remove("/contacts3"); + if (removed) { + if (fs->exists(CONTACT_MIGRATION_MARKER)) { + fs->remove(CONTACT_MIGRATION_MARKER); + } + _legacy_contact_count = 0; + _legacy_contacts_pending_cleanup = false; + _legacy_migration_ready = false; + } + return removed; + } + + File file = fs->open("/contacts3", FILE_O_WRITE); + if (!file) return false; + + const uint32_t expected_size = + (uint32_t)remaining_contacts * mesh::storage::CONTACT_RECORD_SIZE; + const bool truncated = file.truncate(expected_size); + if (truncated) file.flush(); + file.close(); + if (!truncated) return false; + + // Verify the committed length before advancing the in-memory transaction. + // If verification itself fails, retrying the same page/truncate is harmless. + File verify = openRead(fs, "/contacts3"); + const bool valid = verify && verify.size() == expected_size; + if (verify) verify.close(); + if (!valid) return false; + + _legacy_contact_count = remaining_contacts; + return true; +} +#endif + +bool DataStore::serviceContactWrites(DataStoreHost* host, + bool (*filter)(const ContactInfo& c)) { +#if defined(NRF52_PLATFORM) + if (_legacy_contacts_pending_cleanup) { + FILESYSTEM* fs = _getContactsChannelsFS(); + if (!fs->exists("/contacts3")) { + if (fs->exists(CONTACT_MIGRATION_MARKER)) { + fs->remove(CONTACT_MIGRATION_MARKER); + } + _legacy_contacts_pending_cleanup = false; + _legacy_migration_ready = false; + _legacy_contact_count = 0; + } else if (!_legacy_migration_ready + && !prepareLegacyContactMigration()) { + return false; + } else if (_legacy_contact_count == 0) { + return truncateLegacyContacts(0); + } else { + const uint8_t page = + mesh::storage::legacyMigrationPage(_legacy_contact_count); + if (page >= mesh::storage::CONTACT_PAGE_COUNT + || !writeContactPage(host, page, filter)) { + return false; + } + + const uint16_t remaining = + mesh::storage::legacyCountAfterMigratingPage(page); + if (!truncateLegacyContacts(remaining)) return false; + _dirty_contact_pages.clear(page); + return true; + } + } + + const int page = _dirty_contact_pages.first(); + if (page < 0) return true; + if (!writeContactPage(host, (uint8_t)page, filter)) return false; + _dirty_contact_pages.clear((uint8_t)page); + return true; +#else + return saveContacts(host, filter); +#endif +} + +bool DataStore::flushContactWrites(DataStoreHost* host, + bool (*filter)(const ContactInfo& c)) { +#if defined(NRF52_PLATFORM) + while (hasPendingContactWrites()) { + if (!serviceContactWrites(host, filter)) return false; + } + return true; +#else + return saveContacts(host, filter); +#endif +} + +bool DataStore::hasPendingContactWrites() const { +#if defined(NRF52_PLATFORM) + return !_dirty_contact_pages.empty() || _legacy_contacts_pending_cleanup; +#else + return false; +#endif } void DataStore::loadChannels(DataStoreHost* host) { @@ -374,22 +916,31 @@ void DataStore::loadChannels(DataStoreHost* host) { } void DataStore::saveChannels(DataStoreHost* host) { +#if defined(NRF52_PLATFORM) + mesh::AtomicFileWriter file(_getContactsChannelsFS(), "/channels2"); +#else File file = openWrite(_getContactsChannelsFS(), "/channels2"); +#endif if (file) { uint8_t channel_idx = 0; ChannelDetails ch; uint8_t unused[4]; memset(unused, 0, 4); - while (host->getChannelForSave(channel_idx, ch)) { - bool success = (file.write(unused, 4) == 4); + bool success = true; + while (success && host->getChannelForSave(channel_idx, ch)) { + success = (file.write(unused, 4) == 4); success = success && (file.write((uint8_t *)ch.name, 32) == 32); success = success && (file.write((uint8_t *)ch.channel.secret, 32) == 32); if (!success) break; // write failed channel_idx++; } +#if defined(NRF52_PLATFORM) + if (!file.commit(success)) MESH_DEBUG_PRINTLN("DataStore: atomic channels write failed"); +#else file.close(); +#endif } } @@ -404,7 +955,30 @@ struct BlobRec { uint8_t data[MAX_ADVERT_PKT_LEN]; }; +#if !defined(NRF52_PLATFORM) +static void normalizeBlobKey(const uint8_t key[], int key_len, uint8_t normalized[7]) { + memset(normalized, 0, 7); + if (key == NULL || key_len <= 0) return; + if (key_len > 7) key_len = 7; + memcpy(normalized, key, key_len); +} +#endif + void DataStore::checkAdvBlobFile() { +#if defined(NRF52_PLATFORM) + // Advert packets are a disposable cache and are learned again over the air. + // Retaining the old 18 KiB monolithic cache alongside atomic buckets can + // exhaust the 100 KiB ExtraFS and prevent a contact-page commit, so retire + // it once on upgrade. Contact records and identity data are not affected. + if (_fs->exists("/adv_blobs") && !_fs->remove("/adv_blobs")) { + MESH_DEBUG_PRINTLN("DataStore: could not retire internal legacy advert cache"); + } + if (_fsExtra != nullptr && _fsExtra->exists("/adv_blobs") + && !_fsExtra->remove("/adv_blobs")) { + MESH_DEBUG_PRINTLN("DataStore: could not retire secondary legacy advert cache"); + } + return; +#else if (!_getContactsChannelsFS()->exists("/adv_blobs")) { File file = openWrite(_getContactsChannelsFS(), "/adv_blobs"); if (file) { @@ -416,123 +990,309 @@ void DataStore::checkAdvBlobFile() { file.close(); } } +#endif } void DataStore::migrateToSecondaryFS() { - // migrate old adv_blobs, contacts3 and channels2 files to secondary FS if they don't already exist - if (!_fsExtra->exists("/adv_blobs")) { - if (_fs->exists("/adv_blobs")) { - File oldAdvBlobs = openRead(_fs, "/adv_blobs"); - File newAdvBlobs = openWrite(_fsExtra, "/adv_blobs"); + if (_fsExtra == nullptr) return; - if (oldAdvBlobs && newAdvBlobs) { - BlobRec rec; - size_t count = 0; + // Implemented below through verified copy transactions. Source files are + // removed only after the destination has been read back successfully. +#if defined(NRF52_PLATFORM) + // /adv_blobs is a reconstructable cache retired by checkAdvBlobFile(); do + // not spend time and temporary space atomically copying it first. + static const char* to_secondary[] = { + "/contacts3", "/contacts4.mig", "/channels2"}; +#else + static const char* to_secondary[] = {"/adv_blobs", "/contacts3", "/channels2"}; +#endif + static const char* to_primary[] = {"/_main.id", "/new_prefs"}; - // Copy 20 BlobRecs from old to new - while (count < 20 && oldAdvBlobs.read((uint8_t *)&rec, sizeof(rec)) == sizeof(rec)) { - newAdvBlobs.seek(count * sizeof(BlobRec)); - newAdvBlobs.write((uint8_t *)&rec, sizeof(rec)); - count++; + auto filesEqual = [this](FILESYSTEM* left_fs, FILESYSTEM* right_fs, + const char* path) -> bool { + File left = openRead(left_fs, path); + File right = openRead(right_fs, path); + if (!left || !right || left.size() != right.size()) { + if (left) left.close(); + if (right) right.close(); + return false; + } + uint8_t left_buf[64], right_buf[64]; + bool equal = true; + while (equal) { + int left_count = left.read(left_buf, sizeof(left_buf)); + int right_count = right.read(right_buf, sizeof(right_buf)); + if (left_count < 0 || right_count < 0) { + equal = false; + } else if (left_count != right_count) { + equal = false; + } else if (left_count <= 0) { + break; + } else if (memcmp(left_buf, right_buf, left_count) != 0) { + equal = false; } } - if (oldAdvBlobs) oldAdvBlobs.close(); - if (newAdvBlobs) newAdvBlobs.close(); - _fs->remove("/adv_blobs"); + left.close(); + right.close(); + return equal; + }; + + auto migrate = [this, &filesEqual](FILESYSTEM* source_fs, FILESYSTEM* dest_fs, + const char* path) -> bool { + if (!source_fs->exists(path)) return true; + if (dest_fs->exists(path)) { + if (filesEqual(source_fs, dest_fs, path)) { + return source_fs->remove(path); + } + MESH_DEBUG_PRINTLN("DataStore: migration conflict for %s; preserving both copies", path); + return false; } - } - if (!_fsExtra->exists("/contacts3")) { - if (_fs->exists("/contacts3")) { - File oldFile = openRead(_fs, "/contacts3"); - File newFile = openWrite(_fsExtra, "/contacts3"); - if (oldFile && newFile) { - uint8_t buf[64]; - int n; - while ((n = oldFile.read(buf, sizeof(buf))) > 0) { - newFile.write(buf, n); - } + File source = openRead(source_fs, path); + if (!source) return false; + const uint32_t expected_size = source.size(); + bool success = true; + uint8_t buf[64]; + +#if defined(NRF52_PLATFORM) + mesh::AtomicFileWriter destination(dest_fs, path); + success = (bool)destination; + while (success) { + int count = source.read(buf, sizeof(buf)); + if (count < 0) { + success = false; + } else if (count == 0) { + break; + } else { + success = destination.write(buf, count) == (size_t)count; } - if (oldFile) oldFile.close(); - if (newFile) newFile.close(); - _fs->remove("/contacts3"); } - } - if (!_fsExtra->exists("/channels2")) { - if (_fs->exists("/channels2")) { - File oldFile = openRead(_fs, "/channels2"); - File newFile = openWrite(_fsExtra, "/channels2"); - - if (oldFile && newFile) { - uint8_t buf[64]; - int n; - while ((n = oldFile.read(buf, sizeof(buf))) > 0) { - newFile.write(buf, n); - } + source.close(); + success = destination.commit(success && destination.bytesWritten() == expected_size); +#else + char temp_path[64]; + snprintf(temp_path, sizeof(temp_path), "%s.tmp", path); + File destination = openWrite(dest_fs, temp_path); + success = (bool)destination; + uint32_t written = 0; + while (success) { + int count = source.read(buf, sizeof(buf)); + if (count < 0) { + success = false; + } else if (count == 0) { + break; + } else { + success = destination.write(buf, count) == (size_t)count; + written += success ? count : 0; } - if (oldFile) oldFile.close(); - if (newFile) newFile.close(); - _fs->remove("/channels2"); } - } - // cleanup nodes which have been testing the extra fs, copy _main.id and new_prefs back to primary - if (_fsExtra->exists("/_main.id")) { - if (_fs->exists("/_main.id")) {_fs->remove("/_main.id");} - File oldFile = openRead(_fsExtra, "/_main.id"); - File newFile = openWrite(_fs, "/_main.id"); + source.close(); + if (destination) destination.close(); + success = success && written == expected_size; + if (success) { + File verify = openRead(dest_fs, temp_path); + success = verify && verify.size() == expected_size; + if (verify) verify.close(); + } + if (success) success = dest_fs->rename(temp_path, path); + if (!success) dest_fs->remove(temp_path); +#endif - if (oldFile && newFile) { - uint8_t buf[64]; - int n; - while ((n = oldFile.read(buf, sizeof(buf))) > 0) { - newFile.write(buf, n); - } - } - if (oldFile) oldFile.close(); - if (newFile) newFile.close(); - _fsExtra->remove("/_main.id"); - } - if (_fsExtra->exists("/new_prefs")) { - if (_fs->exists("/new_prefs")) {_fs->remove("/new_prefs");} - File oldFile = openRead(_fsExtra, "/new_prefs"); - File newFile = openWrite(_fs, "/new_prefs"); + if (!success || !filesEqual(source_fs, dest_fs, path)) { + MESH_DEBUG_PRINTLN("DataStore: verified migration failed for %s", path); + return false; + } + return source_fs->remove(path); + }; - if (oldFile && newFile) { - uint8_t buf[64]; - int n; - while ((n = oldFile.read(buf, sizeof(buf))) > 0) { - newFile.write(buf, n); - } - } - if (oldFile) oldFile.close(); - if (newFile) newFile.close(); - _fsExtra->remove("/new_prefs"); + for (size_t i = 0; i < sizeof(to_secondary) / sizeof(to_secondary[0]); i++) { + migrate(_fs, _fsExtra, to_secondary[i]); } - // remove files from where they should not be anymore - if (_fs->exists("/adv_blobs")) { - _fs->remove("/adv_blobs"); + // Also move the bounded nRF v4 page/bucket files. This matters after a boot + // where external QSPI was unavailable and the store deliberately fell back + // to internal flash. +#if defined(NRF52_PLATFORM) + for (uint8_t page = 0; page < mesh::storage::CONTACT_PAGE_COUNT; page++) { + char path[24]; + makeContactPagePath(page, path); + migrate(_fs, _fsExtra, path); } - if (_fs->exists("/contacts3")) { - _fs->remove("/contacts3"); + for (uint8_t bucket = 0; bucket < 10; bucket++) { + char path[20]; + snprintf(path, sizeof(path), "/adv4_%02u", (unsigned)bucket); + migrate(_fs, _fsExtra, path); } - if (_fs->exists("/channels2")) { - _fs->remove("/channels2"); - } - if (_fsExtra->exists("/_main.id")) { - _fsExtra->remove("/_main.id"); - } - if (_fsExtra->exists("/new_prefs")) { - _fsExtra->remove("/new_prefs"); +#endif + for (size_t i = 0; i < sizeof(to_primary) / sizeof(to_primary[0]); i++) { + migrate(_fsExtra, _fs, to_primary[i]); } } +#if defined(NRF52_PLATFORM) +// Keep every bucket below one 4 KiB LittleFS block. Five 20-record buckets use +// about the same flash as the old 100-record file; smaller buckets would each +// consume a full block and leave no room for contact-page transactions. +static const uint8_t BLOB_BUCKET_COUNT = MAX_BLOBRECS > 20 ? 5 : 1; +static const uint8_t BLOB_BUCKET_SLOTS = + (MAX_BLOBRECS + BLOB_BUCKET_COUNT - 1) / BLOB_BUCKET_COUNT; +static const uint8_t BLOB_BUCKET_HEADER_SIZE = 16; +static const uint8_t BLOB_BUCKET_MAGIC[4] = {'M', 'C', 'B', '4'}; +static_assert(BLOB_BUCKET_HEADER_SIZE + sizeof(BlobRec) * BLOB_BUCKET_SLOTS < 4096, + "advert bucket must fit in one LittleFS block"); + +static void normalizeBlobKey(const uint8_t key[], int key_len, uint8_t normalized[7]) { + memset(normalized, 0, 7); + if (key == NULL || key_len <= 0) return; + if (key_len > 7) key_len = 7; + memcpy(normalized, key, key_len); +} + +static uint8_t blobBucketFor(const uint8_t key[7]) { + uint32_t hash = 2166136261UL; + for (uint8_t i = 0; i < 7; i++) { + hash ^= key[i]; + hash *= 16777619UL; + } + return hash % BLOB_BUCKET_COUNT; +} + +static void makeBlobBucketPath(uint8_t bucket, char path[20]) { + snprintf(path, 20, "/adv4_%02u", (unsigned)bucket); +} + +static bool loadBlobBucket(FILESYSTEM* fs, uint8_t bucket, + BlobRec records[BLOB_BUCKET_SLOTS]) { + memset(records, 0, sizeof(BlobRec) * BLOB_BUCKET_SLOTS); + char path[20]; + makeBlobBucketPath(bucket, path); + if (!fs->exists(path)) return true; + + File file = fs->open(path, FILE_O_READ); + if (!file) return false; + const size_t payload_size = sizeof(BlobRec) * BLOB_BUCKET_SLOTS; + if (file.size() != BLOB_BUCKET_HEADER_SIZE + payload_size) { + file.close(); + return false; + } + + uint8_t header[BLOB_BUCKET_HEADER_SIZE]; + bool valid = file.read(header, sizeof(header)) == sizeof(header) + && memcmp(header, BLOB_BUCKET_MAGIC, sizeof(BLOB_BUCKET_MAGIC)) == 0 + && header[4] == 1 && header[5] == bucket + && header[6] == BLOB_BUCKET_SLOTS + && mesh::storage::readLE16(&header[8]) == sizeof(BlobRec) + && file.read((uint8_t*)records, payload_size) == (int)payload_size; + file.close(); + if (!valid) return false; + + const uint32_t expected_crc = mesh::storage::readLE32(&header[12]); + const uint32_t actual_crc = mesh::storage::updateCRC32( + 0xFFFFFFFFUL, (const uint8_t*)records, payload_size); + if (actual_crc != expected_crc) return false; + for (uint8_t i = 0; i < BLOB_BUCKET_SLOTS; i++) { + if (records[i].len > MAX_ADVERT_PKT_LEN) return false; + } + return true; +} + +static bool saveBlobBucket(FILESYSTEM* fs, uint8_t bucket, + const BlobRec records[BLOB_BUCKET_SLOTS]) { + const size_t payload_size = sizeof(BlobRec) * BLOB_BUCKET_SLOTS; + uint8_t header[BLOB_BUCKET_HEADER_SIZE]; + memset(header, 0, sizeof(header)); + memcpy(header, BLOB_BUCKET_MAGIC, sizeof(BLOB_BUCKET_MAGIC)); + header[4] = 1; + header[5] = bucket; + header[6] = BLOB_BUCKET_SLOTS; + mesh::storage::writeLE16(&header[8], sizeof(BlobRec)); + mesh::storage::writeLE32(&header[12], mesh::storage::updateCRC32( + 0xFFFFFFFFUL, (const uint8_t*)records, payload_size)); + + char path[20]; + makeBlobBucketPath(bucket, path); + mesh::AtomicFileWriter writer(fs, path); + const bool wrote = writer + && writer.write(header, sizeof(header)) == sizeof(header) + && writer.write((const uint8_t*)records, payload_size) == payload_size; + return writer.commit(wrote); +} + +static bool findBlobInBucket(FILESYSTEM* fs, const uint8_t key[7], + uint8_t dest_buf[], uint8_t& length) { + // Twenty records are roughly 3.6 KiB, too large for the nRF Arduino loop's + // 4 KiB stack once callers are included. Use a short-lived heap buffer. + BlobRec* records = (BlobRec*)malloc(sizeof(BlobRec) * BLOB_BUCKET_SLOTS); + if (records == nullptr) return false; + const uint8_t bucket = blobBucketFor(key); + if (!loadBlobBucket(fs, bucket, records)) { + free(records); + return false; + } + for (uint8_t i = 0; i < BLOB_BUCKET_SLOTS; i++) { + if (memcmp(records[i].key, key, sizeof(records[i].key)) == 0 + && (records[i].timestamp != 0 || records[i].len != 0)) { + length = records[i].len; // zero is an intentional tombstone + if (length > 0) memcpy(dest_buf, records[i].data, length); + free(records); + return true; + } + } + free(records); + return false; +} + +// Once a v4 bucket contains a key, the old monolithic cache entry is no +// longer needed. Clear just that disposable legacy record so an eventually +// evicted bucket/tombstone can never expose stale advert data again. This is +// intentionally a bounded in-place write: atomically rewriting the complete +// legacy cache is the multi-second operation the bucket format avoids. +static bool clearLegacyBlobRecord(FILESYSTEM* fs, const uint8_t key[7]) { + if (!fs->exists("/adv_blobs")) return true; + + File file = fs->open("/adv_blobs", FILE_O_WRITE); + if (!file) return false; + + BlobRec record; + uint32_t position = 0; + bool success = true; + bool found = false; + file.seek(0); + while (file.read((uint8_t*)&record, sizeof(record)) == sizeof(record)) { + if (record.len <= MAX_ADVERT_PKT_LEN + && memcmp(record.key, key, sizeof(record.key)) == 0 + && (record.timestamp != 0 || record.len != 0)) { + found = true; + memset(&record, 0, sizeof(record)); + success = file.seek(position) + && file.write((uint8_t*)&record, sizeof(record)) == sizeof(record); + if (success) file.flush(); + break; + } + position += sizeof(record); + } + file.close(); + return !found || success; +} +#endif + uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_buf[]) { +#if defined(NRF52_PLATFORM) + uint8_t normalized[7], length = 0; + normalizeBlobKey(key, key_len, normalized); + if (findBlobInBucket(_getContactsChannelsFS(), normalized, dest_buf, length)) { + return length; + } +#endif + File file = openRead(_getContactsChannelsFS(), "/adv_blobs"); uint8_t len = 0; // 0 = not found if (file) { BlobRec tmp; while (file.read((uint8_t *) &tmp, sizeof(tmp)) == sizeof(tmp)) { - if (memcmp(key, tmp.key, sizeof(tmp.key)) == 0) { // only match by 7 byte prefix + uint8_t normalized[7]; + normalizeBlobKey(key, key_len, normalized); + if (tmp.len <= MAX_ADVERT_PKT_LEN + && memcmp(normalized, tmp.key, sizeof(tmp.key)) == 0) { // only match by 7 byte prefix len = tmp.len; memcpy(dest_buf, tmp.data, len); break; @@ -545,6 +1305,46 @@ uint8_t DataStore::getBlobByKey(const uint8_t key[], int key_len, uint8_t dest_b bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src_buf[], uint8_t len) { if (len < PUB_KEY_SIZE+4+SIGNATURE_SIZE || len > MAX_ADVERT_PKT_LEN) return false; +#if defined(NRF52_PLATFORM) + uint8_t normalized[7]; + normalizeBlobKey(key, key_len, normalized); + const uint8_t bucket = blobBucketFor(normalized); + BlobRec* records = (BlobRec*)malloc(sizeof(BlobRec) * BLOB_BUCKET_SLOTS); + if (records == nullptr) return false; + if (!loadBlobBucket(_getContactsChannelsFS(), bucket, records)) { + MESH_DEBUG_PRINTLN("DataStore: advert bucket %u corrupt; replacing on next write", bucket); + memset(records, 0, sizeof(BlobRec) * BLOB_BUCKET_SLOTS); + } + + uint8_t selected = 0; + uint32_t oldest = 0xFFFFFFFFUL; + for (uint8_t i = 0; i < BLOB_BUCKET_SLOTS; i++) { + if (memcmp(records[i].key, normalized, sizeof(records[i].key)) == 0 + && (records[i].timestamp != 0 || records[i].len != 0)) { + selected = i; + break; + } + if (records[i].timestamp < oldest) { + oldest = records[i].timestamp; + selected = i; + } + } + BlobRec& record = records[selected]; + memset(&record, 0, sizeof(record)); + memcpy(record.key, normalized, sizeof(record.key)); + memcpy(record.data, src_buf, len); + record.len = len; + record.timestamp = _clock->getCurrentTime(); + if (record.timestamp == 0) record.timestamp = 1; + const bool saved = saveBlobBucket(_getContactsChannelsFS(), bucket, records); + free(records); + if (!saved) return false; + if (!clearLegacyBlobRecord(_getContactsChannelsFS(), normalized)) { + MESH_DEBUG_PRINTLN("DataStore: could not retire legacy advert record"); + return false; + } + return true; +#else checkAdvBlobFile(); File file = _getContactsChannelsFS()->open("/adv_blobs", FILE_O_WRITE); if (file) { @@ -579,9 +1379,48 @@ bool DataStore::putBlobByKey(const uint8_t key[], int key_len, const uint8_t src return true; } return false; // error +#endif } bool DataStore::deleteBlobByKey(const uint8_t key[], int key_len) { +#if defined(NRF52_PLATFORM) + uint8_t normalized[7]; + normalizeBlobKey(key, key_len, normalized); + const uint8_t bucket = blobBucketFor(normalized); + BlobRec* records = (BlobRec*)malloc(sizeof(BlobRec) * BLOB_BUCKET_SLOTS); + if (records == nullptr) return false; + if (!loadBlobBucket(_getContactsChannelsFS(), bucket, records)) { + memset(records, 0, sizeof(BlobRec) * BLOB_BUCKET_SLOTS); + } + + uint8_t selected = 0; + uint32_t oldest = 0xFFFFFFFFUL; + for (uint8_t i = 0; i < BLOB_BUCKET_SLOTS; i++) { + if (memcmp(records[i].key, normalized, sizeof(records[i].key)) == 0 + && (records[i].timestamp != 0 || records[i].len != 0)) { + selected = i; + break; + } + if (records[i].timestamp < oldest) { + oldest = records[i].timestamp; + selected = i; + } + } + BlobRec& tombstone = records[selected]; + memset(&tombstone, 0, sizeof(tombstone)); + memcpy(tombstone.key, normalized, sizeof(tombstone.key)); + tombstone.timestamp = _clock->getCurrentTime(); + if (tombstone.timestamp == 0) tombstone.timestamp = 1; + const bool saved = saveBlobBucket(_getContactsChannelsFS(), bucket, records); + free(records); + if (!saved) return false; + if (!clearLegacyBlobRecord(_getContactsChannelsFS(), normalized)) { + MESH_DEBUG_PRINTLN("DataStore: could not clear deleted legacy advert record"); + return false; + } + return true; +#else return true; // this is just a stub on NRF52/STM32 platforms +#endif } #else inline void makeBlobPath(const uint8_t key[], int key_len, char* path, size_t path_size) { diff --git a/examples/companion_radio/DataStore.h b/examples/companion_radio/DataStore.h index af5ee7af..2e548d64 100644 --- a/examples/companion_radio/DataStore.h +++ b/examples/companion_radio/DataStore.h @@ -3,12 +3,14 @@ #include #include #include +#include #include "NodePrefs.h" class DataStoreHost { public: virtual bool onContactLoaded(const ContactInfo& contact) =0; virtual bool getContactForSave(uint32_t idx, ContactInfo& contact) =0; + virtual ContactInfo* getContactForStore(uint32_t idx) =0; virtual bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) =0; virtual bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) =0; }; @@ -19,6 +21,22 @@ class DataStore { mesh::RTCClock* _clock; IdentityStore identity_store; +#if defined(NRF52_PLATFORM) + mesh::storage::ContactSlotMap _contact_slots; + mesh::storage::DirtyPageSet _dirty_contact_pages; + uint32_t _contact_page_generations[mesh::storage::CONTACT_PAGE_COUNT]; + bool _legacy_contacts_pending_cleanup; + bool _legacy_migration_ready; + uint16_t _legacy_contact_count; + + bool prepareLegacyContactMigration(); + bool loadContactPages(DataStoreHost* host, uint16_t minimum_slot = 0); + bool writeContactPage(DataStoreHost* host, uint8_t page, + bool (*filter)(const ContactInfo& c)); + bool truncateLegacyContacts(uint16_t remaining_contacts); + void resetContactPageState(); +#endif + void loadPrefsInt(const char *filename, NodePrefs& prefs, double& node_lat, double& node_lon); #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) void checkAdvBlobFile(); @@ -31,12 +49,18 @@ public: bool formatFileSystem(); FILESYSTEM* getPrimaryFS() const { return _fs; } FILESYSTEM* getSecondaryFS() const { return _fsExtra; } + void disableSecondaryFS() { _fsExtra = nullptr; } bool loadMainIdentity(mesh::LocalIdentity &identity); bool saveMainIdentity(const mesh::LocalIdentity &identity); void loadPrefs(NodePrefs& prefs, double& node_lat, double& node_lon); - void savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); + bool savePrefs(const NodePrefs& prefs, double node_lat, double node_lon); void loadContacts(DataStoreHost* host); - void saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL); + bool saveContacts(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL); + bool markContactDirty(const ContactInfo& contact); + bool releaseContact(const ContactInfo& contact); + bool serviceContactWrites(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL); + bool flushContactWrites(DataStoreHost* host, bool (*filter)(const ContactInfo& c) = NULL); + bool hasPendingContactWrites() const; void loadChannels(DataStoreHost* host); void saveChannels(DataStoreHost* host); void migrateToSecondaryFS(); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 8c5b2172..1e4bf99a 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -146,8 +146,11 @@ static constexpr uint8_t DEFAULT_FEM_RX_GAIN = 1; #define DIRECT_SEND_PERHOP_FACTOR 6.0f #define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250 #define LAZY_CONTACTS_WRITE_DELAY 5000 +#define CONTACT_PAGE_WRITE_GAP 100 #define EXPECTED_ACK_RETRY_RECHECK_MILLIS 1000 +static bool save_filter(const ContactInfo& c); + #ifndef DEFAULT_MULTI_ACKS #define DEFAULT_MULTI_ACKS 0 #endif @@ -507,6 +510,8 @@ uint8_t MyMesh::getAutoAddMaxHops() const { } void MyMesh::onContactOverwrite(const uint8_t* pub_key) { + ContactInfo* contact = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + if (contact) scheduleContactWriteAfterRelease(*contact); _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); // delete from storage if (_serial->isConnected()) { out_frame[0] = PUSH_CODE_CONTACT_DELETED; @@ -558,7 +563,8 @@ void MyMesh::onDiscoveredContact(ContactInfo &contact, bool is_new, uint8_t path p->path_len = mesh::Packet::copyPath(p->path, path, path_len); } - if (!is_new) dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // only schedule lazy write for contacts that are in contacts[] + ContactInfo* stored = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE); + if (stored == &contact) scheduleContactWrite(contact); updateGpsTelemetryPolicy(); } @@ -581,7 +587,7 @@ void MyMesh::onContactPathUpdated(const ContactInfo &contact) { memcpy(&out_frame[1], contact.id.pub_key, PUB_KEY_SIZE); _serial->writeFrame(out_frame, 1 + PUB_KEY_SIZE); // NOTE: app may not be connected - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + scheduleContactWrite(contact); } void MyMesh::clearExpectedAck(AckTableEntry& entry, bool cancel_retries) { @@ -785,6 +791,8 @@ bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk void MyMesh::onMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp, const char *text) { markConnectionActive(from); // in case this is from a server, and we have a connection + // BaseChatMesh updates lastmod immediately before this callback. + scheduleContactWrite(from); queueMessage(from, TXT_TYPE_PLAIN, pkt, sender_timestamp, NULL, 0, text); } @@ -798,7 +806,7 @@ void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uin const uint8_t *sender_prefix, const char *text) { markConnectionActive(from); // from.sync_since change needs to be persisted - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + scheduleContactWrite(from); queueMessage(from, TXT_TYPE_SIGNED_PLAIN, pkt, sender_timestamp, sender_prefix, 4, text); } @@ -1289,6 +1297,9 @@ void MyMesh::begin(bool has_display) { resetContacts(); _store->loadContacts(this); + if (_store->hasPendingContactWrites()) { + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + } updateGpsTelemetryPolicy(); bootstrapRTCfromContacts(); addChannel("Public", PUBLIC_GROUP_PSK); // pre-configure Andy's public channel @@ -2238,7 +2249,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (recipient) { recipient->out_path_len = OUT_PATH_UNKNOWN; // recipient->lastmod = ?? shouldn't be needed, app already has this version of contact - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + scheduleContactWrite(*recipient); writeOKFrame(); } else { writeErrFrame(ERR_CODE_NOT_FOUND); // unknown contact @@ -2250,7 +2261,7 @@ void MyMesh::handleCmdFrame(size_t len) { if (recipient) { updateContactFromFrame(*recipient, last_mod, cmd_frame, len); recipient->lastmod = last_mod; - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + scheduleContactWrite(*recipient); updateGpsTelemetryPolicy(); writeOKFrame(); } else { @@ -2259,7 +2270,8 @@ void MyMesh::handleCmdFrame(size_t len) { contact.lastmod = last_mod; contact.sync_since = 0; if (addContact(contact)) { - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + ContactInfo* added = lookupContactByPubKey(contact.id.pub_key, PUB_KEY_SIZE); + if (added) scheduleContactWrite(*added); updateGpsTelemetryPolicy(); writeOKFrame(); } else { @@ -2269,9 +2281,11 @@ void MyMesh::handleCmdFrame(size_t len) { } else if (cmd_frame[0] == CMD_REMOVE_CONTACT && len >= 1 + PUB_KEY_SIZE) { uint8_t *pub_key = &cmd_frame[1]; ContactInfo *recipient = lookupContactByPubKey(pub_key, PUB_KEY_SIZE); + ContactInfo removed; + if (recipient) removed = *recipient; if (recipient && removeContact(*recipient)) { + scheduleContactWriteAfterRelease(removed); _store->deleteBlobByKey(pub_key, PUB_KEY_SIZE); - dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); updateGpsTelemetryPolicy(); writeOKFrame(); } else { @@ -2469,8 +2483,14 @@ void MyMesh::handleCmdFrame(size_t len) { writeOKFrame(); } } else if (cmd_frame[0] == CMD_REBOOT && len >= 7 && memcmp(&cmd_frame[1], "reboot", 6) == 0) { - if (dirty_contacts_expiry) { // is there are pending dirty contacts write needed? - saveContacts(); + // Non-nRF stores use the legacy monolithic file and therefore do not + // report dirty pages. The lazy-write timer is still proof that RAM holds + // newer contact data which must be persisted before rebooting. + if (dirty_contacts_expiry || _store->hasPendingContactWrites()) { + if (!_store->flushContactWrites(this, save_filter)) { + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + return; + } } board.reboot(); } else if (cmd_frame[0] == CMD_GET_BATT_AND_STORAGE) { @@ -2559,6 +2579,9 @@ void MyMesh::handleCmdFrame(size_t len) { ContactInfo anon; if (recipient == NULL) { // FIRMWARE_VER_CODE 13+, allow non-contact requests memset(&anon, 0, sizeof(anon)); +#if defined(NRF52_PLATFORM) + anon.storage_slot = mesh::storage::CONTACT_SLOT_NONE; +#endif memcpy(anon.id.pub_key, pub_key, PUB_KEY_SIZE); anon.out_path_len = 0; // default to zero-hop direct anon.type = ADV_TYPE_NONE; // unknown @@ -3060,7 +3083,21 @@ static bool save_filter(const ContactInfo& c) { } void MyMesh::saveContacts() { - _store->saveContacts(this, save_filter); + const bool success = _store->saveContacts(this, save_filter); + dirty_contacts_expiry = (!success || _store->hasPendingContactWrites()) + ? futureMillis(1000) : 0; +} + +void MyMesh::scheduleContactWrite(const ContactInfo& contact) { + if (_store->markContactDirty(contact)) { + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + } +} + +void MyMesh::scheduleContactWriteAfterRelease(const ContactInfo& contact) { + if (_store->releaseContact(contact)) { + dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); + } } void MyMesh::enterCLIRescue() { @@ -3310,8 +3347,12 @@ void MyMesh::loop() { // is there are pending dirty contacts write needed? if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) { - saveContacts(); - dirty_contacts_expiry = 0; + const bool success = _store->serviceContactWrites(this, save_filter); + if (!success || _store->hasPendingContactWrites()) { + dirty_contacts_expiry = futureMillis(success ? CONTACT_PAGE_WRITE_GAP : 1000); + } else { + dirty_contacts_expiry = 0; + } } #ifdef DISPLAY_CLASS diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 58636b50..97b91c0f 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -199,6 +199,7 @@ protected: // DataStoreHost methods bool onContactLoaded(const ContactInfo& contact) override { return addContact(contact); } bool getContactForSave(uint32_t idx, ContactInfo& contact) override { return getContactByIdx(idx, contact); } + ContactInfo* getContactForStore(uint32_t idx) override { return getContactPtrByIdx(idx); } bool onChannelLoaded(uint8_t channel_idx, const ChannelDetails& ch) override { return setChannel(channel_idx, ch); } bool getChannelForSave(uint8_t channel_idx, ChannelDetails& ch) override { return getChannel(channel_idx, ch); } @@ -252,6 +253,8 @@ private: // helpers, short-cuts void saveChannels() { _store->saveChannels(this); } void saveContacts(); + void scheduleContactWrite(const ContactInfo& contact); + void scheduleContactWriteAfterRelease(const ContactInfo& contact); DataStore* _store; NodePrefs _prefs; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 091b14a0..58005f58 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -310,6 +310,16 @@ void setup() { if (!QSPIFlash.begin()) { // debug output might not be available at this point, might be too early. maybe should fall back to InternalFS here? MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: failed to initialize"); +#if defined(NRF52_PLATFORM) + // A failed nrfx QSPI init can leave its IRQ pending/enabled. That IRQ + // storm starves BLE and the main loop even though this build can fall + // back to internal storage. Fully release the peripheral on failure. + NVIC_DisableIRQ(QSPI_IRQn); + NVIC_ClearPendingIRQ(QSPI_IRQn); + NRF_QSPI->TASKS_DEACTIVATE = 1; + NRF_QSPI->ENABLE = QSPI_ENABLE_ENABLE_Disabled; +#endif + store.disableSecondaryFS(); } else { MESH_DEBUG_PRINTLN("CustomLFS_QSPIFlash: initialized successfully"); } diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index 9e12913e..7b4b52ec 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -16,6 +16,13 @@ namespace mesh { #define NOISE_FLOOR_CALIB_INTERVAL 2000 // refresh every 2 seconds #endif +#ifndef RADIO_LIVENESS_SOFT_MS + #define RADIO_LIVENESS_SOFT_MS (30UL * 60UL * 1000UL) +#endif +#ifndef RADIO_LIVENESS_HARD_MS + #define RADIO_LIVENESS_HARD_MS (12UL * 60UL * 60UL * 1000UL) +#endif + void Dispatcher::begin() { n_sent_flood = n_sent_direct = 0; n_recv_flood = n_recv_direct = 0; @@ -29,12 +36,11 @@ void Dispatcher::begin() { _radio->begin(); prev_isrecv_mode = _radio->isInRecvMode(); -#ifdef WITH_MQTT_BRIDGE - // Use begin() as the watchdog baseline even if the radio never reports an - // RX/IRQ timestamp. This lets a radio that is dead from startup recover. last_radio_active_ms = _ms->getMillis(); last_watchdog_recovery = last_radio_active_ms; -#endif + last_observed_radio_irq = _radio->getLastRadioInterruptMillis(); + radio_liveness.begin(last_radio_active_ms); + nonrx_soft_recovery_attempted = false; } float Dispatcher::getAirtimeBudgetFactor() const { @@ -130,43 +136,67 @@ void Dispatcher::loop() { } _radio->loop(); + const unsigned long now = _ms->getMillis(); + const unsigned long latest_irq = _radio->getLastRadioInterruptMillis(); + if (latest_irq != 0 && latest_irq != last_observed_radio_irq) { + last_observed_radio_irq = latest_irq; + last_radio_active_ms = now; + radio_liveness.noteActivity(now); + } + // check for radio 'stuck' in mode other than Rx bool is_recv = _radio->isInRecvMode(); if (is_recv != prev_isrecv_mode) { prev_isrecv_mode = is_recv; if (!is_recv) { - radio_nonrx_start = _ms->getMillis(); + radio_nonrx_start = now; + } else { + nonrx_soft_recovery_attempted = false; } } - if (!is_recv && _ms->getMillis() - radio_nonrx_start > 8000) { // radio has not been in Rx mode for 8 seconds! + bool recovered_this_loop = false; + if (!is_recv && outbound == NULL && now - radio_nonrx_start > 8000) { _err_flags |= ERR_EVENT_STARTRX_TIMEOUT; + const bool hard = nonrx_soft_recovery_attempted; + MESH_DEBUG_PRINTLN("Radio watchdog: radio outside RX for %lu ms; %s recovery", + now - radio_nonrx_start, hard ? "hard" : "soft"); + if (_radio->recoverRadio(hard)) { + last_watchdog_recovery = now; + recovered_this_loop = true; + } + nonrx_soft_recovery_attempted = true; + radio_nonrx_start = now; // bounded retry cadence if recovery is unsupported } - // Radio watchdog: detect radio stuck in RX mode but not receiving any packets. - // Observer-only feature (gated behind WITH_MQTT_BRIDGE); configured via the - // MQTTPrefs radio_watchdog_minutes setting. + // Continuous-RX can fail while the MCU and main loop remain healthy, so a + // CPU watchdog cannot detect it. Use packet/IRQ/TX activity as proof of + // radio life and stage recovery from a harmless re-arm to a radio-only + // hardware reset when the driver supports one. Observer configuration may + // request a faster soft check. + uint32_t soft_liveness_ms = RADIO_LIVENESS_SOFT_MS; #ifdef WITH_MQTT_BRIDGE - { - const uint32_t watchdog_ms = getRadioWatchdogMillis(); - if (watchdog_ms > 0) { - const unsigned long now = _ms->getMillis(); - unsigned long silent_ms = now - last_radio_active_ms; - const unsigned long last_recv = _radio->getLastRecvMillis(); - const unsigned long last_irq = _radio->getLastRadioInterruptMillis(); - if (last_recv != 0 && now - last_recv < silent_ms) silent_ms = now - last_recv; - if (last_irq != 0 && now - last_irq < silent_ms) silent_ms = now - last_irq; - const unsigned long since_recovery = now - last_watchdog_recovery; - if (is_recv && silent_ms >= watchdog_ms && since_recovery >= watchdog_ms) { - _err_flags |= ERR_EVENT_RADIO_WATCHDOG; - MESH_DEBUG_PRINTLN("Radio watchdog: silent %lu ms, state=%d, recovering", silent_ms, _radio->getRadioState()); - _radio->idle(); - _radio->startRecv(); - last_watchdog_recovery = now; - last_radio_active_ms = now; - } + const uint32_t configured_watchdog_ms = getRadioWatchdogMillis(); + if (configured_watchdog_ms > 0) soft_liveness_ms = configured_watchdog_ms; +#endif + uint32_t hard_liveness_ms = RADIO_LIVENESS_HARD_MS; + if (soft_liveness_ms > hard_liveness_ms / 2) { + hard_liveness_ms = soft_liveness_ms <= 0x7FFFFFFFUL + ? soft_liveness_ms * 2 : 0xFFFFFFFFUL; + } + if (is_recv && outbound == NULL && !recovered_this_loop) { + const RadioRecoveryAction action = radio_liveness.poll( + now, soft_liveness_ms, hard_liveness_ms); + if (action != RadioRecoveryAction::NONE) { + const bool hard = action == RadioRecoveryAction::HARD; + _err_flags |= ERR_EVENT_RADIO_WATCHDOG; + MESH_DEBUG_PRINTLN("Radio watchdog: no hardware activity for %lu ms, state=%d, %s recovery", + now - last_radio_active_ms, _radio->getRadioState(), + hard ? "hard" : "soft"); + const bool recovered = _radio->recoverRadio(hard); + if (recovered) last_watchdog_recovery = now; + if (hard) radio_liveness.noteHardRecoveryResult(now, recovered); } } -#endif // WITH_MQTT_BRIDGE (radio watchdog) if (outbound) { // waiting for outbound send to be completed if (_radio->isSendComplete()) { @@ -192,6 +222,7 @@ void Dispatcher::loop() { _radio->onSendFinished(); last_radio_active_ms = _ms->getMillis(); // TX success → radio is alive + radio_liveness.noteActivity(last_radio_active_ms); restoreOutboundTxOverrides(); logTx(outbound, 2 + outbound->getPathByteLen() + outbound->payload_len); onSendComplete(outbound); @@ -317,6 +348,8 @@ void Dispatcher::checkRecv() { uint8_t raw[MAX_TRANS_UNIT+1]; int len = _radio->recvRaw(raw, MAX_TRANS_UNIT); if (len > 0) { + last_radio_active_ms = _ms->getMillis(); + radio_liveness.noteActivity(last_radio_active_ms); logRxRaw(_radio->getLastSNR(), _radio->getLastRSSI(), raw, len); pkt = _mgr->allocNew(); diff --git a/src/Dispatcher.h b/src/Dispatcher.h index ebaad5ad..cc31cbd2 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -4,6 +4,7 @@ #include #include #include +#include #include namespace mesh { @@ -78,6 +79,17 @@ public: virtual void idle() { } virtual void startRecv() { } + // Recover a radio whose MCU driver is still running but whose RF state has + // stopped making progress. hard=false is a safe receive/AGC re-arm; + // hard=true may reset only the radio peripheral. The default remains useful + // for simple radios that do not expose a dedicated reset. + virtual bool recoverRadio(bool hard) { + (void)hard; + idle(); + startRecv(); + return true; + } + virtual int getNoiseFloor() const { return 0; } virtual void triggerNoiseFloorCalibrate(int threshold) { } @@ -210,6 +222,8 @@ class Dispatcher { unsigned long outbound_expiry, outbound_start, total_air_time, rx_air_time; unsigned long last_watchdog_recovery; unsigned long last_radio_active_ms; // updated on any TX or RX event; used by watchdog + unsigned long last_observed_radio_irq; + RadioLivenessTracker radio_liveness; uint16_t outbound_restore_preamble_len; uint8_t outbound_restore_cr; unsigned long next_tx_time; @@ -217,6 +231,7 @@ class Dispatcher { unsigned long radio_nonrx_start; unsigned long next_floor_calib_time, next_agc_reset_time; bool prev_isrecv_mode; + bool nonrx_soft_recovery_attempted; uint32_t n_sent_flood, n_sent_direct; uint32_t n_recv_flood, n_recv_direct; unsigned long tx_budget_ms; @@ -252,6 +267,8 @@ protected: duty_cycle_window_ms = 3600000; last_watchdog_recovery = 0; last_radio_active_ms = 0; + last_observed_radio_irq = 0; + nonrx_soft_recovery_attempted = false; } virtual DispatcherAction onRecvPacket(Packet* pkt) = 0; diff --git a/src/Identity.cpp b/src/Identity.cpp index e7539028..b104050e 100644 --- a/src/Identity.cpp +++ b/src/Identity.cpp @@ -111,7 +111,7 @@ void LocalIdentity::printTo(Stream& s) const { s.print("prv_key: "); Utils::printHex(s, prv_key, PRV_KEY_SIZE); s.println(); } -size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) { +size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) const { if (max_len < PRV_KEY_SIZE) return 0; // not big enough if (max_len < PRV_KEY_SIZE + PUB_KEY_SIZE) { // only room for prv_key diff --git a/src/Identity.h b/src/Identity.h index 008f7b5b..f52d751b 100644 --- a/src/Identity.h +++ b/src/Identity.h @@ -90,9 +90,8 @@ public: bool readFrom(Stream& s); bool writeTo(Stream& s) const; void printTo(Stream& s) const; - size_t writeTo(uint8_t* dest, size_t max_len); + size_t writeTo(uint8_t* dest, size_t max_len) const; void readFrom(const uint8_t* src, size_t len); }; } - diff --git a/src/MeshCore.h b/src/MeshCore.h index 6b93ace9..2ec8aa19 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -110,6 +110,8 @@ public: virtual const char* getResetReasonString(uint32_t reason) { return "Not available"; } virtual uint8_t getShutdownReason() const { return 0; } virtual const char* getShutdownReasonString(uint8_t reason) { return "Not available"; } + virtual bool isPowerManagementInitialized() const { return false; } + virtual bool supportsVoltageWake() const { return false; } inline static uint32_t n_cad_busy = 0; }; diff --git a/src/helpers/BaseChatMesh.cpp b/src/helpers/BaseChatMesh.cpp index 2bdfabb7..a0d0b3cf 100644 --- a/src/helpers/BaseChatMesh.cpp +++ b/src/helpers/BaseChatMesh.cpp @@ -105,6 +105,9 @@ ContactInfo* BaseChatMesh::allocateContactSlot(bool transient_only) { void BaseChatMesh::populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp) { memset(&ci, 0, sizeof(ci)); +#if defined(NRF52_PLATFORM) + ci.storage_slot = mesh::storage::CONTACT_SLOT_NONE; +#endif ci.id = id; ci.out_path_len = OUT_PATH_UNKNOWN; StrHelper::strncpy(ci.name, parser.getName(), sizeof(ci.name)); @@ -148,6 +151,9 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, if (from && from->type == ADV_TYPE_NONE) { // already in contacts, but from a temporary ANON_REQ ? memset(from, 0, sizeof(*from)); // clear the anon/temp slot +#if defined(NRF52_PLATFORM) + from->storage_slot = mesh::storage::CONTACT_SLOT_NONE; +#endif from = NULL; // do normal 'add' flow } @@ -182,6 +188,7 @@ void BaseChatMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, populateContactFromAdvert(*from, id, parser, timestamp); from->sync_since = 0; from->shared_secret_valid = false; + is_new = true; } // update diff --git a/src/helpers/BaseChatMesh.h b/src/helpers/BaseChatMesh.h index 8ba572b1..e8aa975c 100644 --- a/src/helpers/BaseChatMesh.h +++ b/src/helpers/BaseChatMesh.h @@ -96,6 +96,11 @@ protected: void resetContacts() { memset(contacts, 0, sizeof(contacts[0])*MAX_ANON_CONTACTS); // set all to have type = ADV_TYPE_NONE(0) +#if defined(NRF52_PLATFORM) + for (int i = 0; i < MAX_ANON_CONTACTS; i++) { + contacts[i].storage_slot = mesh::storage::CONTACT_SLOT_NONE; + } +#endif num_contacts = MAX_ANON_CONTACTS; // seed the first contacts for anon requests } void populateContactFromAdvert(ContactInfo& ci, const mesh::Identity& id, const AdvertDataParser& parser, uint32_t timestamp); @@ -177,6 +182,9 @@ public: int getTotalContactSlots() const { return num_contacts; } int getNumContacts() const { return num_contacts - MAX_ANON_CONTACTS; } // don't include the reserved slots at start bool getContactByIdx(uint32_t idx, ContactInfo& contact); + ContactInfo* getContactPtrByIdx(uint32_t idx) { + return idx < (uint32_t)num_contacts ? &contacts[idx] : NULL; + } ContactsIterator startContactsIterator(); ChannelDetails* addChannel(const char* name, const char* psk_base64); bool getChannel(int idx, ChannelDetails& dest); diff --git a/src/helpers/ContactInfo.h b/src/helpers/ContactInfo.h index ede977ca..2ab36ffb 100644 --- a/src/helpers/ContactInfo.h +++ b/src/helpers/ContactInfo.h @@ -2,6 +2,9 @@ #include #include +#if defined(NRF52_PLATFORM) +#include "PersistentStoreFormat.h" +#endif #define OUT_PATH_UNKNOWN 0xFF @@ -18,6 +21,13 @@ struct ContactInfo { int32_t gps_lat, gps_lon; // 6 dec places uint32_t sync_since; + // Runtime-only position in the paged companion contact store. It is not + // part of the on-disk record; the record's page/slot supplies it on load. + // Mutable keeps persistence bookkeeping out of contact protocol semantics. +#if defined(NRF52_PLATFORM) + mutable uint16_t storage_slot = mesh::storage::CONTACT_SLOT_NONE; +#endif + const uint8_t* getSharedSecret(const mesh::LocalIdentity& self_id) const { if (!shared_secret_valid) { self_id.calcSharedSecret(shared_secret, id.pub_key); diff --git a/src/helpers/IdentityStore.cpp b/src/helpers/IdentityStore.cpp index dc85d69c..44ce79ae 100644 --- a/src/helpers/IdentityStore.cpp +++ b/src/helpers/IdentityStore.cpp @@ -1,5 +1,9 @@ #include "IdentityStore.h" +#if defined(NRF52_PLATFORM) +#include "AtomicFileWriter.h" +#endif + bool IdentityStore::load(const char *name, mesh::LocalIdentity& id) { bool loaded = false; char filename[40]; @@ -46,7 +50,20 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { char filename[40]; sprintf(filename, "%s/%s.id", _dir, name); -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) + uint8_t key_data[PRV_KEY_SIZE + PUB_KEY_SIZE]; + if (id.writeTo(key_data, sizeof(key_data)) != sizeof(key_data)) return false; + + // LocalIdentity's byte-buffer export is private-key then public-key, while + // the historical file format is public-key then private-key. + mesh::AtomicFileWriter writer(_fs, filename); + const bool wrote = writer + && writer.write(&key_data[PRV_KEY_SIZE], PUB_KEY_SIZE) == PUB_KEY_SIZE + && writer.write(key_data, PRV_KEY_SIZE) == PRV_KEY_SIZE; + const bool success = writer.commit(wrote); + MESH_DEBUG_PRINTLN("IdentityStore::save() atomic write - %s", success ? "OK" : "Err"); + return success; +#elif defined(STM32_PLATFORM) _fs->remove(filename); File file = _fs->open(filename, FILE_O_WRITE); #elif defined(RP2040_PLATFORM) @@ -54,12 +71,14 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id) { #else File file = _fs->open(filename, "w", true); #endif +#if !defined(NRF52_PLATFORM) if (file) { bool success = id.writeTo(file); file.close(); MESH_DEBUG_PRINTLN("IdentityStore::save() write - %s", success ? "OK" : "Err"); - return true; + return success; } +#endif MESH_DEBUG_PRINTLN("IdentityStore::save() failed"); return false; } @@ -68,7 +87,22 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const char filename[40]; sprintf(filename, "%s/%s.id", _dir, name); -#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) +#if defined(NRF52_PLATFORM) + uint8_t key_data[PRV_KEY_SIZE + PUB_KEY_SIZE]; + if (id.writeTo(key_data, sizeof(key_data)) != sizeof(key_data)) return false; + uint8_t display_data[32]; + memset(display_data, 0, sizeof(display_data)); + size_t display_len = strlen(display_name); + if (display_len > sizeof(display_data) - 1) display_len = sizeof(display_data) - 1; + memcpy(display_data, display_name, display_len); + + mesh::AtomicFileWriter writer(_fs, filename); + const bool wrote = writer + && writer.write(&key_data[PRV_KEY_SIZE], PUB_KEY_SIZE) == PUB_KEY_SIZE + && writer.write(key_data, PRV_KEY_SIZE) == PRV_KEY_SIZE + && writer.write(display_data, sizeof(display_data)) == sizeof(display_data); + return writer.commit(wrote); +#elif defined(STM32_PLATFORM) _fs->remove(filename); File file = _fs->open(filename, FILE_O_WRITE); #elif defined(RP2040_PLATFORM) @@ -76,18 +110,20 @@ bool IdentityStore::save(const char *name, const mesh::LocalIdentity& id, const #else File file = _fs->open(filename, "w", true); #endif +#if !defined(NRF52_PLATFORM) if (file) { - id.writeTo(file); + bool success = id.writeTo(file); uint8_t tmp[32]; memset(tmp, 0, sizeof(tmp)); int n = strlen(display_name); if (n > sizeof(tmp)-1) n = sizeof(tmp)-1; memcpy(tmp, display_name, n); - file.write(tmp, sizeof(tmp)); + success = success && file.write(tmp, sizeof(tmp)) == sizeof(tmp); file.close(); - return true; + return success; } +#endif return false; } diff --git a/src/helpers/NRF52Board.cpp b/src/helpers/NRF52Board.cpp index b8140002..85816c02 100644 --- a/src/helpers/NRF52Board.cpp +++ b/src/helpers/NRF52Board.cpp @@ -1,5 +1,6 @@ #if defined(NRF52_PLATFORM) #include "NRF52Board.h" +#include "PowerManagementUtils.h" #include #include @@ -104,6 +105,8 @@ static void __attribute__((constructor(101))) nrf52_early_reset_capture() { } void NRF52Board::initPowerMgr() { + if (power_mgr_initialized) return; + // Copy early-captured register values reset_reason = g_nrf52_reset_reason; shutdown_reason = g_nrf52_shutdown_reason; @@ -130,6 +133,7 @@ void NRF52Board::initPowerMgr() { MESH_DEBUG_PRINTLN("PWRMGT: Reset = %s (0x%lX)", getResetReasonString(reset_reason), (unsigned long)reset_reason); } + power_mgr_initialized = true; } const char* NRF52Board::getResetReasonString(uint32_t reason) { @@ -154,6 +158,7 @@ const char* NRF52Board::getResetReasonString(uint32_t reason) { const char* NRF52Board::getShutdownReasonString(uint8_t reason) { switch (reason) { + case SHUTDOWN_REASON_NONE: return "None"; case SHUTDOWN_REASON_LOW_VOLTAGE: return "Low Voltage"; case SHUTDOWN_REASON_USER: return "User Request"; case SHUTDOWN_REASON_BOOT_PROTECT: return "Boot Protection"; @@ -164,15 +169,22 @@ const char* NRF52Board::getShutdownReasonString(uint8_t reason) { bool NRF52Board::checkBootVoltage(const PowerMgtConfig* config) { initPowerMgr(); - // Read boot voltage - boot_voltage_mv = getBattMilliVolts(); + if (config == nullptr) return true; + + // Use the median of three readings. A single unsettled ADC sample during a + // brownout must not put the device into a persistent SYSTEMOFF boot lock. + uint16_t samples[3]; + for (uint8_t i = 0; i < 3; i++) { + samples[i] = getBattMilliVolts(); + if (i != 2) delay(5); + } + boot_voltage_mv = mesh::power::medianVoltage(samples[0], samples[1], samples[2]); if (config->voltage_bootlock == 0) return true; // Protection disabled // Skip check if externally powered if (isExternalPowered()) { MESH_DEBUG_PRINTLN("PWRMGT: Boot check skipped (external power)"); - boot_voltage_mv = getBattMilliVolts(); return true; } @@ -181,7 +193,7 @@ bool NRF52Board::checkBootVoltage(const PowerMgtConfig* config) { // Only trigger shutdown if reading is valid (>1000mV) AND below threshold // This prevents spurious shutdowns on ADC glitches or uninitialized reads - if (boot_voltage_mv > 1000 && boot_voltage_mv < config->voltage_bootlock) { + if (mesh::power::shouldBootLock(boot_voltage_mv, config->voltage_bootlock, false)) { MESH_DEBUG_PRINTLN("PWRMGT: Boot voltage too low - entering protective shutdown"); initiateShutdown(SHUTDOWN_REASON_BOOT_PROTECT); @@ -230,6 +242,18 @@ void NRF52Board::enterSystemOff(uint8_t reason) { } void NRF52Board::configureVoltageWake(uint8_t ain_channel, uint8_t refsel) { + // USB power should always be able to recover a device from SYSTEMOFF, even + // if voltage comparator setup is unavailable or invalid. + armVbusWake(); + if (!power_mgr_initialized || !supportsVoltageWake()) { + MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake skipped (power manager not ready/unsupported)"); + return; + } + if (ain_channel > 7 || refsel > 15) { + MESH_DEBUG_PRINTLN("PWRMGT: LPCOMP wake skipped (invalid AIN/ref)"); + return; + } + // LPCOMP is not managed by SoftDevice - direct register access required // Halt and disable before reconfiguration NRF_LPCOMP->TASKS_STOP = 1; @@ -244,8 +268,9 @@ void NRF52Board::configureVoltageWake(uint8_t ain_channel, uint8_t refsel) { // Detect UP events (voltage rises above threshold for battery recovery) NRF_LPCOMP->ANADETECT = LPCOMP_ANADETECT_ANADETECT_Up; - // Enable 50mV hysteresis for noise immunity - NRF_LPCOMP->HYST = LPCOMP_HYST_HYST_Hyst50mV; + // Do not add comparator hysteresis here. On divided battery inputs it can + // shift the effective wake point enough to strand a valid low-voltage cell. + NRF_LPCOMP->HYST = LPCOMP_HYST_HYST_NoHyst; // Clear stale events/interrupts before enabling wake NRF_LPCOMP->EVENTS_READY = 0; @@ -276,7 +301,10 @@ void NRF52Board::configureVoltageWake(uint8_t ain_channel, uint8_t refsel) { ain_channel, ref_num); } - // Configure VBUS (USB power) wake alongside LPCOMP +} + +void NRF52Board::armVbusWake() { + // Configure VBUS (USB power) wake alongside (or instead of) LPCOMP. uint8_t sd_enabled = 0; sd_softdevice_is_enabled(&sd_enabled); if (sd_enabled) { diff --git a/src/helpers/NRF52Board.h b/src/helpers/NRF52Board.h index 55e6890d..f8a46530 100644 --- a/src/helpers/NRF52Board.h +++ b/src/helpers/NRF52Board.h @@ -27,6 +27,8 @@ struct PowerMgtConfig { class NRF52Board : public mesh::MainBoard { #ifdef NRF52_POWER_MANAGEMENT void initPowerMgr(); + void armVbusWake(); + bool power_mgr_initialized; #endif protected: @@ -45,7 +47,11 @@ protected: #endif public: - NRF52Board(char *otaname) : ota_name(otaname) {} + NRF52Board(char *otaname) : ota_name(otaname) +#ifdef NRF52_POWER_MANAGEMENT + , power_mgr_initialized(false) +#endif + {} virtual void begin(); // Feed the hardware main-loop watchdog. The first call starts it, so normal // setup (including filesystem mount and radio initialization) is not timed. @@ -71,6 +77,8 @@ public: uint8_t getShutdownReason() const override { return shutdown_reason; } const char* getResetReasonString(uint32_t reason) override; const char* getShutdownReasonString(uint8_t reason) override; + bool isPowerManagementInitialized() const override { return power_mgr_initialized; } + bool supportsVoltageWake() const override { return true; } #endif }; diff --git a/src/helpers/PersistentStoreFormat.h b/src/helpers/PersistentStoreFormat.h new file mode 100644 index 00000000..8ff516c7 --- /dev/null +++ b/src/helpers/PersistentStoreFormat.h @@ -0,0 +1,221 @@ +#pragma once + +#include +#include +#include + +namespace mesh { +namespace storage { + +// Contact pages are deliberately smaller than a 4 KiB LittleFS block. A +// single contact update therefore never rewrites the complete contact list. +static const uint8_t CONTACTS_PER_PAGE = 25; +static const uint8_t CONTACT_PAGE_COUNT = 14; +static const uint16_t CONTACT_RECORD_SIZE = 152; +static const uint16_t CONTACT_PAGE_HEADER_SIZE = 20; +static const uint16_t CONTACT_PAGE_PAYLOAD_SIZE = + CONTACTS_PER_PAGE * CONTACT_RECORD_SIZE; +static const uint16_t CONTACT_PAGE_FILE_SIZE = + CONTACT_PAGE_HEADER_SIZE + CONTACT_PAGE_PAYLOAD_SIZE; +static const uint16_t CONTACT_SLOT_NONE = 0xFFFF; + +static const uint8_t CONTACT_PAGE_VERSION = 1; +static const uint8_t CONTACT_PAGE_MAGIC[4] = {'M', 'C', 'P', '4'}; + +struct ContactPageHeader { + uint8_t page_index; + uint32_t occupied; + uint32_t generation; + uint32_t payload_crc; +}; + +enum class ContactStoreSource : uint8_t { + EMPTY, + LEGACY, + PAGED, +}; + +// A retained legacy file means migration is still in progress. Its complete +// record count is the authoritative prefix boundary; page records at or above +// that boundary have already committed. +inline ContactStoreSource chooseContactStoreSource(bool legacy_exists, + bool page_exists) { + if (legacy_exists) return ContactStoreSource::LEGACY; + if (page_exists) return ContactStoreSource::PAGED; + return ContactStoreSource::EMPTY; +} + +// When a legacy file returns after a downgrade, old page files may describe a +// different contact list. Only a marker created after stale pages were removed +// makes those pages valid participants in an in-progress migration. +inline bool trustMigratedContactPages(bool legacy_exists, + bool migration_marker_exists) { + return !legacy_exists || migration_marker_exists; +} + +inline uint16_t legacyContactCountForSize(size_t file_size) { + const size_t count = file_size / CONTACT_RECORD_SIZE; + const size_t capacity = CONTACT_PAGE_COUNT * CONTACTS_PER_PAGE; + return (uint16_t)(count < capacity ? count : capacity); +} + +inline uint8_t legacyMigrationPage(uint16_t legacy_contact_count) { + return legacy_contact_count == 0 ? CONTACT_PAGE_COUNT + : (uint8_t)((legacy_contact_count - 1) / CONTACTS_PER_PAGE); +} + +inline uint16_t legacyCountAfterMigratingPage(uint8_t page) { + return page < CONTACT_PAGE_COUNT + ? (uint16_t)page * CONTACTS_PER_PAGE : 0; +} + +// During tail-first migration, the still-present prefix in /contacts3 wins. +// Page slots at or beyond its complete-record count are already committed and +// can be loaded. This also makes a reset between page commit and truncation +// harmless: the duplicate page records remain hidden until truncation commits. +inline bool loadSlotFromMigratedPage(uint16_t slot, + uint16_t legacy_contact_count) { + return slot >= legacy_contact_count; +} + +inline uint16_t readLE16(const uint8_t* src) { + return (uint16_t)src[0] | ((uint16_t)src[1] << 8); +} + +inline uint32_t readLE32(const uint8_t* src) { + return (uint32_t)src[0] | ((uint32_t)src[1] << 8) + | ((uint32_t)src[2] << 16) | ((uint32_t)src[3] << 24); +} + +inline void writeLE16(uint8_t* dest, uint16_t value) { + dest[0] = (uint8_t)value; + dest[1] = (uint8_t)(value >> 8); +} + +inline void writeLE32(uint8_t* dest, uint32_t value) { + dest[0] = (uint8_t)value; + dest[1] = (uint8_t)(value >> 8); + dest[2] = (uint8_t)(value >> 16); + dest[3] = (uint8_t)(value >> 24); +} + +// Same CRC convention as AtomicFileWriter: reflected CRC-32, initial value +// 0xFFFFFFFF, with no final xor. Keeping this incremental makes it usable on +// small targets without buffering an entire file. +inline uint32_t updateCRC32(uint32_t crc, const uint8_t* data, size_t len) { + while (len-- > 0) { + crc ^= *data++; + for (uint8_t bit = 0; bit < 8; bit++) { + crc = (crc >> 1) ^ ((crc & 1) ? 0xEDB88320UL : 0); + } + } + return crc; +} + +inline void encodeContactPageHeader(uint8_t dest[CONTACT_PAGE_HEADER_SIZE], + const ContactPageHeader& header) { + memcpy(dest, CONTACT_PAGE_MAGIC, sizeof(CONTACT_PAGE_MAGIC)); + dest[4] = CONTACT_PAGE_VERSION; + dest[5] = header.page_index; + writeLE16(&dest[6], CONTACT_RECORD_SIZE); + writeLE32(&dest[8], header.occupied); + writeLE32(&dest[12], header.generation); + writeLE32(&dest[16], header.payload_crc); +} + +inline bool decodeContactPageHeader(const uint8_t src[CONTACT_PAGE_HEADER_SIZE], + uint8_t expected_page, + ContactPageHeader& header) { + const uint32_t valid_slots = (1UL << CONTACTS_PER_PAGE) - 1UL; + if (memcmp(src, CONTACT_PAGE_MAGIC, sizeof(CONTACT_PAGE_MAGIC)) != 0 + || src[4] != CONTACT_PAGE_VERSION || src[5] != expected_page + || readLE16(&src[6]) != CONTACT_RECORD_SIZE) { + return false; + } + + header.page_index = src[5]; + header.occupied = readLE32(&src[8]); + header.generation = readLE32(&src[12]); + header.payload_crc = readLE32(&src[16]); + return (header.occupied & ~valid_slots) == 0; +} + +class DirtyPageSet { + uint32_t _bits; + +public: + DirtyPageSet() : _bits(0) {} + + void clearAll() { _bits = 0; } + bool empty() const { return _bits == 0; } + uint32_t bits() const { return _bits; } + + bool mark(uint8_t page) { + if (page >= CONTACT_PAGE_COUNT) return false; + _bits |= (1UL << page); + return true; + } + + int first() const { + for (uint8_t page = 0; page < CONTACT_PAGE_COUNT; page++) { + if ((_bits & (1UL << page)) != 0) return page; + } + return -1; + } + + void clear(uint8_t page) { + if (page < CONTACT_PAGE_COUNT) _bits &= ~(1UL << page); + } +}; + +class ContactSlotMap { + uint32_t _used[CONTACT_PAGE_COUNT]; + +public: + ContactSlotMap() { clear(); } + + void clear() { memset(_used, 0, sizeof(_used)); } + + uint32_t pageMask(uint8_t page) const { + return page < CONTACT_PAGE_COUNT ? _used[page] : 0; + } + + bool isUsed(uint16_t slot) const { + if (slot >= CONTACT_PAGE_COUNT * CONTACTS_PER_PAGE) return false; + return (_used[slot / CONTACTS_PER_PAGE] + & (1UL << (slot % CONTACTS_PER_PAGE))) != 0; + } + + bool reserve(uint16_t slot) { + if (slot >= CONTACT_PAGE_COUNT * CONTACTS_PER_PAGE) return false; + const uint8_t page = slot / CONTACTS_PER_PAGE; + const uint32_t bit = 1UL << (slot % CONTACTS_PER_PAGE); + if ((_used[page] & bit) != 0) return false; + _used[page] |= bit; + return true; + } + + uint16_t allocate() { + const uint32_t valid_slots = (1UL << CONTACTS_PER_PAGE) - 1UL; + for (uint8_t page = 0; page < CONTACT_PAGE_COUNT; page++) { + if ((_used[page] & valid_slots) == valid_slots) continue; + for (uint8_t index = 0; index < CONTACTS_PER_PAGE; index++) { + const uint32_t bit = 1UL << index; + if ((_used[page] & bit) == 0) { + _used[page] |= bit; + return (uint16_t)page * CONTACTS_PER_PAGE + index; + } + } + } + return CONTACT_SLOT_NONE; + } + + bool release(uint16_t slot) { + if (!isUsed(slot)) return false; + _used[slot / CONTACTS_PER_PAGE] &= ~(1UL << (slot % CONTACTS_PER_PAGE)); + return true; + } +}; + +} // namespace storage +} // namespace mesh diff --git a/src/helpers/PowerManagementUtils.h b/src/helpers/PowerManagementUtils.h new file mode 100644 index 00000000..06a01e7c --- /dev/null +++ b/src/helpers/PowerManagementUtils.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace mesh { +namespace power { + +inline uint16_t medianVoltage(uint16_t a, uint16_t b, uint16_t c) { + if (a > b) { uint16_t t = a; a = b; b = t; } + if (b > c) { uint16_t t = b; b = c; c = t; } + if (a > b) { uint16_t t = a; a = b; b = t; } + return b; +} + +inline bool shouldBootLock(uint16_t voltage_mv, uint16_t threshold_mv, + bool externally_powered) { + return !externally_powered && threshold_mv != 0 + && voltage_mv > 1000 && voltage_mv < threshold_mv; +} + +} // namespace power +} // namespace mesh diff --git a/src/helpers/RadioLivenessTracker.h b/src/helpers/RadioLivenessTracker.h new file mode 100644 index 00000000..830d2071 --- /dev/null +++ b/src/helpers/RadioLivenessTracker.h @@ -0,0 +1,67 @@ +#pragma once + +#include + +namespace mesh { + +enum class RadioRecoveryAction : uint8_t { + NONE, + SOFT, + HARD, +}; + +// Tracks proof that the radio hardware is alive independently of the MCU main +// loop. A successful packet, IRQ, or TX resets the schedule. Silence first +// requests a non-destructive RX/AGC re-arm, then a radio-only hardware reset. +class RadioLivenessTracker { + static const uint32_t HARD_RETRY_MS = 30000UL; + + uint32_t _last_activity; + uint32_t _last_hard_attempt; + uint8_t _stage; + +public: + RadioLivenessTracker() : _last_activity(0), _last_hard_attempt(0), _stage(0) {} + + void begin(uint32_t now) { + _last_activity = now; + _last_hard_attempt = 0; + _stage = 0; + } + + void noteActivity(uint32_t now) { + _last_activity = now; + _last_hard_attempt = 0; + _stage = 0; + } + + // A completed hard recovery is a safe point from which to restart the + // liveness window. Failed recoveries remain escalated and retry at a bounded + // cadence, instead of being silently deferred for another full interval. + void noteHardRecoveryResult(uint32_t now, bool success) { + if (success) noteActivity(now); + } + + RadioRecoveryAction poll(uint32_t now, uint32_t soft_after_ms, + uint32_t hard_after_ms) { + const uint32_t silent_for = now - _last_activity; + if (hard_after_ms > 0 && silent_for >= hard_after_ms) { + if (_stage < 2 || now - _last_hard_attempt >= HARD_RETRY_MS) { + _stage = 2; + _last_hard_attempt = now; + return RadioRecoveryAction::HARD; + } + return RadioRecoveryAction::NONE; + } + if (_stage == 0 && soft_after_ms > 0 && silent_for >= soft_after_ms) { + _stage = 1; + return RadioRecoveryAction::SOFT; + } + return RadioRecoveryAction::NONE; + } + + uint32_t lastActivity() const { return _last_activity; } + uint8_t stage() const { return _stage; } +}; + +} // namespace mesh diff --git a/src/helpers/bridges/RS232Bridge.cpp b/src/helpers/bridges/RS232Bridge.cpp index f719d342..ac16ed8b 100644 --- a/src/helpers/bridges/RS232Bridge.cpp +++ b/src/helpers/bridges/RS232Bridge.cpp @@ -1,4 +1,5 @@ #include "RS232Bridge.h" +#include "RS232UartUtils.h" #include @@ -17,7 +18,11 @@ void RS232Bridge::begin() { ((HardwareSerial *)_serial)->setPins(WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX); #elif defined(NRF52_PLATFORM) // Tested with RAK_4631 and T114 - ((Uart *)_serial)->setPins(WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX); + // The Adafruit Uart object may already be active on its variant defaults. + // Stop it before changing pins or the EasyDMA instance can retain the old + // pin selection and silently receive nothing. + mesh::bridge::prepareNrfUart(*((Uart *)_serial), + WITH_RS232_BRIDGE_RX, WITH_RS232_BRIDGE_TX); #elif defined(RP2040_PLATFORM) ((SerialUART *)_serial)->setRX(WITH_RS232_BRIDGE_RX); ((SerialUART *)_serial)->setTX(WITH_RS232_BRIDGE_TX); diff --git a/src/helpers/bridges/RS232UartUtils.h b/src/helpers/bridges/RS232UartUtils.h new file mode 100644 index 00000000..259f9ed8 --- /dev/null +++ b/src/helpers/bridges/RS232UartUtils.h @@ -0,0 +1,13 @@ +#pragma once + +namespace mesh { +namespace bridge { + +template +inline void prepareNrfUart(UartType& uart, PinType rx, PinType tx) { + uart.end(); + uart.setPins(rx, tx); +} + +} // namespace bridge +} // namespace mesh diff --git a/src/helpers/nrf52/SecuritySessionTimer.h b/src/helpers/nrf52/SecuritySessionTimer.h new file mode 100644 index 00000000..63f9221d --- /dev/null +++ b/src/helpers/nrf52/SecuritySessionTimer.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +// Keep the link available for the same two-minute window used by the pairing +// PIN screen. An expired session is disconnected, but its bond is not erased. +static const uint32_t BLE_SECURITY_SESSION_TIMEOUT_MS = 120000UL; + +class SecuritySessionTimer { + bool _pending; + uint32_t _started_at; + +public: + SecuritySessionTimer() : _pending(false), _started_at(0) {} + + void start(uint32_t now) { + _pending = true; + _started_at = now; + } + + void cancel() { _pending = false; } + bool pending() const { return _pending; } + + bool expired(uint32_t now, + uint32_t timeout_ms = BLE_SECURITY_SESSION_TIMEOUT_MS) const { + return _pending && (uint32_t)(now - _started_at) >= timeout_ms; + } +}; diff --git a/src/helpers/nrf52/SerialBLEInterface.cpp b/src/helpers/nrf52/SerialBLEInterface.cpp index 50046887..6bb89154 100644 --- a/src/helpers/nrf52/SerialBLEInterface.cpp +++ b/src/helpers/nrf52/SerialBLEInterface.cpp @@ -36,6 +36,7 @@ void SerialBLEInterface::onConnect(uint16_t connection_handle) { if (instance) { instance->_conn_handle = connection_handle; instance->_isDeviceConnected = false; + instance->_security_timer.start(millis()); instance->clearBuffers(); } } @@ -46,6 +47,7 @@ void SerialBLEInterface::onDisconnect(uint16_t connection_handle, uint8_t reason if (instance->_conn_handle == connection_handle) { instance->_conn_handle = BLE_CONN_HANDLE_INVALID; instance->_isDeviceConnected = false; + instance->_security_timer.cancel(); instance->clearBuffers(); } } @@ -59,6 +61,7 @@ void SerialBLEInterface::onSecured(uint16_t connection_handle) { if (conn == nullptr || !conn->secured()) { BLE_DEBUG_PRINTLN("SerialBLEInterface: security update did not secure the link"); instance->_isDeviceConnected = false; + instance->_security_timer.cancel(); if (conn != nullptr && conn->bonded()) { instance->removeStoredBondForPeer("unsecured link"); } @@ -67,6 +70,7 @@ void SerialBLEInterface::onSecured(uint16_t connection_handle) { } instance->_isDeviceConnected = true; + instance->_security_timer.cancel(); // Connection interval units: 1.25ms, supervision timeout units: 10ms // Apple: "The product will not read or use the parameters in the Peripheral Preferred Connection Parameters characteristic." @@ -113,6 +117,7 @@ void SerialBLEInterface::onPairingComplete(uint16_t connection_handle, uint8_t a instance->removeStoredBondForPeer("pairing failure"); } instance->_isDeviceConnected = false; + instance->_security_timer.cancel(); instance->disconnect(); } } else { @@ -140,6 +145,7 @@ void SerialBLEInterface::onBLEEvent(ble_evt_t* evt) { instance->removeStoredBondForPeer("failed bond encryption"); } instance->_isDeviceConnected = false; + instance->_security_timer.cancel(); sd_ble_gap_disconnect(conn_handle, BLE_HCI_REMOTE_USER_TERMINATED_CONNECTION); } } else if (evt->header.evt_id == BLE_GAP_EVT_DISCONNECTED) { @@ -335,6 +341,7 @@ void SerialBLEInterface::disable() { Bluefruit.Advertising.restartOnDisconnect(false); Bluefruit.Advertising.stop(); disconnect(); + _security_timer.cancel(); _last_health_check = 0; } @@ -408,6 +415,17 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { // Advertising watchdog: periodically check if advertising is running, restart if not // Only run when truly disconnected (no connection handle), not during connection establishment unsigned long now = millis(); + if (_isEnabled && _conn_handle != BLE_CONN_HANDLE_INVALID + && _security_timer.expired(now)) { + // A client may open a link and never finish PIN/bond negotiation. That + // otherwise suppresses advertising forever because a connection handle + // remains live. Disconnect only: inactivity is not evidence of a stale + // bond, so do not erase anything here. + BLE_DEBUG_PRINTLN("SerialBLEInterface: security setup timed out after %lu ms", + (unsigned long)BLE_SECURITY_SESSION_TIMEOUT_MS); + _security_timer.cancel(); + disconnect(); + } if (_isEnabled && !isConnected() && _conn_handle == BLE_CONN_HANDLE_INVALID) { if (now - _last_health_check >= BLE_HEALTH_CHECK_INTERVAL) { _last_health_check = now; diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index 0178e06f..33886326 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -1,6 +1,7 @@ #pragma once #include "../BaseSerialInterface.h" +#include "SecuritySessionTimer.h" #include #ifndef BLE_TX_POWER @@ -18,6 +19,7 @@ class SerialBLEInterface : public BaseSerialInterface { ble_gap_addr_t _peer_address = {}; bool _peer_address_valid; bool _bond_removed_for_connection; + SecuritySessionTimer _security_timer; struct Frame { uint8_t len; diff --git a/src/helpers/radiolib/CustomLLCC68Wrapper.h b/src/helpers/radiolib/CustomLLCC68Wrapper.h index f1dcb62f..00cb6753 100644 --- a/src/helpers/radiolib/CustomLLCC68Wrapper.h +++ b/src/helpers/radiolib/CustomLLCC68Wrapper.h @@ -39,6 +39,12 @@ public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } +protected: + bool radioDeepInit() override { + return ((CustomLLCC68 *)_radio)->std_init(); + } + bool supportsRadioDeepInit() const override { return true; } + protected: bool applyRxBoostedGainMode(bool en) override { return ((CustomLLCC68 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 99d3b164..b781361d 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -91,6 +91,7 @@ protected: // via NRST - the only way out of a hard-locked chip (BUSY stuck high). return ((CustomSX1262 *)_radio)->std_init(); } + bool supportsRadioDeepInit() const override { return true; } void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } diff --git a/src/helpers/radiolib/CustomSX1268Wrapper.h b/src/helpers/radiolib/CustomSX1268Wrapper.h index 49ecbecf..43b44ca8 100644 --- a/src/helpers/radiolib/CustomSX1268Wrapper.h +++ b/src/helpers/radiolib/CustomSX1268Wrapper.h @@ -43,6 +43,12 @@ public: void doResetAGC() override { sx126xResetAGC((SX126x *)_radio); } +protected: + bool radioDeepInit() override { + return ((CustomSX1268 *)_radio)->std_init(); + } + bool supportsRadioDeepInit() const override { return true; } + protected: bool applyRxBoostedGainMode(bool en) override { return ((CustomSX1268 *)_radio)->setRxBoostedGainMode(en) == RADIOLIB_ERR_NONE; diff --git a/src/helpers/radiolib/CustomSX1276Wrapper.h b/src/helpers/radiolib/CustomSX1276Wrapper.h index 940bd937..a85419ba 100644 --- a/src/helpers/radiolib/CustomSX1276Wrapper.h +++ b/src/helpers/radiolib/CustomSX1276Wrapper.h @@ -39,4 +39,10 @@ public: return packetScoreInt(snr, sf, packet_len); } uint8_t getSpreadingFactor() const override { return ((CustomSX1276 *)_radio)->spreadingFactor; } + +protected: + bool radioDeepInit() override { + return ((CustomSX1276 *)_radio)->std_init(); + } + bool supportsRadioDeepInit() const override { return true; } }; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index d293e32a..b6b88b52 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -92,6 +92,31 @@ void RadioLibWrapper::endReconfigure(bool resume_rx) { if (resume_rx) startRecv(); } +bool RadioLibWrapper::restoreAfterDeepInit() { + if (!radioDeepInit()) return false; + + _rx_ps_armed = false; + state = STATE_IDLE; + _radio->setPacketReceivedAction(setFlag); + + bool restored; + if (_params_valid) { + restored = applyParams(_cur_freq, _cur_bw, _cur_sf, _cur_cr); + } else { + _preamble_sf = getSpreadingFactor(); + restored = _radio->setPreambleLength(preambleLengthForSF(_preamble_sf)) + == RADIOLIB_ERR_NONE; + } + if (_dbm_valid) { + restored = _radio->setOutputPower(_cur_dbm) == RADIOLIB_ERR_NONE && restored; + } + if (_rx_boosted_gain_valid) { + restored = applyRxBoostedGainMode(_cur_rx_boosted_gain) && restored; + } + recalibrateNoiseFloor(); + return restored; +} + mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, uint8_t sf, uint8_t cr, const uint32_t* rx_ps_timings) { if (rx_ps_timings != NULL && !supportsRxPowerSaving()) { @@ -119,19 +144,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, bool restored = had_previous_params && applyParams(previous_freq, previous_bw, previous_sf, previous_cr); - if (!restored && radioDeepInit()) { - _rx_ps_armed = false; - state = STATE_IDLE; - _radio->setPacketReceivedAction(setFlag); - if (had_previous_params) { - restored = applyParams(previous_freq, previous_bw, previous_sf, previous_cr); - } else { - _preamble_sf = getSpreadingFactor(); - restored = _radio->setPreambleLength(preambleLengthForSF(_preamble_sf)) == RADIOLIB_ERR_NONE; - } - if (_dbm_valid) _radio->setOutputPower(_cur_dbm); - if (_rx_boosted_gain_valid) applyRxBoostedGainMode(_cur_rx_boosted_gain); - } + if (!restored) restored = restoreAfterDeepInit(); if (!restored) { MESH_DEBUG_PRINTLN("RadioLibWrapper: failed to restore radio parameters after apply failure"); @@ -234,6 +247,37 @@ void RadioLibWrapper::resetAGC() { _nf_calib_deadline = 0; // starts after reset recovery reaches RX } +bool RadioLibWrapper::recoverRadio(bool hard) { + const uint8_t base_state = state & ~STATE_INT_READY; + if ((state & STATE_INT_READY) != 0 || base_state == STATE_TX_WAIT) return false; + + const bool busy = isChipBusy(); + if (!busy && isReceivingPacket()) return false; + + if (hard) { + n_wd_hard++; + if (supportsRadioDeepInit()) { + MESH_DEBUG_PRINTLN("RadioLibWrapper: liveness watchdog: hard radio reset"); + return restoreAfterDeepInit(); + } + // LR11xx and integrated radios do not all expose a safe board-level reset + // here. Fall back to their proven AGC/RX re-arm instead of reporting a + // successful no-op. A stuck BUSY pin still reports failure and is retried. + if (busy) return false; + MESH_DEBUG_PRINTLN("RadioLibWrapper: hard reset unavailable; using RX re-arm"); + resetAGC(); + return true; + } + + // Never issue a warm-sleep/standby command while BUSY is stuck high. The + // hard stage can still escape that condition through the hardware reset pin. + if (busy) return false; + n_wd_soft++; + MESH_DEBUG_PRINTLN("RadioLibWrapper: liveness watchdog: soft AGC/RX re-arm"); + resetAGC(); + return true; +} + void RadioLibWrapper::rxPsWatchdogCheck() { // don't interfere mid-transmit or with a completed-but-unread packet // (a pending DIO1 event is itself proof the radio is alive; recvRaw() will @@ -296,14 +340,7 @@ void RadioLibWrapper::rxPsWatchdogCheck() { _wd_stage = 2; n_wd_hard++; MESH_DEBUG_PRINTLN("RadioLibWrapper: watchdog: still stuck, hard radio reset"); - if (radioDeepInit()) { - _rx_ps_armed = false; // chip is factory-fresh after NRST - state = STATE_IDLE; - _radio->setPacketReceivedAction(setFlag); - if (_params_valid) setParams(_cur_freq, _cur_bw, _cur_sf, _cur_cr); - if (_dbm_valid) _radio->setOutputPower(_cur_dbm); - if (_rx_boosted_gain_valid) applyRxBoostedGainMode(_cur_rx_boosted_gain); - } + restoreAfterDeepInit(); state = STATE_IDLE; // re-arm (rx powersaving settings are kept in members) } } diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 7e1187c2..06192250 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -80,11 +80,13 @@ protected: // full radio recovery: hardware reset (NRST) + re-init to boot defaults; // returns false if unsupported. Caller reapplies cached runtime params. virtual bool radioDeepInit() { return false; } + virtual bool supportsRadioDeepInit() const { return false; } virtual bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) = 0; virtual bool applyRxBoostedGainMode(bool) { return false; } // 0 = reconfigure from idle, 1 = resume RX afterwards, 2 = currently busy. uint8_t beginReconfigure(); void endReconfigure(bool resume_rx); + bool restoreAfterDeepInit(); float packetScoreInt(float snr, int sf, int packet_len); virtual bool isReceivingPacket() =0; virtual void doResetAGC(); @@ -152,6 +154,7 @@ public: void resetAGC() override; void loop() override; + bool recoverRadio(bool hard) override; uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsRecvErrors() const override { return n_recv_errors; } diff --git a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp index bf6b653f..cbb09d2f 100644 --- a/test/test_packet_manager/test_rx_reserve_packet_manager.cpp +++ b/test/test_packet_manager/test_rx_reserve_packet_manager.cpp @@ -11,6 +11,11 @@ class TestRadio : public mesh::Radio { public: int send_starts = 0; bool receiving = false; + bool in_recv_mode = true; + int soft_recoveries = 0; + int hard_recoveries = 0; + unsigned long last_irq = 0; + bool recovery_result = true; int recvRaw(uint8_t*, int) override { return 0; } uint32_t getEstAirtimeFor(int) override { return 1; } @@ -18,8 +23,17 @@ public: bool startSendRaw(const uint8_t*, int) override { send_starts++; return true; } bool isSendComplete() override { return false; } void onSendFinished() override { } - bool isInRecvMode() const override { return true; } + bool isInRecvMode() const override { return in_recv_mode; } bool isReceiving() override { return receiving; } + unsigned long getLastRadioInterruptMillis() const override { return last_irq; } + bool recoverRadio(bool hard) override { + if (hard) { + hard_recoveries++; + } else { + soft_recoveries++; + } + return recovery_result; + } }; class TestDispatcher : public mesh::Dispatcher { @@ -384,6 +398,43 @@ TEST(Dispatcher, QueueWakeDelayIncludesSchedulesAndChannelBackoff) { EXPECT_TRUE(dispatcher.queuedWorkDue()); } +TEST(Dispatcher, SilentRadioEscalatesFromSoftToHardRecovery) { + RxReservePacketManager manager(8, 4); + TestClock clock; + TestRadio radio; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.begin(); + + clock.now = 30UL * 60UL * 1000UL; + dispatcher.loop(); + EXPECT_EQ(1, radio.soft_recoveries); + EXPECT_EQ(0, radio.hard_recoveries); + + clock.now = 12UL * 60UL * 60UL * 1000UL; + dispatcher.loop(); + EXPECT_EQ(1, radio.soft_recoveries); + EXPECT_EQ(1, radio.hard_recoveries); +} + +TEST(Dispatcher, RadioOutsideReceiveModeEscalatesOnSecondAttempt) { + RxReservePacketManager manager(8, 4); + TestClock clock; + TestRadio radio; + radio.in_recv_mode = false; + TestDispatcher dispatcher(radio, clock, manager); + dispatcher.begin(); + + clock.now = 8001; + dispatcher.loop(); + EXPECT_EQ(1, radio.soft_recoveries); + EXPECT_EQ(0, radio.hard_recoveries); + + clock.now = 16002; + dispatcher.loop(); + EXPECT_EQ(1, radio.soft_recoveries); + EXPECT_EQ(1, radio.hard_recoveries); +} + TEST(RxReservePacketManager, RejectedOutboundRemainsOwnedByCaller) { RxReservePacketManager manager(8, 4); mesh::Packet* held[6]; diff --git a/test/test_persistent_store_format/test_persistent_store_format.cpp b/test/test_persistent_store_format/test_persistent_store_format.cpp new file mode 100644 index 00000000..5389f780 --- /dev/null +++ b/test/test_persistent_store_format/test_persistent_store_format.cpp @@ -0,0 +1,124 @@ +#include + +#include + +using namespace mesh::storage; + +TEST(PersistentStoreFormat, ContactHeaderRoundTripsAndRejectsDamage) { + uint8_t raw[CONTACT_PAGE_HEADER_SIZE]; + ContactPageHeader original = {3, 0x00100005UL, 42, 0x12345678UL}; + encodeContactPageHeader(raw, original); + + ContactPageHeader decoded = {}; + ASSERT_TRUE(decodeContactPageHeader(raw, 3, decoded)); + EXPECT_EQ(decoded.page_index, 3); + EXPECT_EQ(decoded.occupied, original.occupied); + EXPECT_EQ(decoded.generation, original.generation); + EXPECT_EQ(decoded.payload_crc, original.payload_crc); + + raw[0] ^= 1; + EXPECT_FALSE(decodeContactPageHeader(raw, 3, decoded)); + raw[0] ^= 1; + EXPECT_FALSE(decodeContactPageHeader(raw, 2, decoded)); + writeLE32(&raw[8], 1UL << CONTACTS_PER_PAGE); + EXPECT_FALSE(decodeContactPageHeader(raw, 3, decoded)); +} + +TEST(PersistentStoreFormat, CRCDetectsPayloadChanges) { + const uint8_t payload[] = {1, 2, 3, 4, 5}; + uint32_t expected = updateCRC32(0xFFFFFFFFUL, payload, sizeof(payload)); + uint8_t changed[sizeof(payload)] = {1, 2, 3, 4, 4}; + EXPECT_NE(updateCRC32(0xFFFFFFFFUL, changed, sizeof(changed)), expected); + + uint32_t split = updateCRC32(0xFFFFFFFFUL, payload, 2); + split = updateCRC32(split, payload + 2, sizeof(payload) - 2); + EXPECT_EQ(split, expected); +} + +TEST(PersistentStoreFormat, DirtyPagesStayPendingUntilExplicitlyCleared) { + DirtyPageSet pages; + EXPECT_TRUE(pages.empty()); + EXPECT_TRUE(pages.mark(7)); + EXPECT_TRUE(pages.mark(2)); + EXPECT_EQ(pages.first(), 2); + pages.clear(2); + EXPECT_EQ(pages.first(), 7); + EXPECT_FALSE(pages.mark(CONTACT_PAGE_COUNT)); + pages.clear(7); + EXPECT_TRUE(pages.empty()); +} + +TEST(PersistentStoreFormat, LegacyCountIgnoresPartialTailAndCapsAtCapacity) { + EXPECT_EQ(legacyContactCountForSize(0), 0); + EXPECT_EQ(legacyContactCountForSize(CONTACT_RECORD_SIZE - 1), 0); + EXPECT_EQ(legacyContactCountForSize(63 * CONTACT_RECORD_SIZE + 17), 63); + EXPECT_EQ(legacyContactCountForSize(999 * CONTACT_RECORD_SIZE), 350); +} + +TEST(PersistentStoreFormat, LegacyMigrationMovesTailOnePageAtATime) { + EXPECT_EQ(legacyMigrationPage(63), 2); + EXPECT_EQ(legacyCountAfterMigratingPage(2), 50); + EXPECT_FALSE(loadSlotFromMigratedPage(62, 63)); + EXPECT_TRUE(loadSlotFromMigratedPage(63, 63)); + + // After the page commit and legacy truncate, all of page two is loaded. + EXPECT_TRUE(loadSlotFromMigratedPage(50, 50)); + EXPECT_EQ(legacyMigrationPage(25), 0); + EXPECT_EQ(legacyCountAfterMigratingPage(0), 0); + EXPECT_EQ(legacyMigrationPage(0), CONTACT_PAGE_COUNT); +} + +TEST(PersistentStoreFormat, TailMigrationHasBoundedPeakSpaceAcrossPowerCuts) { + uint16_t legacy_count = CONTACT_PAGE_COUNT * CONTACTS_PER_PAGE; + size_t committed_pages = 0; + size_t peak_bytes = (size_t)legacy_count * CONTACT_RECORD_SIZE; + + while (legacy_count != 0) { + const uint8_t page = legacyMigrationPage(legacy_count); + committed_pages++; + + // Worst reset point: the newly committed page and its temporary retry can + // coexist with the untruncated legacy prefix. + const size_t retry_peak = (size_t)legacy_count * CONTACT_RECORD_SIZE + + (committed_pages + 1) * CONTACT_PAGE_FILE_SIZE; + if (retry_peak > peak_bytes) peak_bytes = retry_peak; + + legacy_count = legacyCountAfterMigratingPage(page); + } + + EXPECT_EQ(committed_pages, CONTACT_PAGE_COUNT); + EXPECT_LT(peak_bytes, 64u * 1024u); +} + +TEST(PersistentStoreFormat, ContactPageFitsWithinOneFourKiBBlock) { + EXPECT_LT(CONTACT_PAGE_FILE_SIZE, 4096); + EXPECT_EQ(CONTACT_PAGE_COUNT * CONTACTS_PER_PAGE, 350); +} + +TEST(PersistentStoreFormat, SlotAllocationIsStableAndReusable) { + ContactSlotMap slots; + EXPECT_EQ(slots.allocate(), 0); + EXPECT_EQ(slots.allocate(), 1); + EXPECT_TRUE(slots.release(0)); + EXPECT_EQ(slots.allocate(), 0); + EXPECT_FALSE(slots.reserve(1)); + EXPECT_TRUE(slots.reserve(CONTACTS_PER_PAGE)); + EXPECT_EQ(slots.pageMask(1), 1u); + EXPECT_FALSE(slots.release(CONTACT_PAGE_COUNT * CONTACTS_PER_PAGE)); +} + +TEST(PersistentStoreFormat, LegacyMarkerSelectsResumableMigrationPath) { + EXPECT_EQ(chooseContactStoreSource(true, true), ContactStoreSource::LEGACY); + EXPECT_EQ(chooseContactStoreSource(true, false), ContactStoreSource::LEGACY); + EXPECT_EQ(chooseContactStoreSource(false, true), ContactStoreSource::PAGED); + EXPECT_EQ(chooseContactStoreSource(false, false), ContactStoreSource::EMPTY); + + EXPECT_FALSE(trustMigratedContactPages(true, false)); + EXPECT_TRUE(trustMigratedContactPages(true, true)); + EXPECT_TRUE(trustMigratedContactPages(false, false)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_power_management/test_power_management.cpp b/test/test_power_management/test_power_management.cpp new file mode 100644 index 00000000..3c40f115 --- /dev/null +++ b/test/test_power_management/test_power_management.cpp @@ -0,0 +1,22 @@ +#include + +#include + +TEST(PowerManagement, MedianRejectsOneBrownoutSample) { + EXPECT_EQ(mesh::power::medianVoltage(3290, 3700, 3710), 3700); + EXPECT_EQ(mesh::power::medianVoltage(3710, 3290, 3700), 3700); + EXPECT_EQ(mesh::power::medianVoltage(3700, 3710, 3290), 3700); +} + +TEST(PowerManagement, BootLockRequiresValidLowBatteryReading) { + EXPECT_TRUE(mesh::power::shouldBootLock(3000, 3300, false)); + EXPECT_FALSE(mesh::power::shouldBootLock(999, 3300, false)); + EXPECT_FALSE(mesh::power::shouldBootLock(3300, 3300, false)); + EXPECT_FALSE(mesh::power::shouldBootLock(3000, 0, false)); + EXPECT_FALSE(mesh::power::shouldBootLock(3000, 3300, true)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_radio_liveness/test_radio_liveness.cpp b/test/test_radio_liveness/test_radio_liveness.cpp new file mode 100644 index 00000000..269cf18c --- /dev/null +++ b/test/test_radio_liveness/test_radio_liveness.cpp @@ -0,0 +1,43 @@ +#include + +#include + +using mesh::RadioLivenessTracker; +using mesh::RadioRecoveryAction; + +TEST(RadioLivenessTracker, StagesSoftThenHardRecovery) { + RadioLivenessTracker tracker; + tracker.begin(1000); + EXPECT_EQ(tracker.poll(1999, 1000, 5000), RadioRecoveryAction::NONE); + EXPECT_EQ(tracker.poll(2000, 1000, 5000), RadioRecoveryAction::SOFT); + EXPECT_EQ(tracker.poll(3000, 1000, 5000), RadioRecoveryAction::NONE); + EXPECT_EQ(tracker.poll(6000, 1000, 5000), RadioRecoveryAction::HARD); + EXPECT_EQ(tracker.poll(6999, 1000, 5000), RadioRecoveryAction::NONE); + EXPECT_EQ(tracker.poll(35999, 1000, 5000), RadioRecoveryAction::NONE); + EXPECT_EQ(tracker.poll(36000, 1000, 5000), RadioRecoveryAction::HARD); + tracker.noteHardRecoveryResult(36000, true); + EXPECT_EQ(tracker.poll(36999, 1000, 5000), RadioRecoveryAction::NONE); + EXPECT_EQ(tracker.poll(37000, 1000, 5000), RadioRecoveryAction::SOFT); +} + +TEST(RadioLivenessTracker, HardwareActivityCancelsEscalation) { + RadioLivenessTracker tracker; + tracker.begin(0); + EXPECT_EQ(tracker.poll(1000, 1000, 5000), RadioRecoveryAction::SOFT); + tracker.noteActivity(1200); + EXPECT_EQ(tracker.stage(), 0); + EXPECT_EQ(tracker.poll(2199, 1000, 5000), RadioRecoveryAction::NONE); + EXPECT_EQ(tracker.poll(2200, 1000, 5000), RadioRecoveryAction::SOFT); +} + +TEST(RadioLivenessTracker, ElapsedTimeIsRolloverSafe) { + RadioLivenessTracker tracker; + tracker.begin(0xFFFFFF00UL); + EXPECT_EQ(tracker.poll(0x000000FFUL, 512, 4096), RadioRecoveryAction::NONE); + EXPECT_EQ(tracker.poll(0x00000100UL, 512, 4096), RadioRecoveryAction::SOFT); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_rs232_uart/test_rs232_uart.cpp b/test/test_rs232_uart/test_rs232_uart.cpp new file mode 100644 index 00000000..d83f0e26 --- /dev/null +++ b/test/test_rs232_uart/test_rs232_uart.cpp @@ -0,0 +1,30 @@ +#include + +#include + +#include + +struct FakeUart { + std::vector calls; + void end() { calls.push_back(1); } + void setPins(int rx, int tx) { + calls.push_back(2); + calls.push_back(rx); + calls.push_back(tx); + } +}; + +TEST(RS232Uart, StopsPeripheralBeforeChangingPins) { + FakeUart uart; + mesh::bridge::prepareNrfUart(uart, 7, 8); + ASSERT_EQ(uart.calls.size(), 4u); + EXPECT_EQ(uart.calls[0], 1); + EXPECT_EQ(uart.calls[1], 2); + EXPECT_EQ(uart.calls[2], 7); + EXPECT_EQ(uart.calls[3], 8); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_security_session_timer/test_security_session_timer.cpp b/test/test_security_session_timer/test_security_session_timer.cpp new file mode 100644 index 00000000..cb8e3309 --- /dev/null +++ b/test/test_security_session_timer/test_security_session_timer.cpp @@ -0,0 +1,32 @@ +#include + +#include + +TEST(SecuritySessionTimer, ExpiresAtTwoMinutesAndCanBeCancelled) { + SecuritySessionTimer timer; + timer.start(1000); + EXPECT_FALSE(timer.expired(120999)); + EXPECT_TRUE(timer.expired(121000)); + timer.cancel(); + EXPECT_FALSE(timer.expired(500000)); +} + +TEST(SecuritySessionTimer, HandlesMillisRollover) { + SecuritySessionTimer timer; + timer.start(0xFFFFFF00UL); + EXPECT_FALSE(timer.expired(0x000000FFUL, 512)); + EXPECT_TRUE(timer.expired(0x00000100UL, 512)); +} + +TEST(SecuritySessionTimer, RestartUsesTheLatestConnection) { + SecuritySessionTimer timer; + timer.start(100); + timer.start(1000); + EXPECT_FALSE(timer.expired(1100, 200)); + EXPECT_TRUE(timer.expired(1200, 200)); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/variants/thinknode_m1/variant.h b/variants/thinknode_m1/variant.h index f7f52a6a..3399253c 100644 --- a/variants/thinknode_m1/variant.h +++ b/variants/thinknode_m1/variant.h @@ -137,7 +137,8 @@ extern const int SCK; #define PIN_GPS_RX (40) #define PIN_GPS_TX (41) #define GPS_EN (34) -#define PIN_GPS_RESET (37) +// GPIO37 powers the SX1262 on the M1; it is not connected to GPS reset. +#define GPS_RESET (-1) #define PIN_GPS_PPS (36) #define PIN_GPS_STANDBY (34) #define PIN_GPS_SWITCH (33) diff --git a/variants/thinknode_m6/variant.cpp b/variants/thinknode_m6/variant.cpp index c88f387d..3b67b5e5 100644 --- a/variants/thinknode_m6/variant.cpp +++ b/variants/thinknode_m6/variant.cpp @@ -30,6 +30,4 @@ void initVariant() { digitalWrite(PIN_GPS_STANDBY, HIGH); pinMode(PIN_GPS_EN, OUTPUT); digitalWrite(PIN_GPS_EN, HIGH); - pinMode(PIN_GPS_RESET, OUTPUT); - digitalWrite(PIN_GPS_RESET, HIGH); } diff --git a/variants/thinknode_m6/variant.h b/variants/thinknode_m6/variant.h index 70fd6506..a65f52e7 100644 --- a/variants/thinknode_m6/variant.h +++ b/variants/thinknode_m6/variant.h @@ -102,7 +102,9 @@ #define PIN_GPS_RX (2) #define PIN_GPS_TX (3) #define PIN_GPS_EN (6) // EN -#define PIN_GPS_RESET (29) +// The L76K REINIT pad must float during normal operation. Driving GPIO29 as +// a reset line leaves some M6 units unable to acquire or retain a fix. +#define GPS_RESET (-1) #define PIN_GPS_STANDBY (30) // STANDBY #define PIN_GPS_PPS (31) #define GPS_BAUD_RATE 9600 diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index 6e4e4afc..09309e94 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -26,8 +26,11 @@ build_flags = ${rak4631_hw.build_flags} -D SX126X_DIO3_TCXO_VOLTAGE=1.8 -D SX126X_CURRENT_LIMIT=140 -D SX126X_RX_BOOSTED_GAIN=1 - -D PIN_WIRE_SCL=D6 - -D PIN_WIRE_SDA=D7 + ; D6/D7 are also Serial1 TX/RX. Auto-probing I2C there can hold the UART + ; peripheral in a bad state and prevent boot/BLE startup. Use the dedicated + ; NFC pads for I2C on every XIAO nRF52 build. + -D PIN_WIRE_SCL=PIN_NFC1 + -D PIN_WIRE_SDA=PIN_NFC2 -D PIN_USER_BTN=PIN_BUTTON1 -D DISPLAY_CLASS=NullDisplayDriver build_src_filter = ${rak4631_hw.build_src_filter} @@ -122,19 +125,14 @@ lib_deps = ; ============================================================================= ; SolarXiao (wehooper4) boards -; Same Xiao nRF52840 module, but I2C remapped to NFC pins to prevent boot hang -; caused by AutoDiscoverRTCClock scanning on D6/D7. +; Same Xiao nRF52840 module. I2C is already on the NFC pads in the base target; +; these solar builds additionally skip the RTC probe. ; 30S = standard SX1262 (22 dBm), 33S = 2W PA variant (9 dBm limit) ; ============================================================================= [solarxiao] extends = Xiao_nrf52 -build_unflags = - -D PIN_WIRE_SCL=D6 - -D PIN_WIRE_SDA=D7 build_flags = ${Xiao_nrf52.build_flags} - -D PIN_WIRE_SCL=PIN_NFC1 - -D PIN_WIRE_SDA=PIN_NFC2 -D DISABLE_I2C_RTC_SCAN [env:solarxiao_30S_companion_radio_ble] @@ -160,7 +158,6 @@ lib_deps = [env:solarxiao_33S_companion_radio_ble] extends = env:solarxiao_30S_companion_radio_ble build_unflags = - ${solarxiao.build_unflags} -D LORA_TX_POWER=22 build_flags = ${env:solarxiao_30S_companion_radio_ble.build_flags} @@ -187,7 +184,6 @@ lib_deps = [env:solarxiao_33S_companion_radio_usb] extends = env:solarxiao_30S_companion_radio_usb build_unflags = - ${solarxiao.build_unflags} -D LORA_TX_POWER=22 build_flags = ${env:solarxiao_30S_companion_radio_usb.build_flags} @@ -208,7 +204,6 @@ build_src_filter = ${solarxiao.build_src_filter} [env:solarxiao_33S_repeater] extends = env:solarxiao_30S_repeater build_unflags = - ${solarxiao.build_unflags} -D LORA_TX_POWER=22 -D ADVERT_NAME='"SolarXiao 30S Repeater"' build_flags = @@ -219,7 +214,6 @@ build_flags = [env:solarxiao_30S_repeater_bridge_rs232] extends = env:solarxiao_30S_repeater build_unflags = - ${solarxiao.build_unflags} -D ADVERT_NAME='"SolarXiao 30S Repeater"' build_flags = ${env:solarxiao_30S_repeater.build_flags} @@ -234,7 +228,6 @@ build_src_filter = ${env:solarxiao_30S_repeater.build_src_filter} [env:solarxiao_33S_repeater_bridge_rs232] extends = env:solarxiao_30S_repeater_bridge_rs232 build_unflags = - ${solarxiao.build_unflags} -D LORA_TX_POWER=22 -D ADVERT_NAME='"SolarXiao 30S Repeater"' -D ADVERT_NAME='"SolarXiao 30S RS232"' @@ -257,7 +250,6 @@ build_src_filter = ${solarxiao.build_src_filter} [env:solarxiao_33S_room_server] extends = env:solarxiao_30S_room_server build_unflags = - ${solarxiao.build_unflags} -D LORA_TX_POWER=22 -D ADVERT_NAME='"SolarXiao 30S Room"' build_flags = @@ -277,7 +269,6 @@ lib_deps = [env:solarxiao_33S_kiss_modem] extends = env:solarxiao_30S_kiss_modem build_unflags = - ${solarxiao.build_unflags} -D LORA_TX_POWER=22 build_flags = ${env:solarxiao_30S_kiss_modem.build_flags}