Pr1 nodedb warmstore (#10705)

* NodeDB: 3-tier node store with persistent warm tier (long-tail identity retention)

Introduces a tiered NodeDB so the device retains identity (public key,
last_heard) for far more nodes than fit in the full-record hot store,
without growing heap or the persisted nodes.proto unboundedly.

- Hot store: full NodeInfoLite, MAX_NUM_NODES (120 on nRF52).
- Satellite maps: position/telemetry/environment/status capped at
  MAX_SATELLITE_NODES (40 freshest); eviction via enforceSatelliteCaps /
  evictSatelliteOverCap.
- Warm tier (WarmNodeStore): 40 B {num,last_heard,public_key} records for
  evicted nodes so DMs to/from long-tail nodes keep encrypting/decrypting.
  Persisted to /prefs/warm.dat, or on nRF52840 a dedicated 12 KB raw-flash
  record-ring below LittleFS (3x4 KB pages; see linker scripts + the
  nrf52_warm_region.py post-link guard).

NodeDB::getOrCreateMeshNode now demotes evicted nodes into the warm tier and
re-admits them (restoring key/last_heard). Router PKI decrypt/encode resolve
the peer key via NodeDB::copyPublicKey (hot store, then warm tier).

NodeInfoLite gains snr_q4 (sint32, Q4-encoded dB); the float snr is zeroed on
disk. NodeInfoLite grows 105 -> 112 B; backup 2432 -> 2468 B.

Note: the snr_q4 .proto change still needs to land in the protobufs submodule
(generated header is updated here; submodule pointer left at upstream).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* NodeDB: robust receive + retention for blocked (ignored) nodes

Hardens how ignored/favourite nodes are received over admin and retained,
closing paths where a block could be lost or accidentally cleared.

- Blocking keeps the node's public key (admin set_ignored_node and
  addFromContact no longer zero it / drop the warm-tier key), so a blocked
  peer stays a verifiable identity.
- set_ignored_node creates the node if absent, so a block by node ID sticks
  even for a node we've never heard from (e.g. pushed by a remote admin) with
  no NodeInfo or key.
- Eviction protection (favourite/ignored/manually-verified) now also applies to
  the load-time hot-store migration and is never undone by cleanupMeshDB, which
  previously purged ignored nodes that lacked user info.
- The hot-store migration leaves our own node (index 0) in place and prefers to
  demote non-protected nodes, like the runtime eviction scan.

Caps the protected set (favourite + ignored + verified) at MAX_NUM_NODES-2 via
NodeDB::setProtectedFlag(), so at least two evictable slots always remain and
getOrCreateMeshNode can always make room — replacing the previous unconditional
append that could run off the end of the node vector when every node was
protected. A locally-set favourite/ignore that hits the cap reports back to the
phone via a ClientNotification.

Adds test_nodedb_blocked covering the migration, favourite/ignored eviction
protection, ignored-survives-cleanup, and the protected-node cap. The
maintenance methods stay private in production; the test reaches them through a
PIO_UNIT_TESTING-guarded friend shim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

# Conflicts:
#	src/mesh/NodeDB.h

* fix copilot comments

* once again

* WarmNodeStore: fix cppcheck warnings (uninitvar, constVariablePointer)

Zero-initialise `stranded[]` and `seqs[]/order[]` VLAs so cppcheck can
verify there are no unguarded reads of uninitialised memory (the guards
exist but are not visible to static analysis). Mark two local pointers
`const` where the pointed-to entry is never mutated after assignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* self-care added to assist 2.7 and 2.8 nodedb migration

* Tidy warm-store/self-care: comments, guards, log + flash cleanup

Style/cleanup pass over the branch (no behavior change except the noted
preprocessor simplifications, which are semantically identical):

- Comments: move function descriptions to the headers, cap in-function
  comments at ~3-4 lines, drop leading-number step markers, label stacked
  #endif blocks, de-decorate banner comments.
- dumpToLog: fully gate decl + definition + AdminModule call site behind
  MESHTASTIC_NODEDB_MIGRATION_VERBOSE so it compiles out when disabled
  (~1.2 KB when off).
- mesh-pb-constants: drop the dead nRF52832 WARM_NODE_COUNT branch and trim
  the macro docs.
- WarmNodeStore: simplify the redundant `ARCH_NRF52 && NRF52840_XXAA` guards
  to `NRF52840_XXAA`, add a kNoPage sentinel for the ring page state.
- Shorten the always-on LOG_WARN strings (~120 B flash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* more tidying up, aligning with docs and undoing other-arch regressions

* Update protobufs (#19)

Co-authored-by: NomDeTom <116762865+NomDeTom@users.noreply.github.com>

* made the migration pathway cleareer

* address copilot review

* fixed a copilot review on a downstream PR.

* Address Copilot review comments for PR #10705 (warmstore/nodedb)

- WarmNodeStore.h: default MIGRATION_VERBOSE to 0 (suppress info-level
  chatter on production builds; opt in with =1)
- WarmNodeStore.cpp load(): move memset to top of function so all
  failure paths (header-read fail, invalid header) leave entries clear
- WarmNodeStore.cpp save(): replace manual spiLock lock/unlock around
  mkdir with LockGuard covering the full SafeFile sequence, matching
  the lock discipline in load()
- Router.cpp: memcpy(&p->public_key.bytes, ...) -> memcpy(p->public_key.bytes,
  ...) — pass decayed uint8_t* rather than pointer-to-array
- AdminModule.cpp: check setProtectedFlag return for PKC auto-favorite;
  log cap-refusal warning instead of unconditional "auto-favoriting"
- nrf52_warm_region.py: error message references both v6.ld and v7.ld

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* NodeDB: formatting cleanup (blank lines after preprocessor blocks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Lukewarm store

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
This commit is contained in:
Tom
2026-06-18 08:41:34 +01:00
committed by GitHub
co-authored by GitHub Claude Opus 4.8 github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Ben Meadors
parent b311e01ce0
commit 79a7dcc46c
17 changed files with 1848 additions and 87 deletions
+432 -31
View File
@@ -197,6 +197,8 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre
if (ostream) {
const auto *vec = static_cast<const std::vector<meshtastic_NodeInfoLite> *>(iter->pData);
for (auto item : *vec) {
item.snr_q4 = (int32_t)(item.snr * 4.0f);
item.snr = 0.0f;
if (!pb_encode_tag_for_field(ostream, iter))
return false;
if (!pb_encode_submessage(ostream, meshtastic_NodeInfoLite_fields, &item))
@@ -206,8 +208,12 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre
if (istream && istream->bytes_left) {
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
auto *vec = static_cast<std::vector<meshtastic_NodeInfoLite> *>(iter->pData);
if (pb_decode(istream, meshtastic_NodeInfoLite_fields, &node))
if (pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) {
if (node.snr_q4)
node.snr = node.snr_q4 / 4.0f;
node.snr_q4 = 0;
vec->push_back(node);
}
}
return true;
}
@@ -482,9 +488,17 @@ NodeDB::NodeDB()
myNodeInfo.my_node_num = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size);
}
#endif
// Include our owner in the node db under our nodenum
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum());
TypeConversions::CopyUserToNodeInfoLite(info, owner);
// Identity is now established, so run the self-care pass on the store
// loadFromDisk() deliberately left untrimmed: confirm self, trim/demote only
// non-self overflow, pin self to index 0, rewrite once if healed.
nodeDBSelfCare();
// If we migrated from legacy during loadFromDisk(), persist the migrated DB
// only after identity and self-care are established.
if (migrationSavePending) {
saveNodeDatabaseToDisk();
migrationSavePending = false;
}
// If node database has not been saved for the first time, save it now
#ifdef FSCom
@@ -689,6 +703,39 @@ template <typename Map> std::vector<NodeNum> snapshotSatelliteNodeNums(const Map
}
return result;
}
// Drop the stalest entry of `map` (staleness proxied via the owner's
// last_heard; 0 = owner evicted, i.e. an orphan — first out). Never evicts our
// own node's entry. Caller holds satelliteMutex. Returns false if nothing
// could be evicted.
template <typename Map> bool evictStalestSatellite(NodeDB &db, Map &map)
{
auto victim = map.end();
uint32_t victimTs = UINT32_MAX;
for (auto it = map.begin(); it != map.end(); ++it) {
if (it->first == db.getNodeNum())
continue;
uint32_t ts = db.hotNodeLastHeard(it->first);
if (ts < victimTs) {
victimTs = ts;
victim = it;
}
}
if (victim == map.end())
return false;
map.erase(victim);
return true;
}
// Keep `map` within MAX_SATELLITE_NODES ahead of inserting `incoming` (the
// tier-1/tier-2 split: only the freshest MAX_SATELLITE_NODES nodes carry
// satellite payloads). Caller holds satelliteMutex.
template <typename Map> void evictSatelliteOverCap(NodeDB &db, Map &map, NodeNum incoming)
{
if (map.size() < MAX_SATELLITE_NODES || map.count(incoming))
return;
evictStalestSatellite(db, map);
}
} // namespace
void NodeDB::resetRadioConfig(bool is_fresh_install)
@@ -721,6 +768,7 @@ bool NodeDB::factoryReset(bool eraseBleBonds)
LOG_ERROR("Could not remove rangetest.csv file");
}
#endif
spiLock->unlock();
// rmDir above nuked the .dat file, but TransmitHistory's in-memory
@@ -731,6 +779,14 @@ bool NodeDB::factoryReset(bool eraseBleBonds)
#if HAS_SCREEN
messageStore.clearAllMessages();
#endif
#if WARM_NODE_COUNT > 0
// On nRF52840 the warm tier lives in raw flash outside /prefs, so rmDir
// didn't touch it; clear it and persist the empty store.
warmStore.clear();
warmStore.saveIfDirty();
#endif
// second, install default state (this will deal with the duplicate mac address issue)
installDefaultNodeDatabase();
installDefaultDeviceState();
@@ -745,6 +801,7 @@ bool NodeDB::factoryReset(bool eraseBleBonds)
// This will erase what's in NVS including ssl keys, persistent variables and ble pairing
nvs_flash_erase();
#endif
#ifdef ARCH_NRF52
LOG_INFO("Clear bluetooth bonds!");
bond_print_list(BLE_GAP_ROLE_PERIPH);
@@ -767,12 +824,15 @@ void NodeDB::installDefaultNodeDatabase()
#if !MESHTASTIC_EXCLUDE_POSITIONDB
nodePositions.clear();
#endif
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
nodeTelemetry.clear();
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
nodeEnvironment.clear();
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
nodeStatus.clear();
#endif
@@ -835,34 +895,42 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
#else
config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
#endif
#ifdef USERPREFS_LORACONFIG_MODEM_PRESET
config.lora.modem_preset = USERPREFS_LORACONFIG_MODEM_PRESET;
#else
config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST;
#endif
#ifdef USERPREFS_LORACONFIG_USE_PRESET
config.lora.use_preset = USERPREFS_LORACONFIG_USE_PRESET;
#else
config.lora.use_preset = true;
#endif
#ifdef USERPREFS_LORACONFIG_BANDWIDTH
config.lora.bandwidth = USERPREFS_LORACONFIG_BANDWIDTH;
#endif
#ifdef USERPREFS_LORACONFIG_SPREAD_FACTOR
config.lora.spread_factor = USERPREFS_LORACONFIG_SPREAD_FACTOR;
#endif
#ifdef USERPREFS_LORACONFIG_CODING_RATE
config.lora.coding_rate = USERPREFS_LORACONFIG_CODING_RATE;
#endif
#ifdef USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY
config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY;
#endif
config.lora.hop_limit = HOP_RELIABLE;
#ifdef USERPREFS_CONFIG_LORA_IGNORE_MQTT
config.lora.ignore_mqtt = USERPREFS_CONFIG_LORA_IGNORE_MQTT;
#else
config.lora.ignore_mqtt = false;
#endif
// Initialize admin_key_count to zero
byte numAdminKeys = 0;
@@ -892,6 +960,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
numAdminKeys++;
}
#endif
config.security.admin_key_count = numAdminKeys;
if (shouldPreserveKey) {
@@ -906,6 +975,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
#ifdef PIN_GPS_EN
config.position.gps_en_gpio = PIN_GPS_EN;
#endif
#if defined(USERPREFS_CONFIG_GPS_MODE)
config.position.gps_mode = USERPREFS_CONFIG_GPS_MODE;
#elif !HAS_GPS || GPS_DEFAULT_NOT_PRESENT
@@ -918,11 +988,13 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
#else
config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
#endif
#ifdef USERPREFS_CONFIG_SMART_POSITION_ENABLED
config.position.position_broadcast_smart_enabled = USERPREFS_CONFIG_SMART_POSITION_ENABLED;
#else
config.position.position_broadcast_smart_enabled = true;
#endif
config.position.broadcast_smart_minimum_distance = 100;
config.position.broadcast_smart_minimum_interval_secs = default_broadcast_smart_minimum_interval_secs;
if (config.device.role != meshtastic_Config_DeviceConfig_Role_ROUTER &&
@@ -942,6 +1014,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
// default to bluetooth capability of platform as default
config.bluetooth.enabled = true;
#endif
config.bluetooth.fixed_pin = defaultBLEPin;
#if defined(ST7735_CS) || defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \
@@ -953,7 +1026,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
if (st7789_id == 0xFFFFFF) {
hasScreen = false;
}
#endif
#endif // HELTEC_MESH_NODE_T114
#elif ARCH_PORTDUINO
bool hasScreen = false;
if (portduino_config.displayPanel)
@@ -973,6 +1046,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
config.bluetooth.mode = hasScreen ? meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN
: meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
#endif
// for backward compat, default position flags are ALT+MSL
config.position.position_flags =
(meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE | meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE_MSL |
@@ -985,8 +1059,8 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
config.network.enabled_protocols = USERPREFS_NETWORK_ENABLED_PROTOCOLS;
#else
config.network.enabled_protocols = 0;
#endif
#endif
#endif // Network enabled protocols
#endif // UDP Multicast
#ifdef USERPREFS_NETWORK_WIFI_ENABLED
config.network.wifi_enabled = USERPREFS_NETWORK_WIFI_ENABLED;
@@ -1013,16 +1087,20 @@ void NodeDB::installDefaultConfig(bool preserveKey = false)
#ifdef DISPLAY_FLIP_SCREEN
config.display.flip_screen = true;
#endif
#ifdef RAK4630
config.display.wake_on_tap_or_motion = true;
#endif
#if defined(T_WATCH_S3) || defined(SENSECAP_INDICATOR)
config.display.screen_on_secs = 30;
config.display.wake_on_tap_or_motion = true;
#endif
#ifdef COMPASS_ORIENTATION
config.display.compass_orientation = COMPASS_ORIENTATION;
#endif
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
if (MeshtasticOTA::isUpdated()) {
MeshtasticOTA::recoverConfig(&config.network);
@@ -1046,6 +1124,7 @@ void NodeDB::initConfigIntervals()
#else
config.position.gps_update_interval = default_gps_update_interval;
#endif
#ifdef USERPREFS_CONFIG_POSITION_BROADCAST_INTERVAL
config.position.position_broadcast_secs = USERPREFS_CONFIG_POSITION_BROADCAST_INTERVAL;
#else
@@ -1082,22 +1161,26 @@ void NodeDB::installDefaultModuleConfig()
defined(NEOPIXEL_STATUS_NOTIFICATION_PIN)
moduleConfig.external_notification.enabled = true;
#endif
#if defined(PIN_BUZZER)
moduleConfig.external_notification.output_buzzer = PIN_BUZZER;
moduleConfig.external_notification.use_pwm = true;
moduleConfig.external_notification.alert_message_buzzer = true;
#endif
#if defined(PIN_VIBRATION)
moduleConfig.external_notification.output_vibra = PIN_VIBRATION;
moduleConfig.external_notification.alert_message_vibra = true;
moduleConfig.external_notification.output_ms = 500;
#endif
#if defined(LED_NOTIFICATION)
moduleConfig.external_notification.output = LED_NOTIFICATION;
moduleConfig.external_notification.active = LED_STATE_ON;
moduleConfig.external_notification.alert_message = true;
moduleConfig.external_notification.output_ms = 1000;
#endif
#if defined(PIN_VIBRATION)
moduleConfig.external_notification.nag_timeout = 2;
#elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN)
@@ -1114,14 +1197,16 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.external_notification.nag_timeout = 0;
#else
moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs;
#endif
#endif
#endif // HAS_TFT
#endif // HAS_I2S
#ifdef NANO_G2_ULTRA
moduleConfig.external_notification.enabled = true;
moduleConfig.external_notification.alert_message = true;
moduleConfig.external_notification.output_ms = 100;
moduleConfig.external_notification.active = true;
#endif
#endif // NANO_G2_ULTRA
#ifdef T_LORA_PAGER
moduleConfig.canned_message.updown1_enabled = true;
moduleConfig.canned_message.inputbroker_pin_a = ROTARY_A;
@@ -1131,35 +1216,43 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.canned_message.inputbroker_event_ccw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar(29);
moduleConfig.canned_message.inputbroker_event_press = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT;
#endif
moduleConfig.has_canned_message = true;
#if USERPREFS_MQTT_ENABLED && !MESHTASTIC_EXCLUDE_MQTT
moduleConfig.mqtt.enabled = true;
#endif
#ifdef USERPREFS_MQTT_ADDRESS
strncpy(moduleConfig.mqtt.address, USERPREFS_MQTT_ADDRESS, sizeof(moduleConfig.mqtt.address));
#else
strncpy(moduleConfig.mqtt.address, default_mqtt_address, sizeof(moduleConfig.mqtt.address));
#endif
#ifdef USERPREFS_MQTT_USERNAME
strncpy(moduleConfig.mqtt.username, USERPREFS_MQTT_USERNAME, sizeof(moduleConfig.mqtt.username));
#else
strncpy(moduleConfig.mqtt.username, default_mqtt_username, sizeof(moduleConfig.mqtt.username));
#endif
#ifdef USERPREFS_MQTT_PASSWORD
strncpy(moduleConfig.mqtt.password, USERPREFS_MQTT_PASSWORD, sizeof(moduleConfig.mqtt.password));
#else
strncpy(moduleConfig.mqtt.password, default_mqtt_password, sizeof(moduleConfig.mqtt.password));
#endif
#ifdef USERPREFS_MQTT_ROOT_TOPIC
strncpy(moduleConfig.mqtt.root, USERPREFS_MQTT_ROOT_TOPIC, sizeof(moduleConfig.mqtt.root));
#else
strncpy(moduleConfig.mqtt.root, default_mqtt_root, sizeof(moduleConfig.mqtt.root));
#endif
#ifdef USERPREFS_MQTT_ENCRYPTION_ENABLED
moduleConfig.mqtt.encryption_enabled = USERPREFS_MQTT_ENCRYPTION_ENABLED;
#else
moduleConfig.mqtt.encryption_enabled = default_mqtt_encryption_enabled;
#endif
#ifdef USERPREFS_MQTT_TLS_ENABLED
moduleConfig.mqtt.tls_enabled = USERPREFS_MQTT_TLS_ENABLED;
#else
@@ -1253,11 +1346,13 @@ void NodeDB::initModuleConfigIntervals()
#else
moduleConfig.telemetry.device_update_interval = MAX_INTERVAL;
#endif
#ifdef USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL
moduleConfig.telemetry.environment_update_interval = USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL;
#else
moduleConfig.telemetry.environment_update_interval = 0;
#endif
#ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED
moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED;
#endif
@@ -1302,6 +1397,10 @@ void NodeDB::resetNodes(bool keepFavorites)
std::fill(nodeDatabase.nodes.begin() + 1, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite());
}
(void)ourNum;
#if WARM_NODE_COUNT > 0
warmStore.clear(); // warm entries are never favorites; a DB reset clears them too
#endif
devicestate.has_rx_waypoint = false;
saveNodeDatabaseToDisk();
saveDeviceStateToDisk();
@@ -1323,6 +1422,11 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum)
meshtastic_NodeInfoLite());
if (removed)
eraseNodeSatellites(nodeNum);
#if WARM_NODE_COUNT > 0
// Explicit user removal: don't let the warm tier resurrect the node
warmStore.remove(nodeNum);
#endif
LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed);
saveNodeDatabaseToDisk();
}
@@ -1436,6 +1540,7 @@ void NodeDB::setNodeStatus(NodeNum n, const meshtastic_StatusMessage &status)
(void)status;
#else
concurrency::LockGuard guard(&satelliteMutex);
evictSatelliteOverCap(*this, nodeStatus, n);
nodeStatus[n] = status;
#endif
}
@@ -1447,6 +1552,7 @@ void NodeDB::touchNodePositionTime(NodeNum n, uint32_t time)
(void)time;
#else
concurrency::LockGuard guard(&satelliteMutex);
evictSatelliteOverCap(*this, nodePositions, n);
nodePositions[n].time = time;
#endif
}
@@ -1457,23 +1563,65 @@ void NodeDB::eraseNodeSatellites(NodeNum n)
#if !MESHTASTIC_EXCLUDE_POSITIONDB
nodePositions.erase(n);
#endif
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
nodeTelemetry.erase(n);
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
nodeEnvironment.erase(n);
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
nodeStatus.erase(n);
#endif
}
bool NodeDB::enforceSatelliteCaps()
{
concurrency::LockGuard guard(&satelliteMutex);
bool trimmedAny = false;
auto trim = [this, &trimmedAny](auto &map, const char *name) {
const size_t before = map.size();
while (map.size() > MAX_SATELLITE_NODES) {
if (!evictStalestSatellite(*this, map))
break;
}
if (map.size() != before) {
trimmedAny = true;
LOG_MIGRATION("Trimmed %s satellites %u -> %u (cap %d)", name, (unsigned)before, (unsigned)map.size(),
MAX_SATELLITE_NODES);
}
};
#if !MESHTASTIC_EXCLUDE_POSITIONDB
trim(nodePositions, "position");
#endif
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
trim(nodeTelemetry, "telemetry");
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
trim(nodeEnvironment, "environment");
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
trim(nodeStatus, "status");
#endif
(void)trim; // all four maps may be compiled out
return trimmedAny;
}
void NodeDB::cleanupMeshDB()
{
int newPos = 0, removed = 0;
for (int i = 0; i < numMeshNodes; i++) {
meshtastic_NodeInfoLite &n = meshNodes->at(i);
if (nodeInfoLiteHasUser(&n)) {
// Keep ignored (blocked) nodes even without user info: a block set by
// bare node ID has no NodeInfo and would otherwise be purged here,
// silently dropping the block.
if (nodeInfoLiteHasUser(&n) || nodeInfoLiteIsIgnored(&n)) {
if (n.public_key.size > 0) {
if (memfll(n.public_key.bytes, 0, n.public_key.size)) {
n.public_key.size = 0;
@@ -1486,8 +1634,16 @@ void NodeDB::cleanupMeshDB()
} else {
// No user info - drop this node and its satellites
const NodeNum gone = n.num;
if (gone)
if (gone) {
#if WARM_NODE_COUNT > 0
// Keep any key we learned (e.g. via a DM before the NodeInfo
// exchange completed) rather than losing it with the purge.
if (n.public_key.size == 32)
warmStore.absorb(gone, n.last_heard, n.public_key.bytes);
#endif
eraseNodeSatellites(gone);
}
removed++;
}
}
@@ -1518,12 +1674,15 @@ void NodeDB::installDefaultDeviceState()
#else
snprintf(owner.long_name, sizeof(owner.long_name), "Meshtastic %04x", getNodeNum() & 0x0ffff);
#endif
clampLongName(owner.long_name); // vendor userprefs may exceed the local cap
#ifdef USERPREFS_CONFIG_OWNER_SHORT_NAME
snprintf(owner.short_name, sizeof(owner.short_name), (const char *)USERPREFS_CONFIG_OWNER_SHORT_NAME);
#else
snprintf(owner.short_name, sizeof(owner.short_name), "%04x", getNodeNum() & 0x0ffff);
#endif
snprintf(owner.id, sizeof(owner.id), "!%08x", getNodeNum()); // Default node ID now based on nodenum
memcpy(owner.macaddr, ourMacAddr, sizeof(owner.macaddr));
owner.has_is_unmessagable = true;
@@ -1637,6 +1796,114 @@ LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t
return state;
}
#if WARM_NODE_COUNT > 0
void NodeDB::demoteOldestHotNodesToWarm()
{
const int keep = MAX_NUM_NODES;
if (numMeshNodes <= keep)
return;
// Protected nodes (favorite/ignored/verified) outrank recency and are demoted
// only when the store is full of them; within a class, most-recently-heard
// wins. Index 0 is self and stays put (sort from +1), as in runtime eviction.
std::sort(meshNodes->begin() + 1, meshNodes->begin() + numMeshNodes,
[](const meshtastic_NodeInfoLite &a, const meshtastic_NodeInfoLite &b) {
const bool ka = nodeInfoLiteIsProtected(&a);
const bool kb = nodeInfoLiteIsProtected(&b);
if (ka != kb)
return ka;
return a.last_heard > b.last_heard;
});
int demoted = 0;
for (int i = keep; i < numMeshNodes; i++) {
const meshtastic_NodeInfoLite &n = (*meshNodes)[i];
if (n.num == 0)
continue;
// Keep the public key if we have one (40 B warm record); keyless nodes
// still get a placeholder so re-admission restores last_heard.
warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr);
// Demotion drops the node from the header table, so drop its satellites
// too (the eviction chokepoint) — they'd otherwise orphan until the next
// enforceSatelliteCaps pass.
eraseNodeSatellites(n.num);
demoted++;
}
numMeshNodes = keep; // the resize() in loadFromDisk reclaims the demoted tail
LOG_MIGRATION("NodeDB migration: demoted %d node(s) over %d into the warm tier (keepers preferred)", demoted, keep);
}
#endif
void NodeDB::nodeDBSelfCare()
{
if (!meshNodes)
return;
const NodeNum self = getNodeNum();
const bool nodesOverCap = numMeshNodes > MAX_NUM_NODES;
// Confirm self is present and its key matches what we just (re)derived. A
// non-empty DB that doesn't contain us means a foreign/over-cap or corrupt
// nodes.proto was loaded; an empty DB is just a fresh device (no warning).
meshtastic_NodeInfoLite *selfNode = getMeshNode(self);
if (!selfNode && numMeshNodes > 0) {
LOG_WARN("NodeDB self-care: self 0x%08x absent from DB, re-adding", (unsigned)self);
} else if (selfNode && owner.public_key.size == 32 && selfNode->public_key.size == 32 &&
memcmp(selfNode->public_key.bytes, owner.public_key.bytes, 32) != 0) {
LOG_WARN("NodeDB self-care: self 0x%08x key mismatch, refreshing", (unsigned)self);
}
// Maintenance that must never touch self. Pin self to index 0 first so
// the positional demote/eviction scans (which skip index 0) provably exclude
// us, wherever the loaded file happened to place our row.
if (selfNode && numMeshNodes > 0 && selfNode != &meshNodes->at(0)) {
std::swap(meshNodes->at(0), *selfNode);
}
#if WARM_NODE_COUNT > 0
if (nodesOverCap)
demoteOldestHotNodesToWarm(); // demotes oldest NON-self overflow; index 0 (us) left in place
#endif
if (numMeshNodes > MAX_NUM_NODES) {
LOG_WARN("NodeDB self-care: %d over cap %d, truncating", numMeshNodes, MAX_NUM_NODES);
numMeshNodes = MAX_NUM_NODES;
}
// Normalise the backing store to the hot cap so getOrCreateMeshNode always
// has spare slots to append into (it indexes meshNodes->at(numMeshNodes++)).
meshNodes->resize(MAX_NUM_NODES);
const bool satsTrimmed = enforceSatelliteCaps();
// Ensure self exists, sits at index 0, and carries current owner info — after
// any demotion has freed a slot. Covers the foreign/fixture case where the
// loaded file did not contain us at all.
meshtastic_NodeInfoLite *info = getOrCreateMeshNode(self);
if (info) {
TypeConversions::CopyUserToNodeInfoLite(info, owner);
if (info != &meshNodes->at(0))
std::swap(meshNodes->at(0), *info);
}
// One-shot rewrite: only when we healed something, and never while storage
// is locked — a locked boot loads placeholder defaults that must not be written
// over the encrypted store; reloadFromDisk() re-runs self-care once unlocked.
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
const bool storageLocked = EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked();
#else
const bool storageLocked = false;
#endif
if ((nodesOverCap || satsTrimmed) && !storageLocked) {
LOG_MIGRATION("NodeDB self-care: healed store (nodes-over-cap:%s sats-trimmed:%s); rewriting nodes.proto once",
nodesOverCap ? "yes" : "no", satsTrimmed ? "yes" : "no");
saveNodeDatabaseToDisk();
#if WARM_NODE_COUNT > 0
warmStore.saveIfDirty();
#endif
}
}
void NodeDB::loadFromDisk()
{
// Mark the current device state as completely unusable, so that if we fail reading the entire file from
@@ -1650,6 +1917,8 @@ void NodeDB::loadFromDisk()
storageCorruptThisLoad = false;
#endif
migrationSavePending = false;
meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero;
#ifdef ARCH_ESP32
@@ -1659,6 +1928,7 @@ void NodeDB::loadFromDisk()
rmDir("/static/static"); // Remove bad static web files bundle from initial 2.5.13 release
spiLock->unlock();
#endif
#ifdef FSCom
#if defined(FACTORY_INSTALL) && !defined(ARCH_PORTDUINO)
spiLock->lock();
@@ -1673,7 +1943,7 @@ void NodeDB::loadFromDisk()
}
}
spiLock->unlock();
#endif
#endif // FACTORY_INSTALL, not PORTDUINO
spiLock->lock();
if (FSCom.exists(legacyPrefFileName)) {
spiLock->unlock();
@@ -1691,7 +1961,8 @@ void NodeDB::loadFromDisk()
spiLock->unlock();
}
#endif
#endif // FSCom
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// Only take the locked-boot defaults path when lockdown is ACTIVE (the
// device is provisioned) AND storage is still locked. A lockdown-capable
@@ -1745,7 +2016,7 @@ void NodeDB::loadFromDisk()
installDefaultNodeDatabase();
} else if (nodeDatabase.version < DEVICESTATE_CUR_VER) {
if (migrateLegacyNodeDatabase())
saveNodeDatabaseToDisk();
migrationSavePending = true;
else
installDefaultNodeDatabase();
} else {
@@ -1758,33 +2029,40 @@ void NodeDB::loadFromDisk()
#else
0u;
#endif
const unsigned telCount =
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
(unsigned)nodeTelemetry.size();
#else
0u;
#endif
const unsigned envCount =
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
(unsigned)nodeEnvironment.size();
#else
0u;
#endif
const unsigned statusCount =
#if !MESHTASTIC_EXCLUDE_STATUSDB
(unsigned)nodeStatus.size();
#else
0u;
#endif
LOG_INFO("Loaded saved nodedatabase v%d: %d nodes, %u pos, %u tel, %u env, %u status", nodeDatabase.version,
nodeDatabase.nodes.size(), posCount, telCount, envCount, statusCount);
}
if (numMeshNodes > MAX_NUM_NODES) {
LOG_WARN("Node count %d exceeds MAX_NUM_NODES %d, truncating", numMeshNodes, MAX_NUM_NODES);
numMeshNodes = MAX_NUM_NODES;
}
meshNodes->resize(MAX_NUM_NODES);
// Left UNTRIMMED on purpose: trim/demote/satellite-cap/self-pin/rewrite all
// run in nodeDBSelfCare() once getNodeNum() is valid (still 0 here on a cold
// boot, so we could only assume index 0 == self — the very bug being fixed).
#if WARM_NODE_COUNT > 0
// Load the warm tier so its on-disk snapshot is available before the node DB
// is exercised (and before nodeDBSelfCare() demotes any overflow into it).
warmStore.load();
#endif
// static DeviceState scratch; We no longer read into a tempbuf because this structure is 15KB of valuable RAM
state = loadProto(deviceStateFileName, meshtastic_DeviceState_size, sizeof(meshtastic_DeviceState),
@@ -1856,18 +2134,23 @@ void NodeDB::loadFromDisk()
#ifdef USERPREFS_CONFIG_LORA_REGION
config.lora.region = USERPREFS_CONFIG_LORA_REGION;
#endif
#ifdef USERPREFS_LORACONFIG_USE_PRESET
config.lora.use_preset = USERPREFS_LORACONFIG_USE_PRESET;
#endif
#ifdef USERPREFS_LORACONFIG_BANDWIDTH
config.lora.bandwidth = USERPREFS_LORACONFIG_BANDWIDTH;
#endif
#ifdef USERPREFS_LORACONFIG_SPREAD_FACTOR
config.lora.spread_factor = USERPREFS_LORACONFIG_SPREAD_FACTOR;
#endif
#ifdef USERPREFS_LORACONFIG_CODING_RATE
config.lora.coding_rate = USERPREFS_LORACONFIG_CODING_RATE;
#endif
#ifdef USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY
config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY;
#endif
@@ -1884,6 +2167,7 @@ void NodeDB::loadFromDisk()
#if defined(USERPREFS_USE_ADMIN_KEY_0) || defined(USERPREFS_USE_ADMIN_KEY_1) || defined(USERPREFS_USE_ADMIN_KEY_2)
uint16_t sum = 0;
#endif
#ifdef USERPREFS_USE_ADMIN_KEY_0
for (uint8_t b = 0; b < 32; b++) {
@@ -2076,6 +2360,17 @@ bool NodeDB::reloadFromDisk()
return false;
}
// loadFromDisk() leaves the store untrimmed; run self-care now (getNodeNum()
// is valid at runtime) to trim/demote non-self overflow, pin self to index 0
// and normalise the backing store before the node DB is exercised again.
nodeDBSelfCare();
// Preserve constructor ordering: persist any migration only after self-care.
if (migrationSavePending) {
saveNodeDatabaseToDisk();
migrationSavePending = false;
}
// Push the now-real config to the radio.
if (rIface) {
channels.onConfigChanged();
@@ -2196,6 +2491,7 @@ bool NodeDB::saveChannelsToDisk()
FSCom.mkdir("/prefs");
spiLock->unlock();
#endif
return saveProto(channelFileName, meshtastic_ChannelFile_size, &meshtastic_ChannelFile_msg, &channelFile);
}
@@ -2251,6 +2547,7 @@ bool NodeDB::saveNodeDatabaseToDisk()
FSCom.mkdir("/prefs");
spiLock->unlock();
#endif
// Project the maps into the on-disk vectors just before encoding; cleared
// again on the way out so we don't carry duplicate state.
concurrency::LockGuard guard(&satelliteMutex);
@@ -2267,6 +2564,7 @@ bool NodeDB::saveNodeDatabaseToDisk()
#else
nodeDatabase.positions.clear();
#endif
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
nodeDatabase.telemetry.clear();
nodeDatabase.telemetry.reserve(nodeTelemetry.size());
@@ -2280,6 +2578,7 @@ bool NodeDB::saveNodeDatabaseToDisk()
#else
nodeDatabase.telemetry.clear();
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
nodeDatabase.environment.clear();
nodeDatabase.environment.reserve(nodeEnvironment.size());
@@ -2293,6 +2592,7 @@ bool NodeDB::saveNodeDatabaseToDisk()
#else
nodeDatabase.environment.clear();
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
nodeDatabase.status.clear();
nodeDatabase.status.reserve(nodeStatus.size());
@@ -2319,6 +2619,11 @@ bool NodeDB::saveNodeDatabaseToDisk()
nodeDatabase.environment.shrink_to_fit();
nodeDatabase.status.clear();
nodeDatabase.status.shrink_to_fit();
#if WARM_NODE_COUNT > 0
// Same cadence as the node DB; failure is logged but must not propagate —
// a false return from here would trigger saveToDisk()'s fsFormat() path.
warmStore.saveIfDirty();
#endif
return ok;
}
@@ -2354,6 +2659,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
FSCom.mkdir("/prefs");
spiLock->unlock();
#endif
if (saveWhat & SEGMENT_CONFIG) {
config.has_device = true;
config.has_display = true;
@@ -2555,6 +2861,7 @@ void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSou
#else
{
concurrency::LockGuard guard(&satelliteMutex);
evictSatelliteOverCap(*this, nodePositions, nodeId);
meshtastic_PositionLite &slot = nodePositions[nodeId]; // creates default-zero entry if missing
if (src == RX_SRC_LOCAL) {
@@ -2612,8 +2919,10 @@ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxS
}
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
concurrency::LockGuard guard(&satelliteMutex);
evictSatelliteOverCap(*this, nodeTelemetry, nodeId);
nodeTelemetry[nodeId] = t.variant.device_metrics;
#endif
} else if (t.which_variant == meshtastic_Telemetry_environment_metrics_tag) {
if (src == RX_SRC_LOCAL) {
LOG_DEBUG("updateTelemetry LOCAL env");
@@ -2622,8 +2931,10 @@ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxS
}
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
concurrency::LockGuard guard(&satelliteMutex);
evictSatelliteOverCap(*this, nodeEnvironment, nodeId);
nodeEnvironment[nodeId] = t.variant.environment_metrics;
#endif
} else {
return; // air_quality / power / local_stats / health: not stored per-node
}
@@ -2652,13 +2963,13 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
info->num = contact.node_num;
TypeConversions::CopyUserToNodeInfoLite(info, contact.user);
if (contact.should_ignore) {
// If should_ignore is set,
// we need to clear the public key and other cruft, in addition to setting the node as ignored
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
// Block the contact and drop its rich satellite data, but keep the
// public key copied above — an ignored peer keeps a usable identity
// (a verifiable target) rather than a bare node number.
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_IGNORED_MASK, true))
LOG_WARN(PROTECTED_CAP_WARN_FMT, "ignore", contact.node_num, MAX_NUM_NODES - 2);
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false);
eraseNodeSatellites(contact.node_num);
info->public_key.size = 0;
memset(info->public_key.bytes, 0, sizeof(info->public_key.bytes));
} else {
/* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with
* public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM!
@@ -2677,12 +2988,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact)
} else {
// Normal case: set is_favorite to prevent expiration.
// last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB).
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
// If the protected cap refuses the favorite, fall back to stamping last_heard so the
// contact still isn't the first eviction victim.
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true))
info->last_heard = getTime();
}
// As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified
if (contact.manually_verified) {
nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true);
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true))
LOG_WARN(PROTECTED_CAP_WARN_FMT, "verify", contact.node_num, MAX_NUM_NODES - 2);
}
// Mark the node's key as manually verified to indicate trustworthiness.
updateGUIforNode = info;
@@ -2815,14 +3130,48 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
}
}
void NodeDB::set_favorite(bool is_favorite, uint32_t nodeId)
int NodeDB::numProtectedNodes() const
{
int count = 0;
for (int i = 0; i < numMeshNodes; i++)
if (nodeInfoLiteIsProtected(&meshNodes->at(i)))
count++;
return count;
}
bool NodeDB::setProtectedFlag(meshtastic_NodeInfoLite *node, uint32_t mask, bool on)
{
if (!node)
return false;
if (!on) {
nodeInfoLiteSetBit(node, mask, false);
return true;
}
// Adding a flag to a node that is already protected doesn't grow the
// protected set, so it's always allowed. A newly-protected node is refused
// once the protected set has reached MAX_NUM_NODES-2, leaving two evictable
// slots so getOrCreateMeshNode can always make room.
if (nodeInfoLiteIsProtected(node) || numProtectedNodes() < MAX_NUM_NODES - 2) {
nodeInfoLiteSetBit(node, mask, true);
return true;
}
return false;
}
bool NodeDB::set_favorite(bool is_favorite, uint32_t nodeId)
{
meshtastic_NodeInfoLite *lite = getMeshNode(nodeId);
if (lite && nodeInfoLiteIsFavorite(lite) != is_favorite) {
nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_IS_FAVORITE_MASK, is_favorite);
if (!lite)
return false;
if (nodeInfoLiteIsFavorite(lite) == is_favorite)
return true; // already in the requested state
if (setProtectedFlag(lite, NODEINFO_BITFIELD_IS_FAVORITE_MASK, is_favorite)) {
sortMeshDB();
saveNodeDatabaseToDisk();
return true;
}
LOG_WARN(PROTECTED_CAP_WARN_FMT, "favorite", nodeId, MAX_NUM_NODES - 2);
return false;
}
bool NodeDB::isFavorite(uint32_t nodeId)
@@ -2952,6 +3301,30 @@ bool NodeDB::isFull()
return (numMeshNodes >= MAX_NUM_NODES) || (memGet.getFreeHeap() < MINIMUM_SAFE_FREE_HEAP);
}
uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const
{
for (int i = 0; i < numMeshNodes; i++)
if (meshNodes->at(i).num == n)
return meshNodes->at(i).last_heard;
return 0;
}
bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
const meshtastic_NodeInfoLite *info = getMeshNode(n);
if (info && info->public_key.size == 32) {
out = info->public_key;
return true;
}
#if WARM_NODE_COUNT > 0
if (warmStore.copyKey(n, out.bytes)) {
out.size = 32;
return true;
}
#endif
return false;
}
/// Find a node in our DB, create an empty NodeInfo if missing
meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
{
@@ -2988,7 +3361,14 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
}
if (oldestIndex != -1) {
eraseNodeSatellites(meshNodes->at(oldestIndex).num);
const meshtastic_NodeInfoLite &evicted = meshNodes->at(oldestIndex);
#if WARM_NODE_COUNT > 0
// Demote to the warm tier so the identity (and crucially the
// PKI key) outlives the hot-store slot.
warmStore.absorb(evicted.num, evicted.last_heard,
evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL);
#endif
eraseNodeSatellites(evicted.num);
// Shove the remaining nodes down the chain
for (int i = oldestIndex; i < numMeshNodes - 1; i++) {
meshNodes->at(i) = meshNodes->at(i + 1);
@@ -2996,12 +3376,33 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
(numMeshNodes)--;
}
}
// Don't append past the end of the vector. The protected-node cap
// (numProtectedNodes() <= MAX_NUM_NODES-2) means the eviction above frees
// a slot in normal operation; this guards the legacy case of a pre-cap
// database that is full of protected nodes — refuse rather than overrun.
if (numMeshNodes >= MAX_NUM_NODES)
return NULL;
// Pre-size before append when run before nodeDBSelfCare() (boot keygen); else at() aborts on nRF52.
if (static_cast<size_t>(numMeshNodes) >= meshNodes->size())
meshNodes->resize(numMeshNodes + 1);
// add the node at the end
lite = &meshNodes->at((numMeshNodes)++);
// everything is missing except the nodenum
memset(lite, 0, sizeof(*lite));
lite->num = n;
#if WARM_NODE_COUNT > 0
// Re-admission: restore what the warm tier kept for this node
WarmNodeEntry warm;
if (warmStore.take(n, warm)) {
lite->last_heard = warm.last_heard;
if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) {
lite->public_key.size = 32;
memcpy(lite->public_key.bytes, warm.public_key, 32);
}
LOG_MIGRATION("Rehydrated node 0x%x from warm tier (key=%d)", n, lite->public_key.size == 32);
}
#endif
LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap());
}