#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_GPS
#include "GPS.h"
#endif
#include "../detect/ScanI2C.h"
#include "Channels.h"
#include "CryptoEngine.h"
#include "Default.h"
#include "FSCommon.h"
#include "MeshRadio.h"
#include "MeshService.h"
#include "MessageStore.h"
#include "NodeDB.h"
#include "PacketHistory.h"
#include "PowerFSM.h"
#include "RadioInterface.h"
#include "Router.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "TransmitHistory.h"
#include "TypeConversions.h"
#include "UptimeClock.h"
#include "error.h"
#include "gps/RTC.h"
#include "main.h"
#include "memory/MemAudit.h"
#include "mesh-pb-constants.h"
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
#include "meshUtils.h"
#include "modules/NeighborInfoModule.h"
#include "target_specific.h"
#if HAS_VARIABLE_HOPS
#include "modules/HopScalingModule.h"
#endif
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
#include "xmodem.h"
#include <ErriezCRC32.h>
#include <algorithm>
#include <pb_decode.h>
#include <pb_encode.h>
#include <power/PowerHAL.h>
#include <vector>

#ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "security/EncryptedStorage.h"
#include "security/SecureZero.h"
#endif

#ifdef ARCH_ESP32
#if HAS_WIFI
#include "mesh/wifi/WiFiAPClient.h"
#endif
#include "SPILock.h"
#include "modules/StoreForwardModule.h"
#include <Preferences.h>
#include <nvs_flash.h>
#endif

#ifdef ARCH_PORTDUINO
#include "modules/StoreForwardModule.h"
#include "platform/portduino/PortduinoGlue.h"
#endif

#ifdef ARCH_NRF52
#include <bluefruit.h>
#include <utility/bonding.h>
#endif

#ifdef ARCH_RP2040
#include <hardware/watchdog.h>
#endif

#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
#include <MeshtasticOTA.h>
#endif

NodeDB *nodeDB = nullptr;

// we have plenty of ram so statically alloc this tempbuf (for now)
EXT_RAM_BSS_ATTR meshtastic_DeviceState devicestate;
meshtastic_MyNodeInfo &myNodeInfo = devicestate.my_node;
meshtastic_NodeDatabase nodeDatabase;
meshtastic_LocalConfig config;
meshtastic_DeviceUIConfig uiconfig{.screen_brightness = 153, .screen_timeout = 30};
meshtastic_LocalModuleConfig moduleConfig;
meshtastic_ChannelFile channelFile;

#ifdef USERPREFS_USE_ADMIN_KEY_0
static unsigned char userprefs_admin_key_0[] = USERPREFS_USE_ADMIN_KEY_0;
#endif
#ifdef USERPREFS_USE_ADMIN_KEY_1
static unsigned char userprefs_admin_key_1[] = USERPREFS_USE_ADMIN_KEY_1;
#endif
#ifdef USERPREFS_USE_ADMIN_KEY_2
static unsigned char userprefs_admin_key_2[] = USERPREFS_USE_ADMIN_KEY_2;
#endif

// Weak empty variant initialization function.
// May be redefined by variant files.
// noinline: weak default and call site share this TU, so LTO would inline the empty body and
// never link the variant's strong override. Same guard as earlyInitVariant() in main.cpp.
__attribute__((noinline)) void variantDefaultConfig() __attribute__((weak));
__attribute__((noinline)) void variantDefaultConfig() {}

__attribute__((noinline)) void variantDefaultModuleConfig() __attribute__((weak));
__attribute__((noinline)) void variantDefaultModuleConfig() {}

#ifdef HELTEC_MESH_NODE_T114

uint32_t read8(uint8_t bits, uint8_t dummy, uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst)
{
    uint32_t ret = 0;
    uint8_t SDAPIN = mosi;
    pinMode(SDAPIN, INPUT_PULLUP);
    digitalWrite(dc, HIGH);
    for (int i = 0; i < dummy; i++) { // any dummy clocks
        digitalWrite(sck, HIGH);
        delay(1);
        digitalWrite(sck, LOW);
        delay(1);
    }
    for (int i = 0; i < bits; i++) { // read results
        ret <<= 1;
        delay(1);
        if (digitalRead(SDAPIN))
            ret |= 1;
        ;
        digitalWrite(sck, HIGH);
        delay(1);
        digitalWrite(sck, LOW);
    }
    return ret;
}

void write9(uint8_t val, uint8_t dc_val, uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst)
{
    pinMode(mosi, OUTPUT);
    digitalWrite(dc, dc_val);
    for (int i = 0; i < 8; i++) { // send command
        digitalWrite(mosi, (val & 0x80) != 0);
        delay(1);
        digitalWrite(sck, HIGH);
        delay(1);
        digitalWrite(sck, LOW);
        val <<= 1;
    }
}

uint32_t readwrite8(uint8_t cmd, uint8_t bits, uint8_t dummy, uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst)
{
    digitalWrite(cs, LOW);
    write9(cmd, 0, cs, sck, mosi, dc, rst);
    uint32_t ret = read8(bits, dummy, cs, sck, mosi, dc, rst);
    digitalWrite(cs, HIGH);
    return ret;
}

uint32_t get_st7789_id(uint8_t cs, uint8_t sck, uint8_t mosi, uint8_t dc, uint8_t rst)
{
    pinMode(cs, OUTPUT);
    digitalWrite(cs, HIGH);
    pinMode(cs, OUTPUT);
    pinMode(sck, OUTPUT);
    pinMode(mosi, OUTPUT);
    pinMode(dc, OUTPUT);
    pinMode(rst, OUTPUT);
    digitalWrite(rst, LOW); // Hardware Reset
    delay(10);
    digitalWrite(rst, HIGH);
    delay(10);

    readwrite8(0x04, 24, 1, cs, sck, mosi, dc, rst);
    uint32_t ID = readwrite8(0x04, 24, 1, cs, sck, mosi, dc, rst); // ST7789 needs twice
    return ID;
}

#endif

// When armed by loadFromDisk, the decode callback writes satellite entries
// straight into these maps instead of the temp vectors. Nullptr = legacy
// push_back-to-vector path for backup/restore and other decoders.
namespace
{
#if !MESHTASTIC_EXCLUDE_POSITIONDB
std::map<NodeNum, meshtastic_PositionLite> *s_decodePositionsTarget = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
std::map<NodeNum, meshtastic_DeviceMetrics> *s_decodeTelemetryTarget = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
std::map<NodeNum, meshtastic_EnvironmentMetrics> *s_decodeEnvironmentTarget = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
std::map<NodeNum, meshtastic_StatusMessage> *s_decodeStatusTarget = nullptr;
#endif
} // namespace

bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostream, const pb_field_t *field)
{
    const auto *iter = reinterpret_cast<const pb_field_iter_t *>(field);
    switch (iter->tag) {
    case meshtastic_NodeDatabase_nodes_tag: {
        if (ostream) {
            const auto *vec = static_cast<const std::vector<meshtastic_NodeInfoLite> *>(iter->pData);
            for (auto item : *vec) {
                // Round rather than truncate: truncation wiped any |SNR| < 0.25 dB to exactly
                // 0, which collided with the "never stored" sentinel below.
                item.snr_q4 = (int32_t)lroundf(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))
                    return false;
            }
        }
        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)) {
                // snr_q4 = 0 is byte-identical to "field never written" but 0 dB is valid.
                // NODEINFO_BITFIELD_HAS_SNR_MASK disambiguates going forward; legacy
                // records (bit clear) treat this as unknown.
                if (nodeInfoLiteHasSnr(&node)) {
                    node.snr = node.snr_q4 / 4.0f;
                } else if (node.snr_q4) {
                    node.snr = node.snr_q4 / 4.0f;
                }
                node.snr_q4 = 0;
                vec->push_back(node);
            }
        }
        return true;
    }
    case meshtastic_NodeDatabase_positions_tag: {
        if (ostream) {
            const auto *vec = static_cast<const std::vector<meshtastic_NodePositionEntry> *>(iter->pData);
            for (auto item : *vec) {
                if (!pb_encode_tag_for_field(ostream, iter))
                    return false;
                if (!pb_encode_submessage(ostream, meshtastic_NodePositionEntry_fields, &item))
                    return false;
            }
        }
        if (istream && istream->bytes_left) {
            meshtastic_NodePositionEntry entry = meshtastic_NodePositionEntry_init_zero;
            if (pb_decode(istream, meshtastic_NodePositionEntry_fields, &entry)) {
#if !MESHTASTIC_EXCLUDE_POSITIONDB
                if (s_decodePositionsTarget) {
                    if (entry.has_position)
                        (*s_decodePositionsTarget)[entry.num] = entry.position;
                    return true;
                }
#endif
                auto *vec = static_cast<std::vector<meshtastic_NodePositionEntry> *>(iter->pData);
                vec->push_back(entry);
            }
        }
        return true;
    }
    case meshtastic_NodeDatabase_telemetry_tag: {
        if (ostream) {
            const auto *vec = static_cast<const std::vector<meshtastic_NodeTelemetryEntry> *>(iter->pData);
            for (auto item : *vec) {
                if (!pb_encode_tag_for_field(ostream, iter))
                    return false;
                if (!pb_encode_submessage(ostream, meshtastic_NodeTelemetryEntry_fields, &item))
                    return false;
            }
        }
        if (istream && istream->bytes_left) {
            meshtastic_NodeTelemetryEntry entry = meshtastic_NodeTelemetryEntry_init_zero;
            if (pb_decode(istream, meshtastic_NodeTelemetryEntry_fields, &entry)) {
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
                if (s_decodeTelemetryTarget) {
                    if (entry.has_device_metrics)
                        (*s_decodeTelemetryTarget)[entry.num] = entry.device_metrics;
                    return true;
                }
#endif
                auto *vec = static_cast<std::vector<meshtastic_NodeTelemetryEntry> *>(iter->pData);
                vec->push_back(entry);
            }
        }
        return true;
    }
    case meshtastic_NodeDatabase_status_tag: {
        if (ostream) {
            const auto *vec = static_cast<const std::vector<meshtastic_NodeStatusEntry> *>(iter->pData);
            for (auto item : *vec) {
                if (!pb_encode_tag_for_field(ostream, iter))
                    return false;
                if (!pb_encode_submessage(ostream, meshtastic_NodeStatusEntry_fields, &item))
                    return false;
            }
        }
        if (istream && istream->bytes_left) {
            meshtastic_NodeStatusEntry entry = meshtastic_NodeStatusEntry_init_zero;
            if (pb_decode(istream, meshtastic_NodeStatusEntry_fields, &entry)) {
#if !MESHTASTIC_EXCLUDE_STATUSDB
                if (s_decodeStatusTarget) {
                    if (entry.has_status)
                        (*s_decodeStatusTarget)[entry.num] = entry.status;
                    return true;
                }
#endif
                auto *vec = static_cast<std::vector<meshtastic_NodeStatusEntry> *>(iter->pData);
                vec->push_back(entry);
            }
        }
        return true;
    }
    case meshtastic_NodeDatabase_environment_tag: {
        if (ostream) {
            const auto *vec = static_cast<const std::vector<meshtastic_NodeEnvironmentEntry> *>(iter->pData);
            for (auto item : *vec) {
                if (!pb_encode_tag_for_field(ostream, iter))
                    return false;
                if (!pb_encode_submessage(ostream, meshtastic_NodeEnvironmentEntry_fields, &item))
                    return false;
            }
        }
        if (istream && istream->bytes_left) {
            meshtastic_NodeEnvironmentEntry entry = meshtastic_NodeEnvironmentEntry_init_zero;
            if (pb_decode(istream, meshtastic_NodeEnvironmentEntry_fields, &entry)) {
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
                if (s_decodeEnvironmentTarget) {
                    if (entry.has_environment_metrics)
                        (*s_decodeEnvironmentTarget)[entry.num] = entry.environment_metrics;
                    return true;
                }
#endif
                auto *vec = static_cast<std::vector<meshtastic_NodeEnvironmentEntry> *>(iter->pData);
                vec->push_back(entry);
            }
        }
        return true;
    }
    default:
        return true;
    }
}

void NodeDB::armNodeDatabaseDecodeTargets()
{
#if !MESHTASTIC_EXCLUDE_POSITIONDB
    nodePositions.clear();
    s_decodePositionsTarget = &nodePositions;
#endif
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
    nodeTelemetry.clear();
    s_decodeTelemetryTarget = &nodeTelemetry;
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
    nodeEnvironment.clear();
    s_decodeEnvironmentTarget = &nodeEnvironment;
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
    nodeStatus.clear();
    s_decodeStatusTarget = &nodeStatus;
#endif
}

void NodeDB::disarmNodeDatabaseDecodeTargets()
{
#if !MESHTASTIC_EXCLUDE_POSITIONDB
    s_decodePositionsTarget = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
    s_decodeTelemetryTarget = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
    s_decodeEnvironmentTarget = nullptr;
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
    s_decodeStatusTarget = nullptr;
#endif
}

/** The current change # for radio settings.  Starts at 0 on boot and any time the radio settings
 * might have changed is incremented.  Allows others to detect they might now be on a new channel.
 */
uint32_t radioGeneration;

// getMacAddr() and getDeviceId() are the per-architecture hooks declared in target_specific.h.

/**
 *
 * Normally userids are unique and start with +country code to look like Signal phone numbers.
 * But there are some special ids used when we haven't yet been configured by a user.  In that case
 * we use !macaddr (no colons).
 */
meshtastic_User &owner = devicestate.owner;

// The slim NodeInfoLite header defines the local long_name cap; the wire-facing
// meshtastic_User stays wider so names from senders built against the older
// 39-byte limit still decode (nanopb halts on string overflow).
static_assert(MAX_LONG_NAME_BYTES + 1 == sizeof(meshtastic_NodeInfoLite::long_name),
              "MAX_LONG_NAME_BYTES must match the NodeInfoLite storage width");
static_assert(sizeof(meshtastic_User::long_name) > MAX_LONG_NAME_BYTES,
              "wire User.long_name must be wider than the local cap so clampLongName stays in bounds");

meshtastic_Position localPosition = meshtastic_Position_init_default;
meshtastic_CriticalErrorCode error_code =
    meshtastic_CriticalErrorCode_NONE; // For the error code, only show values from this boot (discard value from flash)
uint32_t error_address = 0;

static uint8_t ourMacAddr[6];

NodeDB::NodeDB()
{
    LOG_INFO("Init NodeDB");
    loadFromDisk();
    cleanupMeshDB();

    uint32_t devicestateCRC = crc32Buffer(&devicestate, sizeof(devicestate));
    uint32_t nodeDatabaseCRC = crc32Buffer(&nodeDatabase, sizeof(nodeDatabase));
    uint32_t configCRC = crc32Buffer(&config, sizeof(config));
    uint32_t channelFileCRC = crc32Buffer(&channelFile, sizeof(channelFile));

    int saveWhat = 0;
    // Re-read the device id from silicon each boot via the per-arch getDeviceId(); clear the
    // disk-loaded value first so a failed/empty derivation leaves it unset rather than stale.
    myNodeInfo.device_id.size = 0;
    memset(myNodeInfo.device_id.bytes, 0, sizeof(myNodeInfo.device_id.bytes));
    if (getDeviceId(myNodeInfo.device_id.bytes)) {
        myNodeInfo.device_id.size = sizeof(myNodeInfo.device_id.bytes);
    }

    // likewise - we always want the app requirements to come from the running appload
    myNodeInfo.min_app_version = 30200; // format is Mmmss (where M is 1+the numeric major number. i.e. 30200 means 2.2.00

    // likewise the edition: it lives in persisted devicestate, so a vanilla install must
    // overwrite the previous event build's value. Before the CRC compare, so the change persists.
#ifdef USERPREFS_FIRMWARE_EDITION
    myNodeInfo.firmware_edition = USERPREFS_FIRMWARE_EDITION;
#else
    myNodeInfo.firmware_edition = meshtastic_FirmwareEdition_VANILLA;
#endif
    pickNewNodeNum();

    // Set our board type so we can share it with others
    owner.hw_model = HW_VENDOR;
    // Ensure user (nodeinfo) role is set to whatever we're configured to
    owner.role = config.device.role;
    // Ensure macaddr is set to our macaddr as it will be copied in our info below
    memcpy(owner.macaddr, ourMacAddr, sizeof(owner.macaddr));
    // Ensure owner.id is always derived from the node number
    snprintf(owner.id, sizeof(owner.id), "!%08x", getNodeNum());

    if (!config.has_security) {
        config.has_security = true;
        config.security = meshtastic_Config_SecurityConfig_init_default;
        config.security.serial_enabled = config.device.serial_enabled;
        config.security.is_managed = config.device.is_managed;
    }

#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
    // Generate crypto keys if needed using consolidated function
    // Set my node num uint32 value to bytes from the public key (if we have one)
    // Generate identity and crypto keys if needed; this will create a new identity if one does not exist.
    // Skip on a degraded boot: the keypair isn't in RAM, so minting one would change our NodeNum.
    if (!configDecodeFailed)
        generateCryptoKeyPair(nullptr);
#elif !(MESHTASTIC_EXCLUDE_PKI)
    // Calculate Curve25519 public and private keys
    if (config.security.private_key.size == 32 && config.security.public_key.size == 32) {
        owner.public_key.size = config.security.public_key.size;
        memcpy(owner.public_key.bytes, config.security.public_key.bytes, config.security.public_key.size);
        crypto->setDHPrivateKey(config.security.private_key.bytes);
        // Set my node num uint32 value to bytes from the new public key
        myNodeInfo.my_node_num = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size);
    }
#endif
    // 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
    if (!FSCom.exists(nodeDatabaseFileName)) {
        saveNodeDatabaseToDisk();
    }
#endif

#ifdef ARCH_ESP32
    Preferences preferences;
    preferences.begin("meshtastic", false);
    myNodeInfo.reboot_count = preferences.getUInt("rebootCounter", 0);
    preferences.end();
    LOG_DEBUG("Device reboots: %d", myNodeInfo.reboot_count);
#endif

    // UA_868 is obsolete; migrate to EU_868 before resetRadioConfig() below validates the region.
    if (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UA_868) {
        LOG_INFO("UA_868 obsolete, migrating config to EU_868");
        config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868;
    }

    resetRadioConfig(); // If bogus settings got saved, then fix them
    // nodeDB->LOG_DEBUG("region=%d, NODENUM=0x%x, dbsize=%d", config.lora.region, myNodeInfo.my_node_num, numMeshNodes);

    // Uncomment below to always enable UDP broadcasts
    // config.network.enabled_protocols = meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST;

    // If we are setup to broadcast on any default channel slot (with default frequency slot semantics),
    // ensure that the telemetry intervals are coerced to the role-aware minimum value.
    if (channels.hasDefaultChannel()) {
        LOG_DEBUG("Coerce telemetry to role-aware min on defaults");
        moduleConfig.telemetry.device_update_interval = Default::getConfiguredOrMinimumValue(
            moduleConfig.telemetry.device_update_interval, min_default_telemetry_interval_secs);
        moduleConfig.telemetry.environment_update_interval = Default::getConfiguredOrMinimumValue(
            moduleConfig.telemetry.environment_update_interval, min_default_telemetry_interval_secs);
        moduleConfig.telemetry.air_quality_interval = Default::getConfiguredOrMinimumValue(
            moduleConfig.telemetry.air_quality_interval, min_default_telemetry_interval_secs);
        moduleConfig.telemetry.power_update_interval = Default::getConfiguredOrMinimumValue(
            moduleConfig.telemetry.power_update_interval, min_default_telemetry_interval_secs);
        moduleConfig.telemetry.health_update_interval = Default::getConfiguredOrMinimumValue(
            moduleConfig.telemetry.health_update_interval, min_default_telemetry_interval_secs);
    }
    // Enforce position broadcast minimums if we would send positions over a default channel
    // Check channels the same way PositionModule::sendOurPosition() does - first channel with position_precision set
    bool positionUsesDefaultChannel = false;
    for (uint8_t i = 0; i < channels.getNumChannels(); i++) {
        if (channels.getByIndex(i).settings.has_module_settings &&
            channels.getByIndex(i).settings.module_settings.position_precision != 0) {
            positionUsesDefaultChannel = channels.isDefaultChannel(i);
            break;
        }
    }
    if (positionUsesDefaultChannel) {
        LOG_DEBUG("Coerce position broadcasts to role-aware min and smart broadcast min of 5 min on defaults");
        config.position.position_broadcast_secs =
            Default::getConfiguredOrMinimumValue(config.position.position_broadcast_secs, min_default_broadcast_interval_secs);
        config.position.broadcast_smart_minimum_interval_secs = Default::getConfiguredOrMinimumValue(
            config.position.broadcast_smart_minimum_interval_secs, min_default_broadcast_smart_minimum_interval_secs);
    }
    // FIXME: UINT32_MAX intervals overflows Apple clients until they are fully patched
    if (config.device.node_info_broadcast_secs > MAX_INTERVAL)
        config.device.node_info_broadcast_secs = MAX_INTERVAL;
    if (config.position.position_broadcast_secs > MAX_INTERVAL)
        config.position.position_broadcast_secs = MAX_INTERVAL;
    if (config.position.gps_update_interval > MAX_INTERVAL)
        config.position.gps_update_interval = MAX_INTERVAL;
    if (config.position.gps_attempt_time > MAX_INTERVAL)
        config.position.gps_attempt_time = MAX_INTERVAL;
    if (config.position.position_flags > MAX_INTERVAL)
        config.position.position_flags = MAX_INTERVAL;
    if (config.position.rx_gpio > MAX_INTERVAL)
        config.position.rx_gpio = MAX_INTERVAL;
    if (config.position.tx_gpio > MAX_INTERVAL)
        config.position.tx_gpio = MAX_INTERVAL;
    if (config.position.broadcast_smart_minimum_distance > MAX_INTERVAL)
        config.position.broadcast_smart_minimum_distance = MAX_INTERVAL;
    if (config.position.broadcast_smart_minimum_interval_secs > MAX_INTERVAL)
        config.position.broadcast_smart_minimum_interval_secs = MAX_INTERVAL;
    if (config.position.gps_en_gpio > MAX_INTERVAL)
        config.position.gps_en_gpio = MAX_INTERVAL;
    if (moduleConfig.neighbor_info.update_interval > MAX_INTERVAL)
        moduleConfig.neighbor_info.update_interval = MAX_INTERVAL;
    if (moduleConfig.telemetry.device_update_interval > MAX_INTERVAL)
        moduleConfig.telemetry.device_update_interval = MAX_INTERVAL;
    if (moduleConfig.telemetry.environment_update_interval > MAX_INTERVAL)
        moduleConfig.telemetry.environment_update_interval = MAX_INTERVAL;
    if (moduleConfig.telemetry.air_quality_interval > MAX_INTERVAL)
        moduleConfig.telemetry.air_quality_interval = MAX_INTERVAL;
    if (moduleConfig.telemetry.health_update_interval > MAX_INTERVAL)
        moduleConfig.telemetry.health_update_interval = MAX_INTERVAL;

    if (moduleConfig.mqtt.has_map_report_settings &&
        moduleConfig.mqtt.map_report_settings.publish_interval_secs < default_map_publish_interval_secs) {
        moduleConfig.mqtt.map_report_settings.publish_interval_secs = default_map_publish_interval_secs;
    }

    // If a fixed position is configured, restore the persisted position into localPosition at boot.
    // This keeps position broadcasts / MQTT map reports working after reboot on GPS-less nodes.
    if (config.position.fixed_position) {
        meshtastic_PositionLite fixedPos;
        if (copyNodePosition(getNodeNum(), fixedPos) && (fixedPos.latitude_i != 0 || fixedPos.longitude_i != 0)) {
            setLocalPosition(TypeConversions::ConvertToPosition(fixedPos));
            LOG_INFO("Restored fixed position to localPosition: lat=%d lon=%d", fixedPos.latitude_i, fixedPos.longitude_i);
        }
    }

    // Ensure that the neighbor info update interval is coerced to the minimum
    moduleConfig.neighbor_info.update_interval =
        Default::getConfiguredOrMinimumValue(moduleConfig.neighbor_info.update_interval, min_neighbor_info_broadcast_secs);

    // Don't let licensed users to rebroadcast encrypted packets
    if (owner.is_licensed) {
        config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
    }

#if !HAS_TFT
    if (config.display.displaymode == meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
        // On a device without MUI, this display mode makes no sense, and will break logic.
        config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_DEFAULT;
        config.bluetooth.enabled = true;
    }
#endif

    if (devicestateCRC != crc32Buffer(&devicestate, sizeof(devicestate)))
        saveWhat |= SEGMENT_DEVICESTATE;
    if (nodeDatabaseCRC != crc32Buffer(&nodeDatabase, sizeof(nodeDatabase)))
        saveWhat |= SEGMENT_NODEDATABASE;
    // Don't persist on a degraded boot: it would overwrite the unreadable-but-maybe-transient config file
    // with no-key UNSET defaults. Runtime reconfiguration (admin set) still persists normally.
    if (!configDecodeFailed && configCRC != crc32Buffer(&config, sizeof(config)))
        saveWhat |= SEGMENT_CONFIG;
    if (channelFileCRC != crc32Buffer(&channelFile, sizeof(channelFile)))
        saveWhat |= SEGMENT_CHANNELS;

    if (config.position.gps_enabled) {
        config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED;
        config.position.gps_enabled = 0;
    }
#ifdef USERPREFS_FIXED_GPS
    if (myNodeInfo.reboot_count == 1) { // Check if First boot ever or after Factory Reset.
        meshtastic_Position fixedGPS = meshtastic_Position_init_default;
#ifdef USERPREFS_FIXED_GPS_LAT
        fixedGPS.latitude_i = (int32_t)(USERPREFS_FIXED_GPS_LAT * 1e7);
        fixedGPS.has_latitude_i = true;
#endif
#ifdef USERPREFS_FIXED_GPS_LON
        fixedGPS.longitude_i = (int32_t)(USERPREFS_FIXED_GPS_LON * 1e7);
        fixedGPS.has_longitude_i = true;
#endif
#ifdef USERPREFS_FIXED_GPS_ALT
        fixedGPS.altitude = USERPREFS_FIXED_GPS_ALT;
        fixedGPS.has_altitude = true;
#endif
#if defined(USERPREFS_FIXED_GPS_LAT) && defined(USERPREFS_FIXED_GPS_LON)
        fixedGPS.location_source = meshtastic_Position_LocSource_LOC_MANUAL;
        config.has_position = true;
#if !MESHTASTIC_EXCLUDE_POSITIONDB
        {
            concurrency::LockGuard guard(&satelliteMutex);
            nodePositions[info->num] = TypeConversions::ConvertToPositionLite(fixedGPS);
        }
#endif
        nodeDB->setLocalPosition(fixedGPS);
        config.position.fixed_position = true;
#endif
    }
#endif
    sortMeshDB();
    saveToDisk(saveWhat);
    bootInitializationInProgress = false;
}

/**
 * Most (but not always) of the time we want to treat packets 'from' the local phone (where from == 0), as if they originated on
 * the local node. If from is zero this function returns our node number instead
 */
NodeNum getFrom(const meshtastic_MeshPacket *p)
{
    return (p->from == 0) ? nodeDB->getNodeNum() : p->from;
}

// Returns true if the packet originated from the local node
bool isFromUs(const meshtastic_MeshPacket *p)
{
    return p->from == 0 || p->from == nodeDB->getNodeNum();
}

// Returns true if the packet is destined to us
bool isToUs(const meshtastic_MeshPacket *p)
{
    return p->to == nodeDB->getNodeNum();
}

bool isBroadcast(uint32_t dest)
{
    return dest == NODENUM_BROADCAST || dest == NODENUM_BROADCAST_NO_LORA;
}

namespace
{
template <typename Map, typename Value> bool copySatelliteEntry(const Map &map, NodeNum n, Value &out)
{
    auto it = map.find(n);
    if (it == map.end())
        return false;
    out = it->second;
    return true;
}

template <typename Map> std::vector<NodeNum> snapshotSatelliteNodeNums(const Map &map, NodeNum exclude)
{
    std::vector<NodeNum> result;
    result.reserve(map.size());
    for (const auto &kv : map) {
        if (kv.first != exclude)
            result.push_back(kv.first);
    }
    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)
{
    if (is_fresh_install) {
        radioGeneration++;
    }

    if (channelFile.channels_count != MAX_NUM_CHANNELS) {
        LOG_INFO("Set default channel and radio prefs");

        channels.initDefaults();
        // Defaults ship the public PSK, so strip it again before onConfigChanged() publishes hashes;
        // loadFromDisk's sanitation is a no-op when the channel file was absent or corrupt.
        if (owner.is_licensed)
            channels.ensureLicensedOperation();
    }

    channels.onConfigChanged();

    // Update the global myRegion
    initRegion();
}

bool NodeDB::factoryReset(bool eraseBleBonds)
{
    LOG_INFO("Factory reset");
    // first, remove the "/prefs" (this removes most prefs)
    spiLock->lock();
    rmDir("/prefs"); // this uses spilock internally...

#ifdef FSCom
    if (FSCom.exists("/static/rangetest.csv") && !FSCom.remove("/static/rangetest.csv")) {
        LOG_ERROR("Can't remove rangetest.csv");
    }
#endif

    spiLock->unlock();

    // rmDir above nuked the .dat file, but TransmitHistory's in-memory
    // cache auto-flushes every 5 min and would resurrect it.
    if (transmitHistory) {
        transmitHistory->clear();
    }
#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
#if HAS_TRAFFIC_MANAGEMENT
    // Factory reset forgets everything; TMM's RAM caches must not survive to resurrect
    // identities (the device usually reboots after this, but don't rely on it).
    if (trafficManagementModule)
        trafficManagementModule->purgeAll();
#endif

    // second, install default state (this will deal with the duplicate mac address issue)
    installDefaultNodeDatabase();
    installDefaultDeviceState();
    installDefaultConfig(!eraseBleBonds); // Also preserve the private key if we're not erasing BLE bonds
    installDefaultModuleConfig();
    installDefaultChannels();
    // third, write everything to disk
    saveToDisk();
    if (eraseBleBonds) {
        LOG_INFO("Erase BLE bonds");
#ifdef ARCH_ESP32
        // 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);
        bond_print_list(BLE_GAP_ROLE_CENTRAL);
        Bluefruit.Periph.clearBonds();
        Bluefruit.Central.clearBonds();
#endif
    }
    return true;
}

void NodeDB::installDefaultNodeDatabase()
{
    LOG_DEBUG("Install default NodeDatabase");
    nodeDatabase.version = DEVICESTATE_CUR_VER;
    nodeDatabase.nodes = std::vector<meshtastic_NodeInfoLite>(MAX_NUM_NODES);
    numMeshNodes = 0;
    meshNodes = &nodeDatabase.nodes;
    concurrency::LockGuard satelliteGuard(&satelliteMutex);
#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
}

void NodeDB::installDefaultConfig(bool preserveKey = false)
{
    uint8_t private_key_temp[32];
    bool shouldPreserveKey = preserveKey && config.has_security && config.security.private_key.size == 32;
    if (shouldPreserveKey) {
        memcpy(private_key_temp, config.security.private_key.bytes, config.security.private_key.size);
    }
    LOG_INFO("Install default LocalConfig");
    memset(&config, 0, sizeof(meshtastic_LocalConfig));
    config.version = DEVICESTATE_CUR_VER;
    config.has_device = true;
    config.has_display = true;
    config.has_lora = true;
    config.has_position = true;
    config.has_power = true;
    config.has_network = true;
    config.has_bluetooth = (HAS_BLUETOOTH ? true : false);
    config.has_security = true;
    config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_ALL;

    config.lora.sx126x_rx_boosted_gain = true;
    config.lora.tx_enabled =
        true; // FIXME: maybe false in the future, and setting region to enable it. (unset region forces it off)
    config.lora.override_duty_cycle = false;
    config.lora.config_ok_to_mqtt = false;
#if HAS_LORA_FEM
    config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_ENABLED;
#else
    config.lora.fem_lna_mode = meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT;
#endif

#if HAS_TFT // For the devices that support MUI, default to that
    config.display.displaymode = meshtastic_Config_DisplayConfig_DisplayMode_COLOR;
#endif

#if defined(TFT_WIDTH) && defined(TFT_HEIGHT) && (TFT_WIDTH >= 200 || TFT_HEIGHT >= 200)
    config.display.enable_message_bubbles = true;
#endif

#ifdef USERPREFS_CONFIG_DEVICE_ROLE
    // Restrict ROUTER*, LOST AND FOUND roles for security reasons
    if (IS_ONE_OF(USERPREFS_CONFIG_DEVICE_ROLE, meshtastic_Config_DeviceConfig_Role_ROUTER,
                  meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND)) {
        LOG_WARN("ROUTER roles restricted, fall back to CLIENT");
        config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
    } else {
        config.device.role = USERPREFS_CONFIG_DEVICE_ROLE;
    }
#else
    config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; // Default to client.
#endif

#ifdef USERPREFS_CONFIG_LORA_REGION
    config.lora.region = USERPREFS_CONFIG_LORA_REGION;
#else
    config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
#endif

#ifdef USERPREFS_LORACONFIG_TX_POWER
    config.lora.tx_power = USERPREFS_LORACONFIG_TX_POWER;
#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

#if USERPREFS_EVENT_MODE
    config.lora.hop_limit = Default::eventModeHopLimit;
#else
    config.lora.hop_limit = HOP_RELIABLE;
#endif
#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;

#ifdef USERPREFS_USE_ADMIN_KEY_0
    // Check if USERPREFS_ADMIN_KEY_0 is non-empty
    if (sizeof(userprefs_admin_key_0) > 0) {
        memcpy(config.security.admin_key[0].bytes, userprefs_admin_key_0, 32);
        config.security.admin_key[0].size = 32;
        numAdminKeys++;
    }
#endif

#ifdef USERPREFS_USE_ADMIN_KEY_1
    // Check if USERPREFS_ADMIN_KEY_1 is non-empty
    if (sizeof(userprefs_admin_key_1) > 0) {
        memcpy(config.security.admin_key[1].bytes, userprefs_admin_key_1, 32);
        config.security.admin_key[1].size = 32;
        numAdminKeys++;
    }
#endif

#ifdef USERPREFS_USE_ADMIN_KEY_2
    // Check if USERPREFS_ADMIN_KEY_2 is non-empty
    if (sizeof(userprefs_admin_key_2) > 0) {
        memcpy(config.security.admin_key[2].bytes, userprefs_admin_key_2, 32);
        config.security.admin_key[2].size = 32;
        numAdminKeys++;
    }
#endif

    config.security.admin_key_count = numAdminKeys;

    // Left at COMPATIBLE when signature checking is compiled out, so we never report a policy
    // nothing enforces (mirrors the set-config guard in AdminModule).
#if defined(USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY) && !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
    config.security.packet_signature_policy = USERPREFS_CONFIG_SECURITY_PACKET_SIGNATURE_POLICY;
#endif

    if (shouldPreserveKey) {
        config.security.private_key.size = 32;
        memcpy(config.security.private_key.bytes, private_key_temp, config.security.private_key.size);
        // Never log the key bytes: debug logs get pasted into public bug reports.
        LOG_DEBUG("Restored preserved private key");
    } else {
        config.security.private_key.size = 0;
    }
    config.security.public_key.size = 0;

#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
    config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT;
#elif !defined(GPS_RX_PIN)
    if (config.position.rx_gpio == 0)
        config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_NOT_PRESENT;
    else
        config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_DISABLED;
#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 &&
        config.device.role != meshtastic_Config_DeviceConfig_Role_ROUTER_LATE)
        config.device.node_info_broadcast_secs = default_node_info_broadcast_secs;
    config.security.serial_enabled = true;
    config.security.admin_channel_enabled = false;
    resetRadioConfig(true); // This also triggers NodeInfo/Position requests since we're fresh
    strncpy(config.network.ntp_server, "meshtastic.pool.ntp.org", 32);

#if (defined(T_DECK) || defined(T_WATCH_S3) || defined(UNPHONE) || defined(PICOMPUTER_S3) || defined(SENSECAP_INDICATOR) ||      \
     defined(ELECROW_PANEL) || defined(HELTEC_V4_TFT) || defined(HELTEC_V4_R8_TFT) || defined(RAK_WISMESH_TAP_V2)) &&            \
    HAS_TFT
    // switch BT off by default; use TFT programming mode or hotkey to enable
    config.bluetooth.enabled = false;
#else
    // default to bluetooth capability of platform as default
    config.bluetooth.enabled = true;
#endif

    config.bluetooth.fixed_pin = defaultBLEPin;

#if defined(USE_EINK) || defined(HAS_SPI_TFT) || defined(USE_SPISSD1306)
    bool hasScreen = true;
#ifdef HELTEC_MESH_NODE_T114
    uint32_t st7789_id = get_st7789_id(ST7789_NSS, ST7789_SCK, ST7789_SDA, ST7789_RS, ST7789_RESET);
    if (st7789_id == 0xFFFFFF) {
        hasScreen = false;
    }
#endif // HELTEC_MESH_NODE_T114
#elif ARCH_PORTDUINO
    bool hasScreen = false;
    if (portduino_config.displayPanel)
        hasScreen = true;
    else
        hasScreen = screen_found.port != ScanI2C::I2CPort::NO_I2C;
#elif MESHTASTIC_INCLUDE_NICHE_GRAPHICS // See "src/graphics/niche"
    bool hasScreen = true; // Use random pin for Bluetooth pairing
#else
    bool hasScreen = screen_found.port != ScanI2C::I2CPort::NO_I2C;
#endif

#ifdef USERPREFS_FIXED_BLUETOOTH
    config.bluetooth.fixed_pin = USERPREFS_FIXED_BLUETOOTH;
    config.bluetooth.mode = meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN;
#else
    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 |
         meshtastic_Config_PositionConfig_PositionFlags_SPEED | meshtastic_Config_PositionConfig_PositionFlags_HEADING |
         meshtastic_Config_PositionConfig_PositionFlags_DOP | meshtastic_Config_PositionConfig_PositionFlags_SATINVIEW);

// Set default value for 'Mesh via UDP'
#if HAS_UDP_MULTICAST
#ifdef USERPREFS_NETWORK_ENABLED_PROTOCOLS
    config.network.enabled_protocols = USERPREFS_NETWORK_ENABLED_PROTOCOLS;
#else
    config.network.enabled_protocols = 0;
#endif // Network enabled protocols
#endif // UDP Multicast

#ifdef USERPREFS_NETWORK_WIFI_ENABLED
    config.network.wifi_enabled = USERPREFS_NETWORK_WIFI_ENABLED;
#endif

#if USE_ETHERNET_DEFAULT
    config.network.eth_enabled = true;
#endif

#ifdef USERPREFS_NETWORK_WIFI_SSID
    strncpy(config.network.wifi_ssid, USERPREFS_NETWORK_WIFI_SSID, sizeof(config.network.wifi_ssid));
#endif

#ifdef USERPREFS_NETWORK_WIFI_PSK
    strncpy(config.network.wifi_psk, USERPREFS_NETWORK_WIFI_PSK, sizeof(config.network.wifi_psk));
#endif

#if defined(USERPREFS_NETWORK_IPV6_ENABLED)
    config.network.ipv6_enabled = USERPREFS_NETWORK_IPV6_ENABLED;
#else
    config.network.ipv6_enabled = default_network_ipv6_enabled;
#endif

#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

#if defined(T_ECHO_CARD)
    config.display.screen_on_secs = 60;
#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);
    }
#endif

#ifdef USERPREFS_CONFIG_DEVICE_ROLE
    // Apply role-specific defaults when role is set via user preferences
    installRoleDefaults(config.device.role);
#endif

    initConfigIntervals();
    variantDefaultConfig();
    variantDefaultModuleConfig();
}

void NodeDB::initConfigIntervals()
{
#ifdef USERPREFS_CONFIG_GPS_UPDATE_INTERVAL
    config.position.gps_update_interval = USERPREFS_CONFIG_GPS_UPDATE_INTERVAL;
#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
    config.position.position_broadcast_secs = default_broadcast_interval_secs;
#endif

    config.power.ls_secs = default_ls_secs;
    config.power.min_wake_secs = default_min_wake_secs;
    config.power.sds_secs = default_sds_secs;
    config.power.wait_bluetooth_secs = default_wait_bluetooth_secs;

    config.display.screen_on_secs = default_screen_on_secs;

#if defined(USE_POWERSAVE)
    config.power.is_power_saving = true;
    config.display.screen_on_secs = 30;
    config.power.wait_bluetooth_secs = 30;
#endif
}

// Always-on traffic management defaults. Only booleans are written; every
// numeric field stays 0 and resolves to its default_traffic_mgmt_* macro at
// use (e.g. position dedup precision/interval), so fork-wide tuning changes
// take effect without another migration. Rate limiting and the features that
// exhaust or reshape relayed traffic (exhaust_hop_*, drop_unknown_enabled,
// nodeinfo_direct_response) stay opt-in.
static void installTrafficManagementDefaults(meshtastic_LocalModuleConfig &mc)
{
    mc.has_traffic_management = true;
    mc.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero;
#if HAS_TRAFFIC_MANAGEMENT
    // Position dedup ships enabled at the 11-hour default window on all supported targets.
    // STM32WL is excluded at compile time (HAS_TRAFFIC_MANAGEMENT=0 in mesh-pb-constants.h).
    // Set position_min_interval_secs=0 at runtime to disable dedup.
    mc.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs;
#endif
}

// --- 2.8 position/telemetry opt-in migration helpers -------------------------------------------------
// Pure field mutators (no I/O), so the native test suite can exercise them directly. The version gate
// and saveToDisk live in loadFromDisk() below.

void optInDisablePositionSharing(meshtastic_ChannelFile &cf)
{
    for (pb_size_t i = 0; i < cf.channels_count; i++) {
        // Only flip PUBLIC / default-PSK channels. A channel with a real private key is a deliberate
        // trusted-group setup where the "leak location to strangers" concern doesn't apply, so its
        // configured precision (including full precision) is preserved.
        if (!channelFileUsesPublicKey(cf, (ChannelIndex)i))
            continue;
        cf.channels[i].settings.has_module_settings = true;
        cf.channels[i].settings.module_settings.position_precision = 0;
    }
}

void optInDisableTelemetryBroadcast(meshtastic_LocalModuleConfig &mc)
{
    // Every mesh-broadcast telemetry enable flag (each gates its module's sendTelemetry() to the mesh).
    mc.telemetry.device_telemetry_enabled = false;
    mc.telemetry.environment_measurement_enabled = false;
    mc.telemetry.air_quality_enabled = false;
    mc.telemetry.power_measurement_enabled = false;
    mc.telemetry.health_measurement_enabled = false;
    // Position leak via the public MQTT map. Leave map_reporting_enabled alone (anonymous presence is
    // still allowed) and strip only the location component.
    mc.mqtt.map_report_settings.should_report_location = false;
}

void NodeDB::installDefaultModuleConfig()
{
    LOG_INFO("Install default ModuleConfig");
    memset(&moduleConfig, 0, sizeof(meshtastic_ModuleConfig));

    moduleConfig.version = DEVICESTATE_CUR_VER;
    moduleConfig.has_mqtt = true;
    moduleConfig.has_range_test = true;
    moduleConfig.has_serial = true;
    moduleConfig.has_store_forward = true;
    moduleConfig.has_telemetry = true;
    moduleConfig.has_external_notification = true;
#if defined(LED_NOTIFICATION) || defined(PCA_LED_NOTIFICATION)
#define HAS_NOTIFICATION_LED
#endif
#if defined(PIN_BUZZER) || defined(PIN_VIBRATION) || defined(HAS_NOTIFICATION_LED) ||                                            \
    defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) || defined(HAS_I2S_SPEAKER_NRF52)
    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;
#elif defined(HAS_I2S_SPEAKER_NRF52)
    // No PWM piezo pin - alert playback goes through NRF52RtttlPlayer/I2S instead,
    // gated only on alert_message_buzzer + canBuzz(), not output_buzzer/use_pwm.
    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) ||                           \
    defined(HAS_I2S_SPEAKER_NRF52)
    moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs;
#endif

#ifdef HAS_I2S
    // Don't worry about the other settings for T-Watch, we'll also use the DRV2056 behavior for notifications
    moduleConfig.external_notification.enabled = true;
    moduleConfig.external_notification.use_i2s_as_buzzer = true;
    moduleConfig.external_notification.alert_message_buzzer = true;
#if HAS_TFT
    if (moduleConfig.external_notification.nag_timeout == default_ringtone_nag_secs)
        moduleConfig.external_notification.nag_timeout = 0;
#else
    moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs;
#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 // NANO_G2_ULTRA

#ifdef ELECROW_ThinkNode_M8
    moduleConfig.canned_message.rotary1_enabled = true;
    moduleConfig.canned_message.inputbroker_pin_a = PIN_BUTTON_EC04_A;
    moduleConfig.canned_message.inputbroker_pin_b = PIN_BUTTON_EC04_B;
    moduleConfig.canned_message.inputbroker_pin_press = PIN_BUTTON_EC04;
    moduleConfig.canned_message.inputbroker_event_cw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_RIGHT;
    moduleConfig.canned_message.inputbroker_event_ccw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_LEFT;
    moduleConfig.canned_message.inputbroker_event_press = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT;
#endif
#ifdef T_LORA_PAGER
    moduleConfig.canned_message.updown1_enabled = true;
    moduleConfig.canned_message.inputbroker_pin_a = ROTARY_A;
    moduleConfig.canned_message.inputbroker_pin_b = ROTARY_B;
    moduleConfig.canned_message.inputbroker_pin_press = ROTARY_PRESS;
    moduleConfig.canned_message.inputbroker_event_cw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar(28);
    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
    moduleConfig.mqtt.tls_enabled = default_mqtt_tls_enabled;
#endif

    moduleConfig.has_neighbor_info = true;
    moduleConfig.neighbor_info.enabled = false;

    installTrafficManagementDefaults(moduleConfig);

    moduleConfig.has_detection_sensor = true;
    moduleConfig.detection_sensor.enabled = false;
    moduleConfig.detection_sensor.detection_trigger_type = meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_LOGIC_HIGH;
    moduleConfig.detection_sensor.minimum_broadcast_secs = 45;

    moduleConfig.has_ambient_lighting = true;
    moduleConfig.ambient_lighting.current = 10;
    // Default to a color based on our node number
    moduleConfig.ambient_lighting.red = (myNodeInfo.my_node_num & 0xFF0000) >> 16;
    moduleConfig.ambient_lighting.green = (myNodeInfo.my_node_num & 0x00FF00) >> 8;
    moduleConfig.ambient_lighting.blue = myNodeInfo.my_node_num & 0x0000FF;

#if !MESHTASTIC_EXCLUDE_BEACON
    moduleConfig.has_mesh_beacon = true;
    // Default flags: listen on, broadcast off, legacy split on.
    moduleConfig.mesh_beacon.flags = meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED |
                                     meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT;
// Set or clear a single beacon flag bit from a USERPREFS boolean.
#define BEACON_APPLY_FLAG(enabled, flag)                                                                                         \
    do {                                                                                                                         \
        if (enabled)                                                                                                             \
            moduleConfig.mesh_beacon.flags |= (uint32_t)(flag);                                                                  \
        else                                                                                                                     \
            moduleConfig.mesh_beacon.flags &= ~(uint32_t)(flag);                                                                 \
    } while (0)
#ifdef USERPREFS_MESH_BEACON_LISTEN_ENABLED
    BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_LISTEN_ENABLED, meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED);
#endif
#ifdef USERPREFS_MESH_BEACON_BROADCAST_ENABLED
    BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_BROADCAST_ENABLED,
                      meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_BROADCAST_ENABLED);
#endif
#ifdef USERPREFS_MESH_BEACON_MESSAGE
    strncpy(moduleConfig.mesh_beacon.broadcast_message, USERPREFS_MESH_BEACON_MESSAGE,
            sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1);
    moduleConfig.mesh_beacon.broadcast_message[sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1] = '\0';
#endif
#ifdef USERPREFS_MESH_BEACON_INTERVAL_SECS
    moduleConfig.mesh_beacon.broadcast_interval_secs =
        (USERPREFS_MESH_BEACON_INTERVAL_SECS != 0 &&
         USERPREFS_MESH_BEACON_INTERVAL_SECS < default_mesh_beacon_min_broadcast_interval_secs)
            ? default_mesh_beacon_min_broadcast_interval_secs
            : USERPREFS_MESH_BEACON_INTERVAL_SECS;
#endif
#ifdef USERPREFS_MESH_BEACON_OFFER_PRESET
    moduleConfig.mesh_beacon.has_broadcast_offer_preset = true;
    moduleConfig.mesh_beacon.broadcast_offer_preset = USERPREFS_MESH_BEACON_OFFER_PRESET;
#endif
#ifdef USERPREFS_MESH_BEACON_OFFER_REGION
    moduleConfig.mesh_beacon.broadcast_offer_region = USERPREFS_MESH_BEACON_OFFER_REGION;
#endif
#ifdef USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME
    moduleConfig.mesh_beacon.has_broadcast_offer_channel = true;
    strncpy(moduleConfig.mesh_beacon.broadcast_offer_channel.name, USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME,
            sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.name) - 1);
    moduleConfig.mesh_beacon.broadcast_offer_channel.name[sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.name) - 1] =
        '\0';
#endif
#ifdef USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK
    moduleConfig.mesh_beacon.has_broadcast_offer_channel = true;
    static const uint8_t beaconOfferPsk[] = USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK;
    static_assert(sizeof(beaconOfferPsk) <= sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.psk.bytes),
                  "USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK exceeds the 32-byte channel PSK buffer");
    memcpy(moduleConfig.mesh_beacon.broadcast_offer_channel.psk.bytes, beaconOfferPsk, sizeof(beaconOfferPsk));
    moduleConfig.mesh_beacon.broadcast_offer_channel.psk.size = sizeof(beaconOfferPsk);
#endif
#ifdef USERPREFS_MESH_BEACON_ON_PRESET
    moduleConfig.mesh_beacon.has_broadcast_on_preset = true;
    moduleConfig.mesh_beacon.broadcast_on_preset = USERPREFS_MESH_BEACON_ON_PRESET;
#endif
#ifdef USERPREFS_MESH_BEACON_ON_REGION
    moduleConfig.mesh_beacon.broadcast_on_region = USERPREFS_MESH_BEACON_ON_REGION;
#endif
#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NAME
    moduleConfig.mesh_beacon.has_broadcast_on_channel = true;
    strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, USERPREFS_MESH_BEACON_ON_CHANNEL_NAME,
            sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1);
    moduleConfig.mesh_beacon.broadcast_on_channel.name[sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1] = '\0';
#endif
#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_PSK
    moduleConfig.mesh_beacon.has_broadcast_on_channel = true;
    static const uint8_t beaconOnPsk[] = USERPREFS_MESH_BEACON_ON_CHANNEL_PSK;
    static_assert(sizeof(beaconOnPsk) <= sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes),
                  "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK exceeds the 32-byte channel PSK buffer");
    memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconOnPsk, sizeof(beaconOnPsk));
    moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconOnPsk);
#endif
#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NUM
    moduleConfig.mesh_beacon.has_broadcast_on_channel = true;
    moduleConfig.mesh_beacon.broadcast_on_channel.channel_num = USERPREFS_MESH_BEACON_ON_CHANNEL_NUM;
#endif
#ifdef USERPREFS_MESH_BEACON_LEGACY_SPLIT
    BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_LEGACY_SPLIT, meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT);
#endif
#undef BEACON_APPLY_FLAG
// Per-preset broadcast targets (up to 4). Each TARGET_<N>_* key bumps broadcast_targets_count as needed.
#define BEACON_TARGET_PRESET(N, VAL)                                                                                             \
    do {                                                                                                                         \
        if (moduleConfig.mesh_beacon.broadcast_targets_count < (N) + 1)                                                          \
            moduleConfig.mesh_beacon.broadcast_targets_count = (N) + 1;                                                          \
        moduleConfig.mesh_beacon.broadcast_targets[(N)].has_preset = true;                                                       \
        moduleConfig.mesh_beacon.broadcast_targets[(N)].preset = (VAL);                                                          \
    } while (0)
#define BEACON_TARGET_REGION(N, VAL)                                                                                             \
    do {                                                                                                                         \
        if (moduleConfig.mesh_beacon.broadcast_targets_count < (N) + 1)                                                          \
            moduleConfig.mesh_beacon.broadcast_targets_count = (N) + 1;                                                          \
        moduleConfig.mesh_beacon.broadcast_targets[(N)].region = (VAL);                                                          \
    } while (0)
// Target channel is referenced by index into the device's channel table (0..MAX_NUM_CHANNELS-1).
#define BEACON_TARGET_CH_INDEX(N, VAL)                                                                                           \
    do {                                                                                                                         \
        if (moduleConfig.mesh_beacon.broadcast_targets_count < (N) + 1)                                                          \
            moduleConfig.mesh_beacon.broadcast_targets_count = (N) + 1;                                                          \
        moduleConfig.mesh_beacon.broadcast_targets[(N)].has_channel_index = true;                                                \
        moduleConfig.mesh_beacon.broadcast_targets[(N)].channel_index = (VAL);                                                   \
    } while (0)
#ifdef USERPREFS_MESH_BEACON_TARGET_0_PRESET
    BEACON_TARGET_PRESET(0, USERPREFS_MESH_BEACON_TARGET_0_PRESET);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_0_REGION
    BEACON_TARGET_REGION(0, USERPREFS_MESH_BEACON_TARGET_0_REGION);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_0_CHANNEL_INDEX
    BEACON_TARGET_CH_INDEX(0, USERPREFS_MESH_BEACON_TARGET_0_CHANNEL_INDEX);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_1_PRESET
    BEACON_TARGET_PRESET(1, USERPREFS_MESH_BEACON_TARGET_1_PRESET);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_1_REGION
    BEACON_TARGET_REGION(1, USERPREFS_MESH_BEACON_TARGET_1_REGION);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_1_CHANNEL_INDEX
    BEACON_TARGET_CH_INDEX(1, USERPREFS_MESH_BEACON_TARGET_1_CHANNEL_INDEX);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_2_PRESET
    BEACON_TARGET_PRESET(2, USERPREFS_MESH_BEACON_TARGET_2_PRESET);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_2_REGION
    BEACON_TARGET_REGION(2, USERPREFS_MESH_BEACON_TARGET_2_REGION);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_2_CHANNEL_INDEX
    BEACON_TARGET_CH_INDEX(2, USERPREFS_MESH_BEACON_TARGET_2_CHANNEL_INDEX);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_3_PRESET
    BEACON_TARGET_PRESET(3, USERPREFS_MESH_BEACON_TARGET_3_PRESET);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_3_REGION
    BEACON_TARGET_REGION(3, USERPREFS_MESH_BEACON_TARGET_3_REGION);
#endif
#ifdef USERPREFS_MESH_BEACON_TARGET_3_CHANNEL_INDEX
    BEACON_TARGET_CH_INDEX(3, USERPREFS_MESH_BEACON_TARGET_3_CHANNEL_INDEX);
#endif
#undef BEACON_TARGET_PRESET
#undef BEACON_TARGET_REGION
#undef BEACON_TARGET_CH_INDEX
#endif // !MESHTASTIC_EXCLUDE_BEACON

    initModuleConfigIntervals();
}

void NodeDB::installRoleDefaults(meshtastic_Config_DeviceConfig_Role role)
{
    if (role == meshtastic_Config_DeviceConfig_Role_ROUTER) {
        initConfigIntervals();
        initModuleConfigIntervals();
        moduleConfig.telemetry.device_update_interval = default_telemetry_broadcast_interval_secs;
        config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY;
        owner.has_is_unmessagable = true;
        owner.is_unmessagable = true;
    } else if (role == meshtastic_Config_DeviceConfig_Role_ROUTER_LATE) {
        moduleConfig.telemetry.device_update_interval = ONE_DAY;
        owner.has_is_unmessagable = true;
        owner.is_unmessagable = true;
    } else if (role == meshtastic_Config_DeviceConfig_Role_SENSOR) {
        owner.has_is_unmessagable = true;
        owner.is_unmessagable = true;
        moduleConfig.telemetry.device_update_interval = default_telemetry_broadcast_interval_secs;
        moduleConfig.telemetry.environment_measurement_enabled = true;
        moduleConfig.telemetry.environment_update_interval = 300;
    } else if (role == meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND) {
        config.position.position_broadcast_smart_enabled = false;
        config.position.position_broadcast_secs = 300; // Every 5 minutes
    } else if (role == meshtastic_Config_DeviceConfig_Role_TAK) {
        config.device.node_info_broadcast_secs = ONE_DAY;
        config.position.position_broadcast_smart_enabled = false;
        config.position.position_broadcast_secs = ONE_DAY;
        // Remove Altitude MSL from flags since CoTs use HAE (height above ellipsoid)
        config.position.position_flags =
            (meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE | meshtastic_Config_PositionConfig_PositionFlags_SPEED |
             meshtastic_Config_PositionConfig_PositionFlags_HEADING | meshtastic_Config_PositionConfig_PositionFlags_DOP);
        moduleConfig.telemetry.device_update_interval = ONE_DAY;
    } else if (role == meshtastic_Config_DeviceConfig_Role_TRACKER) {
        owner.has_is_unmessagable = true;
        owner.is_unmessagable = true;
        moduleConfig.telemetry.device_update_interval = default_telemetry_broadcast_interval_secs;
    } else if (role == meshtastic_Config_DeviceConfig_Role_TAK_TRACKER) {
        owner.has_is_unmessagable = true;
        owner.is_unmessagable = true;
        config.device.node_info_broadcast_secs = ONE_DAY;
        config.position.position_broadcast_smart_enabled = true;
        config.position.position_broadcast_secs = 3 * 60; // Every 3 minutes
        config.position.broadcast_smart_minimum_distance = 20;
        config.position.broadcast_smart_minimum_interval_secs = 15;
        // Remove Altitude MSL from flags since CoTs use HAE (height above ellipsoid)
        config.position.position_flags =
            (meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE | meshtastic_Config_PositionConfig_PositionFlags_SPEED |
             meshtastic_Config_PositionConfig_PositionFlags_HEADING | meshtastic_Config_PositionConfig_PositionFlags_DOP);
        moduleConfig.telemetry.device_update_interval = ONE_DAY;
    } else if (role == meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) {
        config.device.rebroadcast_mode = meshtastic_Config_DeviceConfig_RebroadcastMode_LOCAL_ONLY;
        config.device.node_info_broadcast_secs = MAX_INTERVAL;
        config.position.position_broadcast_smart_enabled = false;
        config.position.position_broadcast_secs = MAX_INTERVAL;
        moduleConfig.neighbor_info.update_interval = MAX_INTERVAL;
        moduleConfig.telemetry.device_update_interval = MAX_INTERVAL;
        moduleConfig.telemetry.environment_update_interval = MAX_INTERVAL;
        moduleConfig.telemetry.air_quality_interval = MAX_INTERVAL;
        moduleConfig.telemetry.health_update_interval = MAX_INTERVAL;
    }
}

void NodeDB::initModuleConfigIntervals()
{
    // Zero out telemetry intervals so that they coalesce to defaults in Default.h
#ifdef USERPREFS_CONFIG_DEVICE_TELEM_UPDATE_INTERVAL
    moduleConfig.telemetry.device_update_interval = USERPREFS_CONFIG_DEVICE_TELEM_UPDATE_INTERVAL;
#else
    moduleConfig.telemetry.device_update_interval = MAX_INTERVAL;
#endif

#ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED
    moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED;
#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_ENV_SCREEN_SCREEN_ENABLED
    moduleConfig.telemetry.environment_screen_enabled = USERPREFS_CONFIG_ENV_SCREEN_SCREEN_ENABLED;
#endif

#ifdef USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL
    moduleConfig.telemetry.air_quality_interval = USERPREFS_CONFIG_AQ_TELEM_UPDATE_INTERVAL;
#else
    moduleConfig.telemetry.air_quality_interval = 0;
#endif

#ifdef USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED
    moduleConfig.telemetry.air_quality_enabled = USERPREFS_CONFIG_AQ_MEASUREMENT_ENABLED;
#endif

#ifdef USERPREFS_CONFIG_AQ_SCREEN_ENABLED
    moduleConfig.telemetry.air_quality_screen_enabled = USERPREFS_CONFIG_AQ_SCREEN_ENABLED;
#endif

    moduleConfig.telemetry.power_update_interval = 0;
    moduleConfig.telemetry.health_update_interval = 0;
    moduleConfig.neighbor_info.update_interval = 0;
    moduleConfig.paxcounter.paxcounter_update_interval = 0;
}

void NodeDB::installDefaultChannels()
{
    LOG_INFO("Install default ChannelFile");
    memset(&channelFile, 0, sizeof(meshtastic_ChannelFile));
    channelFile.version = DEVICESTATE_CUR_VER;
}

void NodeDB::resetNodes(bool keepFavorites)
{
    if (!config.position.fixed_position)
        clearLocalPosition();
    NodeNum ourNum = getNodeNum();
    numMeshNodes = 1;
    if (keepFavorites) {
        LOG_INFO("Clear node database, keep favorites");
        // Compact favorites into contiguous low slots: zeroing in place leaves one above
        // numMeshNodes, invisible to every `i < numMeshNodes` scan yet still serialized to flash.
        for (size_t i = 1; i < meshNodes->size(); i++) {
            const meshtastic_NodeInfoLite &node = meshNodes->at(i);
            if (nodeInfoLiteIsFavorite(&node)) {
                if (numMeshNodes != i)
                    meshNodes->at(numMeshNodes) = node;
                numMeshNodes += 1;
            } else if (node.num) {
                eraseNodeSatellites(node.num);
            }
        }
        std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite());
    } else {
        LOG_INFO("Clear node database, remove favorites");
        for (size_t i = 1; i < meshNodes->size(); i++) {
            const NodeNum gone = meshNodes->at(i).num;
            if (gone)
                eraseNodeSatellites(gone);
        }
        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
#if HAS_TRAFFIC_MANAGEMENT
    // A user-initiated DB reset forgets everything; TMM's caches must not resurrect it.
    if (trafficManagementModule)
        trafficManagementModule->purgeAll();
#endif

    devicestate.has_rx_waypoint = false;
    saveNodeDatabaseToDisk();
    saveDeviceStateToDisk();
    if (neighborInfoModule && moduleConfig.neighbor_info.enabled)
        neighborInfoModule->resetNeighbors();
}

void NodeDB::removeNodeByNum(NodeNum nodeNum)
{
    int newPos = 0, removed = 0;
    for (int i = 0; i < numMeshNodes; i++) {
        if (meshNodes->at(i).num != nodeNum)
            meshNodes->at(newPos++) = meshNodes->at(i);
        else
            removed++;
    }
    numMeshNodes -= removed;
    if (removed) {
        // Clear exactly the slots compaction vacated. Sizing this from `removed` (rather than a
        // fixed one) keeps it inside the vector when nothing matched and the store is full.
        const size_t first = numMeshNodes;
        const size_t last = std::min(first + removed, nodeDatabase.nodes.size());
        std::fill(nodeDatabase.nodes.begin() + first, nodeDatabase.nodes.begin() + last, meshtastic_NodeInfoLite());
    }
    // Drop the node's satellite stores and warm-tier copy regardless of which tier it lived in, so
    // an explicit removal fully forgets it.
    eraseNodeSatellites(nodeNum);
#if WARM_NODE_COUNT > 0
    // Explicit user removal: don't let the warm tier resurrect the node
    warmStore.remove(nodeNum);
#endif
#if HAS_TRAFFIC_MANAGEMENT
    // Explicit removal is full removal: the TrafficManagement caches (unified slot +
    // NodeInfo identity cache) must not keep serving or resurrect the node either.
    if (trafficManagementModule)
        trafficManagementModule->purgeNode(nodeNum);
#endif

    LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries, saving", removed);
    saveNodeDatabaseToDisk();
}

void NodeDB::clearLocalPosition()
{
#if !MESHTASTIC_EXCLUDE_POSITIONDB
    concurrency::LockGuard guard(&satelliteMutex);
    nodePositions.erase(getNodeNum());
#endif
    setLocalPosition(meshtastic_Position_init_default);
    localPositionUpdatedSinceBoot = false;
}

bool NodeDB::copyNodePosition(NodeNum n, meshtastic_PositionLite &out) const
{
#if MESHTASTIC_EXCLUDE_POSITIONDB
    (void)n;
    (void)out;
    return false;
#else
    concurrency::LockGuard guard(&satelliteMutex);
    return copySatelliteEntry(nodePositions, n, out);
#endif
}

bool NodeDB::copyNodeTelemetry(NodeNum n, meshtastic_DeviceMetrics &out) const
{
#if MESHTASTIC_EXCLUDE_TELEMETRYDB
    (void)n;
    (void)out;
    return false;
#else
    concurrency::LockGuard guard(&satelliteMutex);
    return copySatelliteEntry(nodeTelemetry, n, out);
#endif
}

bool NodeDB::copyNodeEnvironment(NodeNum n, meshtastic_EnvironmentMetrics &out) const
{
#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB
    (void)n;
    (void)out;
    return false;
#else
    concurrency::LockGuard guard(&satelliteMutex);
    return copySatelliteEntry(nodeEnvironment, n, out);
#endif
}

bool NodeDB::copyNodeStatus(NodeNum n, meshtastic_StatusMessage &out) const
{
#if MESHTASTIC_EXCLUDE_STATUSDB
    (void)n;
    (void)out;
    return false;
#else
    concurrency::LockGuard guard(&satelliteMutex);
    return copySatelliteEntry(nodeStatus, n, out);
#endif
}

std::vector<NodeNum> NodeDB::snapshotPositionNodeNums(NodeNum exclude) const
{
#if MESHTASTIC_EXCLUDE_POSITIONDB
    (void)exclude;
    return {};
#else
    concurrency::LockGuard guard(&satelliteMutex);
    return snapshotSatelliteNodeNums(nodePositions, exclude);
#endif
}

std::vector<NodeNum> NodeDB::snapshotTelemetryNodeNums(NodeNum exclude) const
{
#if MESHTASTIC_EXCLUDE_TELEMETRYDB
    (void)exclude;
    return {};
#else
    concurrency::LockGuard guard(&satelliteMutex);
    return snapshotSatelliteNodeNums(nodeTelemetry, exclude);
#endif
}

std::vector<NodeNum> NodeDB::snapshotEnvironmentNodeNums(NodeNum exclude) const
{
#if MESHTASTIC_EXCLUDE_ENVIRONMENTDB
    (void)exclude;
    return {};
#else
    concurrency::LockGuard guard(&satelliteMutex);
    return snapshotSatelliteNodeNums(nodeEnvironment, exclude);
#endif
}

std::vector<NodeNum> NodeDB::snapshotStatusNodeNums(NodeNum exclude) const
{
#if MESHTASTIC_EXCLUDE_STATUSDB
    (void)exclude;
    return {};
#else
    concurrency::LockGuard guard(&satelliteMutex);
    return snapshotSatelliteNodeNums(nodeStatus, exclude);
#endif
}

void NodeDB::setNodeStatus(NodeNum n, const meshtastic_StatusMessage &status)
{
#if MESHTASTIC_EXCLUDE_STATUSDB
    (void)n;
    (void)status;
#else
    concurrency::LockGuard guard(&satelliteMutex);
    evictSatelliteOverCap(*this, nodeStatus, n);
    nodeStatus[n] = status;
#endif
}

void NodeDB::touchNodePositionTime(NodeNum n, uint32_t time)
{
#if MESHTASTIC_EXCLUDE_POSITIONDB
    (void)n;
    (void)time;
#else
    concurrency::LockGuard guard(&satelliteMutex);
    evictSatelliteOverCap(*this, nodePositions, n);
    nodePositions[n].time = time;
#endif
}

void NodeDB::eraseNodeSatellites(NodeNum n)
{
    concurrency::LockGuard guard(&satelliteMutex);
#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

    // Approximate satellite heap usage: each std::map entry is one rb-tree node,
    // value_type plus ~44 B of node overhead (parent/left/right pointers, color,
    // allocator rounding on 32-bit targets - an estimate, not exact bookkeeping).
    size_t satBytes = 0;
#if !MESHTASTIC_EXCLUDE_POSITIONDB
    satBytes += nodePositions.size() * (sizeof(decltype(nodePositions)::value_type) + 44);
#endif
#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
    satBytes += nodeTelemetry.size() * (sizeof(decltype(nodeTelemetry)::value_type) + 44);
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
    satBytes += nodeEnvironment.size() * (sizeof(decltype(nodeEnvironment)::value_type) + 44);
#endif
#if !MESHTASTIC_EXCLUDE_STATUSDB
    satBytes += nodeStatus.size() * (sizeof(decltype(nodeStatus)::value_type) + 44);
#endif
    memaudit::set("satmaps", satBytes);

    return trimmedAny;
}

#if WARM_NODE_COUNT > 0
// Classify an evicted node's hop-protected category for the warm tier. Favorite/ignored/
// verified are local flags (rarely reach warm - they're eviction-protected - but classify
// them if they do); otherwise tracker/sensor/tak_tracker are role-protected.
static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n)
{
    if (nodeInfoLiteHasXeddsaSigned(&n))
        return static_cast<uint8_t>(WarmProtected::XeddsaSigner);
    if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK |
                      NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK))
        return static_cast<uint8_t>(WarmProtected::Flag);
    if (IS_ONE_OF(n.role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_SENSOR,
                  meshtastic_Config_DeviceConfig_Role_TAK_TRACKER))
        return static_cast<uint8_t>(WarmProtected::Role);
    return static_cast<uint8_t>(WarmProtected::None);
}

// The warm tier packs the device role into a 4-bit field (WARM_ROLE_MASK). Fail the build
// loudly if a new role outgrows it, rather than silently truncating role on eviction.
static_assert(_meshtastic_Config_DeviceConfig_Role_MAX <= WARM_ROLE_MASK,
              "device role no longer fits the 4-bit warm metadata field");
#endif // WARM_NODE_COUNT > 0

void NodeDB::cleanupMeshDB()
{
    int newPos = 0, removed = 0;
    for (int i = 0; i < numMeshNodes; i++) {
        meshtastic_NodeInfoLite &n = meshNodes->at(i);
        // 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;
                }
            }
            if (newPos != i)
                meshNodes->at(newPos++) = n;
            else
                newPos++;
        } else {
            // No user info - drop this node and its satellites
            const NodeNum gone = n.num;
            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, n.role, warmProtectedCategory(n),
                                     nodeInfoLiteHasXeddsaSigned(&n));
#endif

                eraseNodeSatellites(gone);
            }
            removed++;
        }
    }
    numMeshNodes -= removed;
    std::fill(nodeDatabase.nodes.begin() + numMeshNodes, nodeDatabase.nodes.begin() + numMeshNodes + removed,
              meshtastic_NodeInfoLite());
    LOG_DEBUG("cleanupMeshDB purged %d entries", removed);
}

void NodeDB::installDefaultDeviceState()
{
    LOG_INFO("Install default DeviceState");
    // memset(&devicestate, 0, sizeof(meshtastic_DeviceState));

    // init our devicestate with valid flags so protobuf writing/reading will work
    devicestate.has_my_node = true;
    devicestate.has_owner = true;
    devicestate.version = DEVICESTATE_CUR_VER;
    devicestate.receive_queue_count = 0; // Not yet implemented FIXME
    devicestate.has_rx_waypoint = false;

    generatePacketId(); // FIXME - ugly way to init current_packet_id;

    // Set default owner name
    pickNewNodeNum(); // based on macaddr now
#ifdef USERPREFS_CONFIG_OWNER_LONG_NAME
    snprintf(owner.long_name, sizeof(owner.long_name), (const char *)USERPREFS_CONFIG_OWNER_LONG_NAME);
#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;
    owner.is_unmessagable = false;

#ifdef HAS_HAM_2M_ONLY
    // Ham-band-only hardware defaults to licensed operation. The user can still flip this off later
    // (e.g. a commercial operator on an adjacent allocation who wants to keep encryption on) - we
    // only set the default here, not on every boot.
    owner.is_licensed = true;
#endif
}

// We reserve a few nodenums for future use
#define NUM_RESERVED 4

/**
 * get our starting (provisional) nodenum from flash.
 */
void NodeDB::pickNewNodeNum()
{
    NodeNum nodeNum = myNodeInfo.my_node_num;
    getMacAddr(ourMacAddr); // Make sure ourMacAddr is set
    if (nodeNum == 0) {
        // Pick an initial nodenum based on the macaddr
        nodeNum = (ourMacAddr[2] << 24) | (ourMacAddr[3] << 16) | (ourMacAddr[4] << 8) | ourMacAddr[5];
    }

    // Identity check via public key (or "empty slot?" when no keys yet);
    // macaddr no longer lives on the slim header.
    auto isOurOwnEntry = [&](const meshtastic_NodeInfoLite *n) -> bool {
        if (!n)
            return false;
        if (owner.public_key.size == 32 && n->public_key.size == 32)
            return memcmp(n->public_key.bytes, owner.public_key.bytes, 32) == 0;
        return !nodeInfoLiteHasUser(n);
    };

    meshtastic_NodeInfoLite *found;
    while (((found = getMeshNode(nodeNum)) && !isOurOwnEntry(found)) ||
           (nodeNum == NODENUM_BROADCAST || nodeNum < NUM_RESERVED)) {
        NodeNum candidate = random(NUM_RESERVED, LONG_MAX); // try a new random choice
        if (found)
            LOG_WARN("NOTE! Desired nodenum 0x%08x invalid or in use, picking 0x%08x", nodeNum, candidate);
        nodeNum = candidate;
    }
    LOG_DEBUG("Use nodenum 0x%08x ", nodeNum);

    myNodeInfo.my_node_num = nodeNum;
}

/** Load a protobuf from a file, return LoadFileResult */
LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t objSize, const pb_msgdesc_t *fields,
                                 void *dest_struct)
{
    LoadFileResult state = LoadFileResult::OTHER_FAILURE;

#ifdef MESHTASTIC_ENCRYPTED_STORAGE
    // check if the file is encrypted and decrypt before protobuf decode
    if (EncryptedStorage::isEncrypted(filename)) {
        // ZeroizingArrayPtr wipes the decrypted plaintext (which contains config
        // secrets - channel PSKs, security private_key, etc.) before delete[],
        // so it isn't recoverable from the heap after this function returns.
        auto decBuf = meshtastic_security::make_zeroizing_array(protoSize);
        if (!decBuf) {
            LOG_ERROR("OOM decrypting %s", filename);
            return LoadFileResult::OTHER_FAILURE;
        }
        size_t decLen = 0;
        if (EncryptedStorage::readAndDecrypt(filename, decBuf.get(), protoSize, decLen)) {
            LOG_INFO("Load encrypted %s", filename);
            pb_istream_t stream = pb_istream_from_buffer(decBuf.get(), decLen);
            if (fields != &meshtastic_NodeDatabase_msg)
                memset(dest_struct, 0, objSize);
            if (!pb_decode(&stream, fields, dest_struct)) {
                LOG_ERROR("Can't decode protobuf %s", PB_GET_ERROR(&stream));
                state = LoadFileResult::DECODE_FAILED;
                storageCorruptThisLoad = true;
            } else {
                LOG_INFO("Loaded encrypted %s", filename);
                state = LoadFileResult::LOAD_SUCCESS;
            }
        } else {
            LOG_ERROR("Decrypt failed for %s, treating as corrupt", filename);
            state = LoadFileResult::DECODE_FAILED;
            storageCorruptThisLoad = true;
        }
        return state;
    }
#endif

#ifdef FSCom
    concurrency::LockGuard g(spiLock);

    auto f = FSCom.open(filename, FILE_O_READ);

    if (f) {
        LOG_INFO("Load %s", filename);
        pb_istream_t stream = {&readcb, &f, protoSize};
        if (fields != &meshtastic_NodeDatabase_msg &&
            fields != &meshtastic_NodeDatabase_Legacy_msg) // both NodeDatabase descriptors contain std::vector members
            memset(dest_struct, 0, objSize);
        if (!pb_decode(&stream, fields, dest_struct)) {
            LOG_ERROR("Can't decode protobuf %s", PB_GET_ERROR(&stream));
            state = LoadFileResult::DECODE_FAILED;
        } else {
            LOG_INFO("Loaded %s", filename);
            state = LoadFileResult::LOAD_SUCCESS;
        }
        f.close();
    } else {
        LOG_ERROR("Can't open/read %s", filename);
    }
#else
    LOG_ERROR("Filesystem not implemented");
    state = LoadFileResult::NO_FILESYSTEM;
#endif
    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, n.role,
                         warmProtectedCategory(n), nodeInfoLiteHasXeddsaSigned(&n));
        // 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);
    memaudit::set("nodedb", MAX_NUM_NODES * sizeof(meshtastic_NodeInfoLite));

    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
    // disk we will still factoryReset to restore things.
    devicestate.version = 0;
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
    // Reset the per-load decrypt-failure tracker. Set by loadProto on any
    // encrypted file that fails to decrypt or proto-decode; consumed by
    // reloadFromDisk to surface storage corruption to the operator instead
    // of silently falling back to defaults.
    storageCorruptThisLoad = false;
#endif

    migrationSavePending = false;
    configDecodeFailed = false;
    configLoadComplete = false;

#if !USERPREFS_EVENT_MODE
#ifdef FSCom
    const char *eventProfileFiles[] = {EVENT_CONFIG_FILE_NAME, EVENT_CHANNEL_FILE_NAME, EVENT_BACKUP_FILE_NAME};
    spiLock->lock();
    for (const char *filename : eventProfileFiles) {
        if (FSCom.exists(filename) && !FSCom.remove(filename))
            LOG_WARN("Can't remove stale event profile file %s", filename);
    }
    spiLock->unlock();
#endif
#endif

#if USERPREFS_EVENT_MODE
    // Seed only a missing event config; never overwrite normal files after a corrupt event config.
    bool eventConfigMissing = false;
    eventProfileStorageUnavailable = false;
#ifdef FSCom
    spiLock->lock();
    eventConfigMissing = !FSCom.exists(configFileName);
    if (eventConfigMissing) {
        const size_t totalBytes = fsTotalBytes();
        const size_t usedBytes = fsUsedBytes();
        eventProfileStorageUnavailable = !hasEventProfileStorageSpace(totalBytes, usedBytes);
        if (eventProfileStorageUnavailable) {
            LOG_ERROR("Event profile needs %u bytes free; only %u available. Changes won't persist",
                      static_cast<unsigned>(EVENT_PROFILE_STORAGE_RESERVATION_BYTES),
                      static_cast<unsigned>(totalBytes >= usedBytes ? totalBytes - usedBytes : 0));
        }
    }
    spiLock->unlock();
#endif
    bool initializedEventConfig = false;
#endif

    meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero;

#ifdef ARCH_ESP32
    spiLock->lock();
    // If the legacy deviceState exists, start over with a factory reset
    if (FSCom.exists("/static/static"))
        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();
    if (!FSCom.exists("/prefs/" xstr(BUILD_EPOCH))) {
        LOG_WARN("Factory Install Reset");
        rmDir("/prefs");
        FSCom.mkdir("/prefs");
        File f2 = FSCom.open("/prefs/" xstr(BUILD_EPOCH), FILE_O_WRITE);
        if (f2) {
            f2.flush();
            f2.close();
        }
    }
    spiLock->unlock();
#endif // FACTORY_INSTALL, not PORTDUINO
    spiLock->lock();
    if (FSCom.exists(legacyPrefFileName)) {
        spiLock->unlock();
        LOG_WARN("Legacy prefs version, factory reset");
        if (loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg,
                      &config) == LoadFileResult::LOAD_SUCCESS &&
            config.has_security && config.security.private_key.size > 0) {
            LOG_DEBUG("Backup security config and keys");
            backupSecurity = config.security;
        }
        spiLock->lock();
        rmDir("/prefs");
        spiLock->unlock();
    } else {
        spiLock->unlock();
    }

#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
    // build that has never been provisioned - or that was disabled - falls
    // through to the normal plaintext load below and behaves like stock.
    if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
        // Encrypted storage is locked. Install defaults and wait for the
        // passphrase over BLE/serial; PhoneAPI::handleLockdownAuthInline
        // calls reloadFromDisk() once the storage is unlocked.
        LOG_WARN("NodeDB: Encrypted storage locked, default config until unlocked");
        installDefaultNodeDatabase();
        installDefaultDeviceState();
        installDefaultConfig();
        installDefaultModuleConfig();
        installDefaultChannels();

        // Hold the radio silent until the operator unlocks. installDefaultConfig
        // would otherwise honour USERPREFS_CONFIG_LORA_REGION (the common shape
        // for managed deployments) and the LongFast default channel synthesised
        // by installDefaultChannels, so the device would beacon nodeinfo /
        // telemetry on the public default PSK before any unlock - and process
        // incoming default-channel packets the same way. Forcing region=UNSET
        // gates both TX and RX in RadioLibInterface (see the region==UNSET
        // checks in startSend and readData); tx_enabled=false is belt-and-
        // suspenders for any code path that does not consult region directly.
        // reloadFromDisk() restores the persisted lora config when the
        // operator unlocks.
        config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
        config.lora.tx_enabled = false;
        return;
    }
#endif

    // Arm the direct-into-map decode so satellite entries skip the temp vectors.
    {
        concurrency::LockGuard guard(&satelliteMutex);
        armNodeDatabaseDecodeTargets();
    }
    struct Disarm {
        NodeDB &self;
        ~Disarm() { self.disarmNodeDatabaseDecodeTargets(); }
    } disarm{*this};

    // Avoid push_back's power-of-2 capacity growth wasting RAM at small N.
    nodeDatabase.nodes.reserve(MAX_NUM_NODES);

    auto state = loadProto(nodeDatabaseFileName, getMaxNodesAllocatedSize(), sizeof(meshtastic_NodeDatabase),
                           &meshtastic_NodeDatabase_msg, &nodeDatabase);
    if (nodeDatabase.version < DEVICESTATE_MIN_VER) {
        LOG_WARN("NodeDatabase %d is old, discard", nodeDatabase.version);
        installDefaultNodeDatabase();
    } else if (nodeDatabase.version < DEVICESTATE_CUR_VER) {
        if (migrateLegacyNodeDatabase())
            migrationSavePending = true;
        else
            installDefaultNodeDatabase();
    } else {
        meshNodes = &nodeDatabase.nodes;
        numMeshNodes = nodeDatabase.nodes.size();
        // Counts computed outside LOG_INFO() so cppcheck doesn't choke on #if in macro args.
        const unsigned posCount =
#if !MESHTASTIC_EXCLUDE_POSITIONDB
            (unsigned)nodePositions.size();
#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);
    }

    // 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),
                      &meshtastic_DeviceState_msg, &devicestate);

    // See https://github.com/meshtastic/firmware/issues/4184#issuecomment-2269390786
    // It is very important to try and use the saved prefs even if we fail to read meshtastic_DeviceState.  Because most of our
    // critical config may still be valid (in the other files - loaded next).
    // Also, if we did fail on reading we probably failed on the enormous (and non critical) nodeDB.  So DO NOT install default
    // device state.
    // if (state != LoadFileResult::LOAD_SUCCESS) {
    //    installDefaultDeviceState(); // Our in RAM copy might now be corrupt
    //} else {
    if ((state != LoadFileResult::LOAD_SUCCESS) || (devicestate.version < DEVICESTATE_MIN_VER)) {
        LOG_WARN("Devicestate %d is old or invalid, discard", devicestate.version);
        installDefaultDeviceState();

        // Attempt recovery of owner fields from our own NodeDB entry if available.
        const meshtastic_NodeInfoLite *us = getMeshNode(getNodeNum());
        if (nodeInfoLiteHasUser(us)) {
            LOG_WARN("Restore owner fields (long_name/short_name/is_licensed/is_unmessagable) from NodeDB for node 0x%08x",
                     us->num);
            // owner.long_name (40) is wider than the lite source (25); bound by the source
            memcpy(owner.long_name, us->long_name, sizeof(us->long_name));
            owner.long_name[sizeof(us->long_name) - 1] = '\0';
            memcpy(owner.short_name, us->short_name, sizeof(owner.short_name));
            owner.short_name[sizeof(owner.short_name) - 1] = '\0';
            owner.is_licensed = nodeInfoLiteIsLicensed(us);
            owner.has_is_unmessagable = nodeInfoLiteHasIsUnmessagable(us);
            owner.is_unmessagable = nodeInfoLiteIsUnmessagable(us);

            // Save the recovered owner to device state on disk
            saveToDisk(SEGMENT_DEVICESTATE);
        }
    } else {
        LOG_INFO("Loaded saved devicestate v%d", devicestate.version);
    }

    // Devicestate saved by firmware that allowed 39-byte names gets clamped on
    // first load; from here on owner never carries more than the local cap.
    clampLongName(owner.long_name);

    state = loadProto(configFileName, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig), &meshtastic_LocalConfig_msg,
                      &config);
#if USERPREFS_EVENT_MODE
    if (eventConfigMissing && state != LoadFileResult::LOAD_SUCCESS) {
        const LoadFileResult eventConfigState = state;
        const LoadFileResult standardConfigState =
            loadProto(STANDARD_CONFIG_FILE_NAME, meshtastic_LocalConfig_size, sizeof(meshtastic_LocalConfig),
                      &meshtastic_LocalConfig_msg, &config);
        if (standardConfigState == LoadFileResult::LOAD_SUCCESS) {
            // Preserve the user's identity and non-radio preferences, then
            // replace only LoRa with this event build's compiled defaults.
            const meshtastic_LocalConfig standardConfig = config;
            installDefaultConfig(true);
            const meshtastic_Config_LoRaConfig eventLora = config.lora;
            config = standardConfig;
            config.has_lora = true;
            config.lora = eventLora;
            state = LoadFileResult::LOAD_SUCCESS;
            initializedEventConfig = true;
            LOG_INFO("Init event config without modifying %s", STANDARD_CONFIG_FILE_NAME);
        } else {
            // Keep the event load outcome because loadProto() clears config before decoding.
            // A normal decode failure must not create a replacement identity.
            state = standardConfigState == LoadFileResult::DECODE_FAILED ? LoadFileResult::DECODE_FAILED : eventConfigState;
        }
    }
#endif
    if (state == LoadFileResult::DECODE_FAILED) {
        // Config file present but undecodable this boot (corruption / torn write / transient decrypt fail).
        // loadProto() already zeroed `config`, so the keypair is gone from RAM; minting a new one would change
        // our NodeNum (== crc32(public_key)) and orphan us on the mesh. configDecodeFailed freezes identity and
        // skips persisting (see ctor), so a transient failure self-heals on the next clean boot. A genuinely
        // absent config returns OTHER_FAILURE, so this never fires on first boot. Boot degraded + radio-silent.
        LOG_ERROR("Config decode failed - freeze identity, boot degraded (radio silent until restored)");
        configDecodeFailed = true;
        installDefaultConfig(true);
        config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
        config.lora.tx_enabled = false;
    } else if (state != LoadFileResult::LOAD_SUCCESS) {
        // No decodable config to work with: the file is absent (first boot) or could not be opened (OTHER_FAILURE
        // / NO_FILESYSTEM). Unlike DECODE_FAILED there are no usable contents to protect, so install defaults.
        installDefaultConfig();
    } else if (config.version < DEVICESTATE_MIN_VER) {
        LOG_WARN("config %d is old, discard", config.version);
        installDefaultConfig(true);
    } else {
        LOG_INFO("Loaded saved config v%d", config.version);
    }
    configLoadComplete = true;

    // Coerce LoRa config fields derived from presets while bootstrapping.
    // Some clients/UI components display bandwidth/spread_factor directly from config even in preset mode.
    if (config.has_lora && config.lora.use_preset) {
        RadioInterface::clampConfigLora(config.lora);
    }

#if defined(USERPREFS_LORA_TX_DISABLED) && USERPREFS_LORA_TX_DISABLED
    config.lora.tx_enabled = false;
#endif

    // Always-apply LoRa overrides: applied after loading saved config so they
    // take effect even when NVS already has a valid config (e.g. region-locked
    // dev boards with no BLE/serial to set the region at runtime).
#ifdef USERPREFS_CONFIG_LORA_REGION
    // Skip on a degraded boot to keep the radio silent (identity is already protected by the keygen gate).
    if (!configDecodeFailed)
        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

#if USERPREFS_EVENT_MODE
    if (initializedEventConfig) {
        // This is the first durable event-profile write.  A failed write is
        // safe: normal files remain untouched and the next event boot retries.
        if (!saveToDisk(SEGMENT_CONFIG))
            LOG_ERROR("Can't persist initial event config");
    }
#endif

    if (backupSecurity.private_key.size > 0) {
        LOG_DEBUG("Restore security config backup");
        config.security = backupSecurity;
        saveToDisk(SEGMENT_CONFIG);
    }

    // Make sure we load hard coded admin keys even when the configuration file has none.
    // Initialize admin_key_count to zero
    byte numAdminKeys = 0;
#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++) {
        sum += config.security.admin_key[0].bytes[b];
    }
    if (sum == 0) {
        numAdminKeys += 1;
        LOG_INFO("Admin 0 key zero. Load hard coded key from user prefs");
        memcpy(config.security.admin_key[0].bytes, userprefs_admin_key_0, 32);
        config.security.admin_key[0].size = 32;
    }
#endif

#ifdef USERPREFS_USE_ADMIN_KEY_1
    sum = 0;
    for (uint8_t b = 0; b < 32; b++) {
        sum += config.security.admin_key[1].bytes[b];
    }
    if (sum == 0) {
        numAdminKeys += 1;
        LOG_INFO("Admin 1 key zero. Load hard coded key from user prefs");
        memcpy(config.security.admin_key[1].bytes, userprefs_admin_key_1, 32);
        config.security.admin_key[1].size = 32;
    }
#endif

#ifdef USERPREFS_USE_ADMIN_KEY_2
    sum = 0;
    for (uint8_t b = 0; b < 32; b++) {
        sum += config.security.admin_key[2].bytes[b];
    }
    if (sum == 0) {
        numAdminKeys += 1;
        LOG_INFO("Admin 2 key zero. Load hard coded key from user prefs");
        memcpy(config.security.admin_key[2].bytes, userprefs_admin_key_2, 32);
        config.security.admin_key[2].size = 32;
    }
#endif

    if (numAdminKeys > 0) {
        LOG_INFO("Saving %d hard coded admin keys", numAdminKeys);
        config.security.admin_key_count = numAdminKeys;
        saveToDisk(SEGMENT_CONFIG);
    }

    state = loadProto(moduleConfigFileName, meshtastic_LocalModuleConfig_size, sizeof(meshtastic_LocalModuleConfig),
                      &meshtastic_LocalModuleConfig_msg, &moduleConfig);
    if (state != LoadFileResult::LOAD_SUCCESS) {
        installDefaultModuleConfig(); // Our in RAM copy might now be corrupt
    } else {
        if (moduleConfig.version < DEVICESTATE_MIN_VER) {
            LOG_WARN("moduleConfig %d is old, discard", moduleConfig.version);
            installDefaultModuleConfig();
        } else {
            LOG_INFO("Loaded saved moduleConfig v%d", moduleConfig.version);
        }
    }

    // Always-on traffic management: a device that has NEVER configured TMM
    // (has_traffic_management false - AdminModule always sets the has_ flag on
    // write, even when disabling) gets the fork defaults. Explicitly configured
    // devices keep their exact settings.
    if (!moduleConfig.has_traffic_management) {
        LOG_INFO("Traffic management never configured, installing always-on defaults");
        installTrafficManagementDefaults(moduleConfig);
        saveToDisk(SEGMENT_MODULECONFIG);
    }

    state = loadProto(channelFileName, meshtastic_ChannelFile_size, sizeof(meshtastic_ChannelFile), &meshtastic_ChannelFile_msg,
                      &channelFile);
    if (state != LoadFileResult::LOAD_SUCCESS) {
        installDefaultChannels(); // Our in RAM copy might now be corrupt
    } else {
        if (channelFile.version < DEVICESTATE_MIN_VER) {
            LOG_WARN("channelFile %d is old, discard", channelFile.version);
            installDefaultChannels();
        } else {
            LOG_INFO("Loaded saved channelFile v%d", channelFile.version);
        }
    }

    state = loadProto(uiconfigFileName, meshtastic_DeviceUIConfig_size, sizeof(meshtastic_DeviceUIConfig),
                      &meshtastic_DeviceUIConfig_msg, &uiconfig);
    if (state == LoadFileResult::LOAD_SUCCESS) {
        LOG_INFO("Loaded UIConfig");
    }

#ifdef MESHTASTIC_ENCRYPTED_STORAGE
    // Ensure all config segments are persisted to encrypted storage.
    // installDefaultConfig/installDefaultModuleConfig only set in-memory structs
    // without saving to disk, so we force a save here to ensure encrypted files exist.
    //
    // Only when lockdown is ACTIVE. A capable-but-off device must leave its
    // files as plaintext - encryptAndWrite would fail anyway (no DEK), but
    // skipping the whole block avoids the wasted attempts and error logs.
    if (EncryptedStorage::isLockdownActive()) {
        const char *filesToCheck[] = {configFileName, moduleConfigFileName, channelFileName, deviceStateFileName,
                                      nodeDatabaseFileName};
        const int segments[] = {SEGMENT_CONFIG, SEGMENT_MODULECONFIG, SEGMENT_CHANNELS, SEGMENT_DEVICESTATE,
                                SEGMENT_NODEDATABASE};
        int toSave = 0;
        for (size_t i = 0; i < sizeof(segments) / sizeof(segments[0]); i++) {
            if (!EncryptedStorage::isEncrypted(filesToCheck[i])) {
                toSave |= segments[i];
            }
        }
        if (toSave) {
            LOG_INFO("Lockdown: Saving unencrypted segments to encrypted storage (mask=0x%x)", toSave);
            saveToDisk(toSave);
        }

        // Migrate any remaining plaintext proto files (from standard firmware upgrade)
        for (const char *fn : filesToCheck) {
            if (!EncryptedStorage::isEncrypted(fn)) {
                LOG_INFO("Migrating %s to encrypted storage", fn);
                EncryptedStorage::migrateFile(fn);
            }
        }

        // Backups are outside saveToDisk(), but can contain radio profile PSKs. Only event builds
        // introduce a second backup file, so leave normal-firmware backup handling unchanged.
#if USERPREFS_EVENT_MODE
#ifdef FSCom
        spiLock->lock();
        const bool activeBackupExists = FSCom.exists(backupFileName);
        spiLock->unlock();
        if (activeBackupExists && !EncryptedStorage::isEncrypted(backupFileName)) {
            LOG_INFO("Migrating %s to encrypted storage", backupFileName);
            if (!EncryptedStorage::migrateFile(backupFileName)) {
                LOG_ERROR("Can't migrate %s to encrypted storage", backupFileName);
                storageCorruptThisLoad = true;
            }
        }
#endif
#endif

        // Event firmware keeps the normal radio profile inactive, so migrate it separately.
#if USERPREFS_EVENT_MODE
#ifdef FSCom
        const char *inactiveRadioProfileFiles[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME,
                                                   STANDARD_BACKUP_FILE_NAME};
        for (const char *fn : inactiveRadioProfileFiles) {
            spiLock->lock();
            const bool exists = FSCom.exists(fn);
            spiLock->unlock();
            if (exists && !EncryptedStorage::isEncrypted(fn)) {
                LOG_INFO("Migrating inactive radio profile %s to encrypted storage", fn);
                if (!EncryptedStorage::migrateFile(fn)) {
                    LOG_ERROR("Can't migrate %s to encrypted storage", fn);
                    storageCorruptThisLoad = true;
                }
            }
        }
#endif
#endif
    }
#endif

    // 2.4.X - configuration migration to update new default intervals
    if (moduleConfig.version < 23) {
        LOG_DEBUG("ModuleConfig v%d stale, upgrade to new default intervals", moduleConfig.version);
        moduleConfig.version = DEVICESTATE_CUR_VER;
        if (moduleConfig.telemetry.device_update_interval == 900)
            moduleConfig.telemetry.device_update_interval = 0;
        if (moduleConfig.telemetry.environment_update_interval == 900)
            moduleConfig.telemetry.environment_update_interval = 0;
        if (moduleConfig.telemetry.air_quality_interval == 900)
            moduleConfig.telemetry.air_quality_interval = 0;
        if (moduleConfig.telemetry.power_update_interval == 900)
            moduleConfig.telemetry.power_update_interval = 0;
        if (moduleConfig.neighbor_info.update_interval == 900)
            moduleConfig.neighbor_info.update_interval = 0;
        if (moduleConfig.paxcounter.paxcounter_update_interval == 900)
            moduleConfig.paxcounter.paxcounter_update_interval = 0;

        saveToDisk(SEGMENT_MODULECONFIG);
    }

    // 2.8 - privacy: one-time flip of position sharing and device telemetry to OPT-IN for nodes upgrading
    // from a build that shipped them on-by-default. Gated on a dedicated watermark (POSITION_TELEMETRY_OPTIN_VER)
    // so it runs exactly once and does NOT re-clobber a user who later re-enables sharing (ordinary saves never
    // re-stamp .version, so a re-enabled node stays at the watermark and skips this block on the next boot).
    // Position is disabled only on public/default-PSK channels; private-PSK channels are preserved.
    if (channelFile.version < POSITION_TELEMETRY_OPTIN_VER) {
        LOG_INFO("Opt-in migration: disabling position broadcast on public channels");
        optInDisablePositionSharing(channelFile);
        channelFile.version = POSITION_TELEMETRY_OPTIN_VER;
        saveToDisk(SEGMENT_CHANNELS);
    }
    if (moduleConfig.version < POSITION_TELEMETRY_OPTIN_VER) {
        LOG_INFO("Opt-in migration: forcing device telemetry broadcast to opt-in");
        optInDisableTelemetryBroadcast(moduleConfig);
        moduleConfig.version = POSITION_TELEMETRY_OPTIN_VER;
        saveToDisk(SEGMENT_MODULECONFIG);
    }

    if (channels.ensureLicensedOperation()) {
        LOG_WARN("Licensed operation removed persisted channel encryption/admin access");
        saveToDisk(SEGMENT_CHANNELS);
    }
#if ARCH_PORTDUINO
    // set any config overrides
    if (portduino_config.has_configDisplayMode) {
        config.display.displaymode = (_meshtastic_Config_DisplayConfig_DisplayMode)portduino_config.configDisplayMode;
    }
    if (portduino_config.has_statusMessage) {
        moduleConfig.has_statusmessage = true;
        strncpy(moduleConfig.statusmessage.node_status, portduino_config.statusMessage.c_str(),
                sizeof(moduleConfig.statusmessage.node_status));
        moduleConfig.statusmessage.node_status[sizeof(moduleConfig.statusmessage.node_status) - 1] = '\0';
    }
    if (portduino_config.enable_UDP) {
        config.network.enabled_protocols = meshtastic_Config_NetworkConfig_ProtocolFlags_UDP_BROADCAST;
    }

#endif
}

#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// Serializes reloadFromDisk against itself. Other readers of config /
// channelFile / nodeDatabase don't take this lock today, so this only
// prevents reload-vs-reload races (e.g. fast successive unlocks). It is
// not a full data-race fix for those structs - that would require
// thread-shared locking discipline across the whole codebase, beyond
// the audit's M7 scope. The radio standby+reconfigure below keeps the
// radio out of the window where SX12xx registers are mid-swap.
static concurrency::Lock g_reloadFromDiskMutex;

/**
 * Re-run loadFromDisk() after encrypted storage is unlocked at runtime.
 * Holds the radio in standby across the file IO + proto decode so the
 * SX12xx is not mid-RX/TX when config.lora is overwritten, then calls
 * reconfigure() to push the now-real settings to the chip.
 *
 * Returns true iff every encrypted file decrypted and decoded cleanly.
 * On false the caller MUST treat storage as corrupt - see header.
 */
bool NodeDB::reloadFromDisk()
{
    concurrency::LockGuard guard(&g_reloadFromDiskMutex);
    LOG_INFO("NodeDB: Reloading config from encrypted storage after unlock");

    RadioInterface *rIface = router ? router->getRadioIface() : nullptr;

    // Park the radio while config.lora / channelFile swap. Without this,
    // a concurrent send or receive can read half-old / half-new state
    // (channel keys, region, modem preset) and the SX12xx ends up in
    // an inconsistent register set that only a reboot recovers from.
    if (rIface)
        rIface->sleep();

    loadFromDisk();

    if (storageCorruptThisLoad) {
        LOG_ERROR("NodeDB: reload decrypt/decode failed - treat as corrupt");
        // Leave the radio sleeping. Caller will lock storage and emit
        // a LOCKED(storage_corrupt) status; we must not reconfigure
        // the chip with the locked-default placeholder values still
        // sitting in config.lora.
        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();
        rIface->reconfigure();
    }
    return true;
}

bool NodeDB::disableLockdownToPlaintext()
{
    concurrency::LockGuard guard(&g_reloadFromDiskMutex);
    if (!EncryptedStorage::isUnlocked()) {
        LOG_ERROR("NodeDB: disable requested but storage not unlocked");
        return false;
    }
    LOG_INFO("NodeDB: reverting encrypted prefs to plaintext for lockdown disable");

    // Decrypt each encrypted pref back to plaintext IN PLACE. Mirror of the
    // plaintext->encrypted migrate loop above. Order does not matter here;
    // EncryptedStorage::removeLockdownArtifacts() (which deletes the DEK,
    // the commit point) only runs after every file is confirmed plaintext.
    const char *filesToCheck[] = {STANDARD_CONFIG_FILE_NAME, STANDARD_CHANNEL_FILE_NAME, STANDARD_BACKUP_FILE_NAME,
                                  EVENT_CONFIG_FILE_NAME,    EVENT_CHANNEL_FILE_NAME,    EVENT_BACKUP_FILE_NAME,
                                  moduleConfigFileName,      deviceStateFileName,        nodeDatabaseFileName};
    for (const char *fn : filesToCheck) {
        if (!EncryptedStorage::migrateFileToPlaintext(fn)) {
            LOG_ERROR("NodeDB: revert %s to plaintext failed; abort disable (stays in lockdown)", fn);
            return false;
        }
    }

    // All files are plaintext now - remove the lockdown artifacts. Deleting
    // /prefs/.dek is the atomic commit: after it, isLockdownActive() is false.
    EncryptedStorage::removeLockdownArtifacts();
    return true;
}
#endif

/** Save a protobuf from a file, return true for success */
bool NodeDB::saveProto(const char *filename, size_t protoSize, const pb_msgdesc_t *fields, const void *dest_struct,
                       bool fullAtomic)
{

    // Only the radio profile is at risk from an unverified config load, so only defer those writes.
    // Devicestate/nodedb/module writes must still land, otherwise boot-time recovery (e.g. loadFromDisk()
    // restoring owner fields) is dropped and never retried.
    if (isRadioProfileFile(filename) &&
        shouldDeferBootPersistence(bootInitializationInProgress, configLoadComplete, configDecodeFailed)) {
        LOG_WARN("NodeDB: deferred boot write to %s until config recovery completes", filename);
        return true;
    }

    // do not try to save anything if power level is not safe. In many cases flash will be lock-protected
    // and all writes will fail anyway. Device should be sleeping at this point anyway.
    if (!powerHAL_isPowerLevelSafe()) {
        LOG_ERROR("saveProto() on unsafe device power level");
        return false;
    }

#ifdef MESHTASTIC_ENCRYPTED_STORAGE
    // Encrypt all files except uiconfig (no secrets) and the DEK file (self-encrypted).
    // Only when lockdown is ACTIVE (provisioned). A lockdown-capable but DISABLED
    // device has no DEK, so encryptAndWrite would fail and config would never
    // persist - it must save plaintext exactly like stock firmware. Once enabled,
    // the reloadFromDisk migrate pass re-saves these plaintext files encrypted.
    if (EncryptedStorage::isLockdownActive() && strcmp(filename, uiconfigFileName) != 0) {
        // ZeroizingArrayPtr wipes the unencrypted protobuf encoding (which contains
        // config secrets - channel PSKs, security private_key, etc.) before delete[],
        // so plaintext copies aren't left in heap memory after encryption completes.
        auto pbBuf = meshtastic_security::make_zeroizing_array(protoSize);
        if (!pbBuf) {
            LOG_ERROR("OOM encoding %s for encryption", filename);
            return false;
        }

        pb_ostream_t stream = pb_ostream_from_buffer(pbBuf.get(), protoSize);
        if (!pb_encode(&stream, fields, dest_struct)) {
            LOG_ERROR("Can't encode protobuf %s", PB_GET_ERROR(&stream));
            return false;
        }

        size_t encodedSize = stream.bytes_written;
        bool ok = EncryptedStorage::encryptAndWrite(filename, pbBuf.get(), encodedSize, fullAtomic);

        if (!ok) {
            LOG_ERROR("EncryptedStorage: encrypt+write %s failed", filename);
        }
        return ok;
    }
#endif

    bool okay = false;
#ifdef FSCom
    auto f = SafeFile(filename, fullAtomic);

    LOG_INFO("Save %s", filename);
    pb_ostream_t stream = {&writecb, static_cast<Print *>(&f), protoSize};

    if (!pb_encode(&stream, fields, dest_struct)) {
        LOG_ERROR("Can't encode protobuf %s", PB_GET_ERROR(&stream));
    } else {
        okay = true;
    }

    bool writeSucceeded = f.close();

    if (!okay || !writeSucceeded) {
        LOG_ERROR("Can't write prefs");
    }
#else
    LOG_ERROR("Filesystem not implemented");
#endif
    return okay;
}

bool NodeDB::saveChannelsToDisk()
{

    // do not try to save anything if power level is not safe. In many cases flash will be lock-protected
    // and all writes will fail anyway.
    if (!powerHAL_isPowerLevelSafe()) {
        LOG_ERROR("saveChannelsToDisk() on unsafe device power level");
        return false;
    }

#ifdef FSCom
    spiLock->lock();
    FSCom.mkdir("/prefs");
    spiLock->unlock();
#endif

    return saveProto(channelFileName, meshtastic_ChannelFile_size, &meshtastic_ChannelFile_msg, &channelFile);
}

bool NodeDB::saveDeviceStateToDisk()
{

    // do not try to save anything if power level is not safe. In many cases flash will be lock-protected
    // and all writes will fail anyway. Device should be sleeping at this point anyway.
    if (!powerHAL_isPowerLevelSafe()) {
        LOG_ERROR("saveDeviceStateToDisk() on unsafe device power level");
        return false;
    }

#ifdef FSCom
    spiLock->lock();
    FSCom.mkdir("/prefs");
    spiLock->unlock();
#endif
    // Note: if MAX_NUM_NODES=100 and meshtastic_NodeInfoLite_size=166, so will be approximately 17KB
    // Because so huge we _must_ not use fullAtomic, because the filesystem is probably too small to hold two copies of this
    return saveProto(deviceStateFileName, meshtastic_DeviceState_size, &meshtastic_DeviceState_msg, &devicestate, true);
}

bool NodeDB::saveNodeDatabaseToDisk()
{
    // Don't persist the node DB until this device has a PKI keypair
    // TODO: revisit when https://github.com/meshtastic/firmware/pull/10478 lands
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
    if (owner.public_key.size != 32 && !owner.is_licensed) {
        LOG_DEBUG("Skip NodeDB without key");
        return true;
    }
#endif

    // do not try to save anything if power level is not safe. In many cases flash will be lock-protected
    // and all writes will fail anyway. Device should be sleeping at this point anyway.
    if (!powerHAL_isPowerLevelSafe()) {
        LOG_ERROR("saveNodeDatabaseToDisk() on unsafe device power level");
        return false;
    }

    // Defer (don't fail) while xmodem holds the prefs file handle. Returning false
    // would propagate through saveToDisk() and trigger fsFormat() mid-transfer.
#ifdef FSCom
    if (xModem.isBusy()) {
        LOG_DEBUG("Defer NodeDB save: xmodem in progress");
        return true;
    }
#endif

#ifdef FSCom
    spiLock->lock();
    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);
#if !MESHTASTIC_EXCLUDE_POSITIONDB
    nodeDatabase.positions.clear();
    nodeDatabase.positions.reserve(nodePositions.size());
    for (const auto &kv : nodePositions) {
        meshtastic_NodePositionEntry entry = meshtastic_NodePositionEntry_init_default;
        entry.num = kv.first;
        entry.has_position = true;
        entry.position = kv.second;
        nodeDatabase.positions.push_back(entry);
    }
#else
    nodeDatabase.positions.clear();
#endif

#if !MESHTASTIC_EXCLUDE_TELEMETRYDB
    nodeDatabase.telemetry.clear();
    nodeDatabase.telemetry.reserve(nodeTelemetry.size());
    for (const auto &kv : nodeTelemetry) {
        meshtastic_NodeTelemetryEntry entry = meshtastic_NodeTelemetryEntry_init_default;
        entry.num = kv.first;
        entry.has_device_metrics = true;
        entry.device_metrics = kv.second;
        nodeDatabase.telemetry.push_back(entry);
    }
#else
    nodeDatabase.telemetry.clear();
#endif

#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB
    nodeDatabase.environment.clear();
    nodeDatabase.environment.reserve(nodeEnvironment.size());
    for (const auto &kv : nodeEnvironment) {
        meshtastic_NodeEnvironmentEntry entry = meshtastic_NodeEnvironmentEntry_init_default;
        entry.num = kv.first;
        entry.has_environment_metrics = true;
        entry.environment_metrics = kv.second;
        nodeDatabase.environment.push_back(entry);
    }
#else
    nodeDatabase.environment.clear();
#endif

#if !MESHTASTIC_EXCLUDE_STATUSDB
    nodeDatabase.status.clear();
    nodeDatabase.status.reserve(nodeStatus.size());
    for (const auto &kv : nodeStatus) {
        meshtastic_NodeStatusEntry entry = meshtastic_NodeStatusEntry_init_default;
        entry.num = kv.first;
        entry.has_status = true;
        entry.status = kv.second;
        nodeDatabase.status.push_back(entry);
    }
#else
    nodeDatabase.status.clear();
#endif

    size_t nodeDatabaseSize;
    pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &nodeDatabase);
    bool ok = saveProto(nodeDatabaseFileName, nodeDatabaseSize, &meshtastic_NodeDatabase_msg, &nodeDatabase, false);

    nodeDatabase.positions.clear();
    nodeDatabase.positions.shrink_to_fit();
    nodeDatabase.telemetry.clear();
    nodeDatabase.telemetry.shrink_to_fit();
    nodeDatabase.environment.clear();
    nodeDatabase.environment.shrink_to_fit();
    nodeDatabase.status.clear();
    nodeDatabase.status.shrink_to_fit();
#if WARM_NODE_COUNT > 0
#ifdef ARCH_RP2040
    // nodes.proto + warm.dat are written back-to-back without the loop running between them;
    // reset the 8s HW watchdog so the second write gets a full budget (issue #10746).
    watchdog_update();
#endif
    // 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;
}

bool NodeDB::saveToDiskNoRetry(int saveWhat)
{

    // do not try to save anything if power level is not safe. In many cases flash will be lock-protected
    // and all writes will fail anyway. Device should be sleeping at this point anyway.
    if (!powerHAL_isPowerLevelSafe()) {
        LOG_ERROR("saveToDiskNoRetry() on unsafe device power level");
        return false;
    }

#ifdef MESHTASTIC_ENCRYPTED_STORAGE
    // When lockdown is ACTIVE but storage is still locked, encryptAndWrite()
    // returns false for every file. That would cause saveToDisk()'s nRF52 retry
    // path to call FSCom.format(), wiping all encrypted proto files from flash.
    // Return true here - "nothing to save, not an error."
    //
    // Gate on isLockdownActive(): a lockdown-capable but DISABLED device (never
    // provisioned) also has isUnlocked()==false, but it must persist plaintext
    // normally - skipping here would silently drop every config write (e.g. the
    // LoRa region) until the device is provisioned.
    if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
        LOG_WARN("NodeDB: saveToDisk skipped - encrypted storage locked");
        return true;
    }
#endif

    bool success = true;
#ifdef FSCom
    spiLock->lock();
    FSCom.mkdir("/prefs");
    spiLock->unlock();
#endif

#if USERPREFS_EVENT_MODE
    if (eventProfileStorageUnavailable) {
        if (saveWhat & SEGMENT_CONFIG) {
            LOG_WARN("Skip event config write: insufficient profile storage at boot");
            saveWhat &= ~SEGMENT_CONFIG;
        }
        if (saveWhat & SEGMENT_CHANNELS) {
            LOG_WARN("Skip event channel write: insufficient profile storage at boot");
            saveWhat &= ~SEGMENT_CHANNELS;
        }
    }
#endif

    if (saveWhat & SEGMENT_CONFIG) {
        config.has_device = true;
        config.has_display = true;
        config.has_lora = true;
        config.has_position = true;
        config.has_power = true;
        config.has_network = true;
        config.has_bluetooth = true;
        config.has_security = true;

        success &= saveProto(configFileName, meshtastic_LocalConfig_size, &meshtastic_LocalConfig_msg, &config);
    }

    if (saveWhat & SEGMENT_MODULECONFIG) {
        moduleConfig.has_canned_message = true;
        moduleConfig.has_external_notification = true;
        moduleConfig.has_mqtt = true;
        moduleConfig.has_range_test = true;
        moduleConfig.has_serial = true;
        moduleConfig.has_store_forward = true;
        moduleConfig.has_telemetry = true;
        moduleConfig.has_neighbor_info = true;
        moduleConfig.has_detection_sensor = true;
        moduleConfig.has_ambient_lighting = true;
        moduleConfig.has_audio = true;
        moduleConfig.has_paxcounter = true;
        moduleConfig.has_statusmessage = true;
        moduleConfig.has_tak = true;
#if !MESHTASTIC_EXCLUDE_BEACON
        moduleConfig.has_mesh_beacon = true;
#endif

        success &=
            saveProto(moduleConfigFileName, meshtastic_LocalModuleConfig_size, &meshtastic_LocalModuleConfig_msg, &moduleConfig);
    }

    if (saveWhat & SEGMENT_CHANNELS) {
        success &= saveChannelsToDisk();
    }

    if (saveWhat & SEGMENT_DEVICESTATE) {
        success &= saveDeviceStateToDisk();
    }

    if (saveWhat & SEGMENT_NODEDATABASE) {
        success &= saveNodeDatabaseToDisk();
    }

    return success;
}

bool NodeDB::saveToDisk(int saveWhat)
{
    LOG_DEBUG("Save to disk %d", saveWhat);

    // do not try to save anything if power level is not safe. In many cases flash will be lock-protected
    // and all writes will fail anyway. Device should be sleeping at this point anyway.
    if (!powerHAL_isPowerLevelSafe()) {
        LOG_ERROR("saveToDisk() on unsafe device power level");
        return false;
    }

    bool success = saveToDiskNoRetry(saveWhat);

    if (!success) {
        LOG_ERROR("Save to disk failed, retry");
        spiLock->lock();
        fsFormat();
        spiLock->unlock();

        success = saveToDiskNoRetry(saveWhat);

        RECORD_CRITICALERROR(success ? meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE
                                     : meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE);
    }

    return success;
}

const meshtastic_NodeInfoLite *NodeDB::readNextMeshNode(uint32_t &readIndex)
{
    if (readIndex < numMeshNodes)
        return &meshNodes->at(readIndex++);
    else
        return NULL;
}

/// Given a node, return how many seconds in the past (vs now) that we last heard from it
uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n)
{
    uint32_t now = getTime();

    int delta = (int)(now - n->last_heard);
    if (delta < 0) // our clock must be slightly off still - not set from GPS yet
        delta = 0;

    return delta;
}

uint32_t sinceReceived(const meshtastic_MeshPacket *p)
{
    // rx_time may be an uptime-seconds placeholder while has_rx_time is false - don't age it as
    // wall-clock, and don't pass it off as "just now" either.
    if (!p->has_rx_time)
        return SINCE_UNKNOWN;

    uint32_t now = getTime();

    int delta = (int)(now - p->rx_time);
    if (delta < 0) // our clock must be slightly off still - not set from GPS yet
        delta = 0;

    return delta;
}

HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p)
{
    // Guard against invalid values.
    if (p.hop_start < p.hop_limit)
        return HopStartStatus::INVALID;

    if (p.hop_start == 0) {
        // hop_start == 0 is either a modern zero-hop broadcast (e.g. beacon) or pre-2.3.0 firmware (585805c)
        // that never populated hop_start. Firmware 2.5.0 (bf34329) introduced a bitfield that is always
        // present; use it to tell the two apart. The bitfield is encrypted under the channel key, so this can
        // only be resolved for decoded packets - until then the status stays MISSING_OR_UNKNOWN. Callers
        // acting before decode must therefore not treat UNKNOWN as a drop (the bitfield isn't readable yet);
        // the verdict is re-checked post-decode in Router::handleReceived.
        if (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.has_bitfield)
            return HopStartStatus::VALID;
        return HopStartStatus::MISSING_OR_UNKNOWN;
    }

    return HopStartStatus::VALID;
}

int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown)
{
    // Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a
    // bitfield that is always present. Use the presence of the bitfield to determine if the origin's firmware
    // version is guaranteed to have hop_start populated. Note that this can only be done for decoded packets as
    // the bitfield is encrypted under the channel encryption key. For encrypted packets, this returns
    // defaultIfUnknown when hop_start is 0.
    if (p.hop_start == 0 && !(p.which_payload_variant == meshtastic_MeshPacket_decoded_tag && p.decoded.has_bitfield))
        return defaultIfUnknown; // Cannot reliably determine the number of hops.

    // Guard against invalid values.
    if (p.hop_start < p.hop_limit)
        return defaultIfUnknown;

    return p.hop_start - p.hop_limit;
}

#define NUM_ONLINE_SECS (60 * 60 * 2) // 2 hrs to consider someone offline

size_t NodeDB::getNumOnlineMeshNodes(bool localOnly)
{
    size_t numseen = 0;

    // FIXME this implementation is kinda expensive
    for (int i = 0; i < numMeshNodes; i++) {
        if (localOnly && nodeInfoLiteViaMqtt(&meshNodes->at(i)))
            continue;
        if (sinceLastSeen(&meshNodes->at(i)) < NUM_ONLINE_SECS)
            numseen++;
    }

    return numseen;
}

#include "MeshModule.h"
#include "Throttle.h"

// Minimum spacing between evictions once the node database is full.
#define NODEDB_FULL_EVICTION_INTERVAL_MS (2 * 1000UL)

static constexpr uint32_t HOPSTART_DROP_LOG_INTERVAL_MS = 15000;

void logHopStartDrop(const meshtastic_MeshPacket &p, const char *context)
{
    static uint32_t lastLogMs = 0;
    if (Throttle::isWithinTimespanMs(lastLogMs, HOPSTART_DROP_LOG_INTERVAL_MS)) {
        return;
    }
    lastLogMs = millis();
    const bool decoded = (p.which_payload_variant == meshtastic_MeshPacket_decoded_tag);
    const bool hasBitfield = decoded && p.decoded.has_bitfield;
    LOG_DEBUG(
        "Drop packet (%s): hop_start invalid/missing (from=0x%08x id=%u hop_start=%u hop_limit=%u decoded=%d has_bitfield=%d)",
        context ? context : "unknown", p.from, p.id, p.hop_start, p.hop_limit, decoded, hasBitfield);
}

/** Update position info for this node based on received position data
 */
void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSource src)
{
    meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
    if (!info) {
        return;
    }

#if MESHTASTIC_EXCLUDE_POSITIONDB
    // Build flag opted out: header still tracks last_heard via updateFrom; we
    // simply don't cache the position payload anywhere on this device.
    if (src == RX_SRC_LOCAL) {
        LOG_INFO("updatePosition LOCAL (PositionDB excluded) time=%u lat=%d lon=%d alt=%d", p.time, p.latitude_i, p.longitude_i,
                 p.altitude);
        setLocalPosition(p);
    }
    (void)nodeId;
    updateGUIforNode = info;
    notifyObservers(true);
#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) {
            // Local packet, fully authoritative
            LOG_INFO("updatePosition LOCAL pos@%x time=%u lat=%d lon=%d alt=%d", p.timestamp, p.time, p.latitude_i, p.longitude_i,
                     p.altitude);

            setLocalPosition(p);
            slot = TypeConversions::ConvertToPositionLite(p);
        } else if ((p.time > 0) && !p.latitude_i && !p.longitude_i && !p.timestamp && !p.location_source) {
            // FIXME SPECIAL TIME SETTING PACKET FROM EUD TO RADIO
            // (stop-gap fix for issue #900)
            LOG_DEBUG("updatePosition SPECIAL time setting time=%u", p.time);
            slot.time = p.time;
        } else {
            // Be careful to only update fields that have been set by the REMOTE sender
            // A lot of position reports don't have time populated.  In that case, be careful to not blow away the time we
            // recorded based on the packet rxTime
            //
            // FIXME perhaps handle RX_SRC_USER separately?
            LOG_INFO("updatePosition REMOTE node=0x%08x time=%u lat=%d lon=%d", nodeId, p.time, p.latitude_i, p.longitude_i);

            // First, back up fields that we want to protect from overwrite
            uint32_t tmp_time = slot.time;

            // Next, update atomically
            slot = TypeConversions::ConvertToPositionLite(p);

            // Last, restore any fields that may have been overwritten
            if (!slot.time)
                slot.time = tmp_time;
        }
    }
    updateGUIforNode = info;
    notifyObservers(true); // Force an update whether or not our node counts have changed
#endif
}

/** Update telemetry info for this node based on received metrics. Stores
 *  device_metrics and environment_metrics into their respective satellite
 *  maps; other variants (air_quality, power, local_stats, health) are
 *  intentionally not retained per-node.
 */
void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxSource src)
{
    meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
    if (!info)
        return;

    if (t.which_variant == meshtastic_Telemetry_device_metrics_tag) {
        if (src == RX_SRC_LOCAL) {
            LOG_TRACE("updateTelemetry LOCAL device");
        } else {
            LOG_TRACE("updateTelemetry REMOTE device node=0x%08x", nodeId);
        }
#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_TRACE("updateTelemetry LOCAL env");
        } else {
            LOG_TRACE("updateTelemetry REMOTE env node=0x%08x", nodeId);
        }
#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
    }
    updateGUIforNode = info;
    notifyObservers(true);
}

/**
 * Update the node database with a new contact
 */
void NodeDB::addFromContact(meshtastic_SharedContact contact)
{
    meshtastic_NodeInfoLite *info = getOrCreateMeshNode(contact.node_num);
    if (!info || !contact.has_user) {
        return;
    }
    // If the local node has this node marked as manually verified
    // and the client does not, do not allow the client to update the
    // saved public key.
    if (nodeInfoLiteIsKeyManuallyVerified(info) && !contact.manually_verified) {
        if (contact.user.public_key.size != info->public_key.size ||
            memcmp(contact.user.public_key.bytes, info->public_key.bytes, info->public_key.size) != 0) {
            return;
        }
    }
    info->num = contact.node_num;
    // CopyUserToNodeInfoLite assigns public_key unconditionally, and clients send add_contact before every
    // DM - often from an entry that carries no key at all. A contact may still supply or update a full
    // 32-byte key (that's what add_contact is for), but it must never *erase* a key we already hold, which
    // would be persisted below and break subsequent DMs with PKI_SEND_FAIL_PUBLIC_KEY.
    const meshtastic_NodeInfoLite_public_key_t storedKey = info->public_key;
    TypeConversions::CopyUserToNodeInfoLite(info, contact.user);
    if (storedKey.size == 32 && info->public_key.size != 32) {
        LOG_INFO("Contact 0x%08x has no key, keep the stored one", contact.node_num);
        info->public_key = storedKey;
    }
    if (contact.should_ignore) {
        // 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);
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
        messageStore.deleteAllMessagesFromNode(contact.node_num);
#endif
    } 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!
         */

        /* "Boring old nodes" are the first to be evicted out of the node database when full. This includes a newly-zeroed
         * nodeinfo because it has: !is_favorite && last_heard==0. To keep this from happening when we addFromContact, we set the
         * new node as a favorite, and we leave last_heard alone (even if it's zero).
         */
        if (config.device.role == meshtastic_Config_DeviceConfig_Role_CLIENT_BASE) {
            // Special case for CLIENT_BASE: is_favorite has special meaning, and we don't want to automatically set it
            // without the user doing so deliberately. We don't normally expect users to use a CLIENT_BASE to send DMs or to add
            // contacts, but we should make sure it doesn't auto-favorite in case they do. Instead, as a workaround, we'll
            // stamp the contact as heard now, so that the add_contact node doesn't immediately get evicted.
            stampContactHeardNow(info);
        } 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).
            // If the protected cap refuses the favorite, fall back to a heard-now stamp so the
            // contact still isn't the first eviction victim.
            if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) {
                LOG_WARN(PROTECTED_CAP_WARN_FMT, "favorite", contact.node_num, MAX_NUM_NODES - 2);
                stampContactHeardNow(info);
            }
        }

        // 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) {
            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;
        sortMeshDB();
        notifyObservers(true); // Force an update whether or not our node counts have changed
    }
    saveNodeDatabaseToDisk();
}

/** Update user info and channel for this node based on received user data
 */
bool NodeDB::updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex, bool xeddsaSigned)
{
    // Only a signed update may change the identity of a proven signer; our own record is exempt.
    // Checked before getOrCreateMeshNode so a refusal cannot evict; isKnownXeddsaSigner covers the warm tier.
    if (nodeId != getNodeNum() && isKnownXeddsaSigner(nodeId) && !xeddsaSigned) {
        LOG_WARN("Refuse unsigned identity update for 0x%08x that previously signed", nodeId);
        return false;
    }

    meshtastic_NodeInfoLite *info = getOrCreateMeshNode(nodeId);
    if (!info) {
        return false;
    }

#if !(MESHTASTIC_EXCLUDE_PKI)
    if (p.public_key.size == 32 && nodeId != nodeDB->getNodeNum()) {
        printBytes("Incoming Pubkey: ", p.public_key.bytes, 32);

        // Alert the user if a remote node is advertising public key that matches our own
        if (owner.public_key.size == 32 && memcmp(p.public_key.bytes, owner.public_key.bytes, 32) == 0) {
            if (!duplicateWarned) {
                duplicateWarned = true;
                // Sanitize before embedding long_name in the phone-facing ClientNotification string
                // (defense-in-depth vs PB_VALIDATE_UTF8).
                char safeName[sizeof(p.long_name)];
                strncpy(safeName, p.long_name, sizeof(safeName));
                safeName[sizeof(safeName) - 1] = '\0';
                sanitizeUtf8(safeName, sizeof(safeName));
                char warning[] =
                    "Remote device %s has advertised your public key. This may indicate a compromised key. You may need "
                    "to regenerate your public keys.";
                LOG_WARN(warning, safeName);
                meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
                if (cn) {
                    cn->level = meshtastic_LogRecord_Level_WARNING;
                    cn->time = getValidTime(RTCQualityFromNet);
                    snprintf(cn->message, sizeof(cn->message), warning, safeName);
                    service->sendClientNotification(cn);
                }
            }
            return false;
        }
    }
    if (info->public_key.size == 32) { // if we have a key for this user already, don't overwrite with a new one
        // if the key doesn't match, don't update nodeDB at all.
        if (p.public_key.size != 32 || (memcmp(p.public_key.bytes, info->public_key.bytes, 32) != 0)) {
            LOG_WARN("Public Key mismatch, drop NodeInfo");
            return false;
        }
        LOG_INFO("Public Key set, not updating");
    } else if (p.public_key.size == 32) {
        LOG_INFO("Update Node Pubkey");
    }
#endif

    // Always ensure user.id is derived from nodeId, regardless of what was received
    snprintf(p.id, sizeof(p.id), "!%08x", nodeId);

    meshtastic_NodeInfoLite before = *info;
    TypeConversions::CopyUserToNodeInfoLite(info, p);
    bool changed =
        (memcmp(before.long_name, info->long_name, sizeof(info->long_name)) != 0) ||
        (memcmp(before.short_name, info->short_name, sizeof(info->short_name)) != 0) || (before.hw_model != info->hw_model) ||
        (before.role != info->role) || (before.public_key.size != info->public_key.size) ||
        (info->public_key.size > 0 && memcmp(before.public_key.bytes, info->public_key.bytes, info->public_key.size) != 0) ||
        (before.bitfield != info->bitfield) || (info->channel != channelIndex);

    if (info->public_key.size == 32) {
        printBytes("Saved Pubkey: ", info->public_key.bytes, 32);
    }
    if (nodeId != getNodeNum())
        info->channel = channelIndex; // Set channel we need to use to reach this node (but don't set our own channel)
    LOG_DEBUG("Update changed=%d user %s/%s, id=0x%08x, channel=%d", changed, info->long_name, info->short_name, nodeId,
              info->channel);

    if (changed) {
        updateGUIforNode = info;
        notifyObservers(true); // Force an update whether or not our node counts have changed

        // We just changed something about a User,
        // store our DB unless we just did so less than a minute ago

        if (!Throttle::isWithinTimespanMs(lastNodeDbSave, ONE_MINUTE_MS)) {
            saveToDisk(SEGMENT_NODEDATABASE);
            lastNodeDbSave = millis();
        } else {
            LOG_DEBUG("Defer NodeDB saveToDisk");
        }
    }

#if HAS_TRAFFIC_MANAGEMENT
    // Write-through: every accepted remote-identity commit lands here (NodeInfoModule,
    // MeshService, and TMM's requester learning all funnel through updateUser; the two
    // key-write sites that bypass it call onNodeKeyCommitted instead), so TMM's NodeInfo
    // cache reflects the commit immediately rather than at the next reconcile pass. Runs on
    // acceptance, not on `changed`: an identical update still proves the identity is
    // current. `p` is the post-hygiene payload; signerKnown transfers only key-matched
    // verified-signer status (isVerifiedSignerForKey semantics), never a bare node flag.
    if (nodeId != getNodeNum() && trafficManagementModule) {
        const bool signerKnown = p.public_key.size == 32 && isVerifiedSignerForKey(nodeId, p.public_key.bytes);
        trafficManagementModule->onNodeIdentityCommitted(nodeId, p, signerKnown);
    }
#endif

    return changed;
}

/// given a subpacket sniffed from the network, update our DB state
/// we updateGUI and updateGUIforNode if we think our this change is big enough for a redraw
void NodeDB::updateFrom(const meshtastic_MeshPacket &mp)
{
    if (mp.from == getNodeNum()) {
        LOG_DEBUG("Ignore update from self");
        return;
    }
    if (mp.which_payload_variant == meshtastic_MeshPacket_decoded_tag && mp.from) {
        LOG_TRACE("Update DB node 0x%08x, rx_time=%u", mp.from, mp.rx_time);

        // mp.from is unauthenticated, so rate-limit admission once the database is full: otherwise
        // invented node numbers churn it at packet rate and push real neighbours out.
        meshtastic_NodeInfoLite *info = getMeshNode(getFrom(&mp));
        if (!info) {
            if (isFull()) {
                if (Throttle::isWithinTimespanMs(lastFullEvictionMs, NODEDB_FULL_EVICTION_INTERVAL_MS)) {
                    LOG_DEBUG("Node database full, defer admitting 0x%08x", mp.from);
                    return;
                }
                lastFullEvictionMs = millis();
            }
            info = getOrCreateMeshNode(getFrom(&mp));
        }
        if (!info) {
            return;
        }

        // Gate on has_rx_time, not truthiness - rx_time may hold an uptime-seconds placeholder.
        if (mp.has_rx_time)
            info->last_heard = mp.rx_time;
        else
            // rx_time is the arrival instant in uptime seconds. It goes to the RAM sidecar, not
            // last_heard, which only ever holds a real epoch or 0.
            recordHeardWhileClockUntrusted(getFrom(&mp), mp.rx_time);

        // Gate on the packet actually having been received over our own radio, not on rx_snr being
        // truthy, because 0 dB is valid. TRANSPORT_LORA is set only on the real over-the-air RX path
        // (RadioInterface.cpp); it excludes TRANSPORT_INTERNAL and TRANSPORT_MQTT, while still accepting
        // an MQTT-origin packet that a gateway rebroadcasts onto LoRa - we genuinely measured that one
        // ourselves. Mirrors hop histogram below.
        // Belt-and-braces: also require has_rx_rssi, which every genuine RF-reception site sets
        // unconditionally alongside rx_snr - unlike PhoneAPI's replay packets, which set TRANSPORT_LORA
        // too (so the client treats restored history as if heard over the air) but never has_rx_rssi.
        // Replay packets don't reach updateFrom() today; this check guards against a future change that
        // routes them back through this path silently recording a replayed rx_snr as a fresh measurement.
        if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && mp.has_rx_rssi) {
            info->snr = mp.rx_snr; // keep the most recent SNR we received for this node.
            nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_HAS_SNR_MASK, true);
        }

        nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_VIA_MQTT_MASK,
                           mp.via_mqtt); // Store if we received this packet via MQTT

#if HAS_VARIABLE_HOPS
        // Only sample genuine RF-origin packets. The transport check excludes packets received
        // directly from the broker (TRANSPORT_MQTT), but an MQTT-origin packet rebroadcast onto
        // LoRa by a gateway arrives as TRANSPORT_LORA with via_mqtt set - count those would
        // inflate the local mesh-size estimate with non-RF nodes (and they usually carry
        // hop_start==0, landing in the hop-0 bucket that pulls the recommendation lowest), so
        // exclude via_mqtt too.
        //
        // The std::max clamp below is deliberate, not a bug: Counting an unproven-but-real neighbor as 0 hops is the
        // conservative direction. This intentionally does not agree with the `hopsAway >= 0`
        // gate below, which rejects the same -1 rather than storing it as a fabricated 0 -
        // the histogram and the stored hops_away serve different purposes.
        if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt &&
            hopScalingModule) {
            uint8_t hopCount = std::max(int8_t(0), getHopsAway(mp));
            hopScalingModule->samplePacketForHistogram(mp.from, hopCount);
        }
#endif

        // If hopStart was set and there wasn't someone messing with the limit in the middle, add hopsAway
        const int8_t hopsAway = getHopsAway(mp);
        if (hopsAway >= 0) {
            info->has_hops_away = true;
            info->hops_away = hopsAway;
        }
        sortMeshDB();
    }
}

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)
        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)
{
    // returns true if nodeId is_favorite; false if not or not found

    // NODENUM_BROADCAST will never be in the DB
    if (nodeId == NODENUM_BROADCAST)
        return false;

    const meshtastic_NodeInfoLite *lite = getMeshNode(nodeId);

    if (lite) {
        return nodeInfoLiteIsFavorite(lite);
    }
    return false;
}

bool NodeDB::isFromOrToFavoritedNode(const meshtastic_MeshPacket &p)
{
    // This method is logically equivalent to:
    //   return isFavorite(p.from) || isFavorite(p.to);
    // but is more efficient by:
    //   1. doing only one pass through the database, instead of two
    //   2. exiting early when a favorite is found, or if both from and to have been seen

    if (p.to == NODENUM_BROADCAST)
        return isFavorite(p.from); // we never store NODENUM_BROADCAST in the DB, so we only need to check p.from

    meshtastic_NodeInfoLite *lite = NULL;

    bool seenFrom = false;
    bool seenTo = false;

    for (int i = 0; i < numMeshNodes; i++) {
        lite = &meshNodes->at(i);

        if (lite->num == p.from) {
            if (nodeInfoLiteIsFavorite(lite))
                return true;

            seenFrom = true;
        }

        if (lite->num == p.to) {
            if (nodeInfoLiteIsFavorite(lite))
                return true;

            seenTo = true;
        }

        if (seenFrom && seenTo)
            return false; // we've seen both, and neither is a favorite, so we can stop searching early

        // Note: if we knew that sortMeshDB was always called after any change to is_favorite, we could exit early after searching
        // all favorited nodes first.
    }

    return false;
}

void NodeDB::pause_sort(bool paused)
{
    sortingIsPaused = paused;
}

void NodeDB::sortMeshDB()
{
    if (!sortingIsPaused && (lastSort == 0 || !Throttle::isWithinTimespanMs(lastSort, 1000 * 5))) {
        lastSort = millis();
        bool changed = true;
        while (changed) { // dumb reverse bubble sort, but probably not bad for what we're doing
            changed = false;
            for (int i = numMeshNodes - 1; i > 0; i--) { // lowest case this should examine is i == 1
                if (meshNodes->at(i - 1).num == getNodeNum()) {
                    // noop
                } else if (meshNodes->at(i).num ==
                           getNodeNum()) { // in the oddball case our own node num is not at location 0, put it there
                    // TODO: Look for at(i-1) also matching own node num, and throw the DB in the trash
                    std::swap(meshNodes->at(i), meshNodes->at(i - 1));
                    changed = true;
                } else if (nodeInfoLiteIsFavorite(&meshNodes->at(i)) && !nodeInfoLiteIsFavorite(&meshNodes->at(i - 1))) {
                    std::swap(meshNodes->at(i), meshNodes->at(i - 1));
                    changed = true;
                } else if (!nodeInfoLiteIsFavorite(&meshNodes->at(i)) && nodeInfoLiteIsFavorite(&meshNodes->at(i - 1))) {
                    // noop
                } else if (meshNodes->at(i).last_heard > meshNodes->at(i - 1).last_heard) {
                    std::swap(meshNodes->at(i), meshNodes->at(i - 1));
                    changed = true;
                }
            }
        }
        LOG_INFO("Sort took %u ms", millis() - lastSort);
    }
}

uint8_t NodeDB::getMeshNodeChannel(NodeNum n)
{
    const meshtastic_NodeInfoLite *info = getMeshNode(n);
    if (!info) {
        return 0; // defaults to PRIMARY
    }
    return info->channel;
}

std::string NodeDB::getNodeId() const
{
    char nodeId[16];
    snprintf(nodeId, sizeof(nodeId), "!%08x", myNodeInfo.my_node_num);
    return std::string(nodeId);
}

/// Find a node in our DB, return null for missing
/// NOTE: This function might be called from an ISR
meshtastic_NodeInfoLite *NodeDB::getMeshNode(NodeNum n)
{
    for (int i = 0; i < numMeshNodes; i++)
        if (meshNodes->at(i).num == n)
            return &meshNodes->at(i);

    return NULL;
}

ResolvedNode NodeDB::resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor)
{
    ResolvedNode result; // defaults to {None, 0}

    // 0 is the NO_RELAY_NODE / NO_NEXT_HOP_PREFERENCE sentinel (also what MQTT-sourced packets carry
    // when hop_start==0). getLastByteOfNodeNum() never yields 0, so nothing can legitimately match.
    if (lastByte == 0)
        return result;

    const NodeNum self = getNodeNum();
    NodeNum firstMatch = 0;
    uint8_t matches = 0;

    for (size_t i = 0; i < numMeshNodes; i++) {
        const meshtastic_NodeInfoLite *node = &meshNodes->at(i);

        // Candidate gate: never resolve to ourselves, the sentinels, or an ignored node.
        if (node->num == self || node->num == 0 || node->num == NODENUM_BROADCAST)
            continue;
        if (nodeInfoLiteIsIgnored(node))
            continue;
        if (getLastByteOfNodeNum(node->num) != lastByte) // cheapest discriminator last
            continue;

        // Relevance gate: is this node a plausible relay for the requested scope?
        bool relevant;
        if (requireDirectNeighbor) {
            relevant = node->has_hops_away && node->hops_away == 0 && sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS;
        } else {
            const bool directNeighbor = node->has_hops_away && node->hops_away == 0;
            const bool routerRole =
                IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
                          meshtastic_Config_DeviceConfig_Role_CLIENT_BASE);
            relevant = directNeighbor || nodeInfoLiteIsFavorite(node) || routerRole;
        }
        if (!relevant)
            continue;

        if (++matches == 1) {
            firstMatch = node->num;
        } else {
            // A second relevant candidate shares this byte: ambiguous. No further scanning can
            // change that, so stop early and report the collision.
            result.status = LastByteResolution::Ambiguous;
            result.num = 0;
            return result;
        }
    }

    if (matches == 1) {
        result.status = LastByteResolution::Unique;
        result.num = firstMatch;
    }
    return result;
}

bool NodeDB::resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum)
{
    ResolvedNode r = resolveLastByte(lastByte, requireDirectNeighbor);
    if (r.status == LastByteResolution::Unique) {
        if (outNum)
            *outNum = r.num;
        return true;
    }
    return false;
}

// returns true if the maximum number of nodes is reached or we are running low on memory
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::copyPublicKeyAuthoritative(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;
}

bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
    if (copyPublicKeyAuthoritative(n, out))
        return true;
#if HAS_TRAFFIC_MANAGEMENT
    // Last resort: a key the TrafficManagement NodeInfo cache learned from an observed frame
    // for a node no longer in either NodeDB tier. This extends the pool of peers we can
    // encrypt to. Keys here may be trust-on-first-use (see copyPublicKey's keyProven), the
    // same first-contact trust NodeDB itself applies via updateUser().
    if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes)) {
        out.size = 32;
        return true;
    }
#endif
    return false;
}

bool NodeDB::copyPublicKeyForDecrypt(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out)
{
    if (copyPublicKeyAuthoritative(n, out))
        return true;
#if HAS_TRAFFIC_MANAGEMENT
    // A cold-tier cache key backs an authenticated decrypt only when key-proven; unverified TOFU
    // cache keys must not. Outbound encryption still uses the opportunistic copyPublicKey().
    bool keyProven = false;
    if (trafficManagementModule && trafficManagementModule->copyPublicKey(n, out.bytes, &keyProven) && keyProven) {
        out.size = 32;
        return true;
    }
#endif
    return false;
}

bool NodeDB::isVerifiedSignerForKey(NodeNum n, const uint8_t *key32)
{
    if (!key32)
        return false;
    // Hot store is authoritative when present; a node lives in the hot XOR warm tier, so if the
    // hot store holds it the warm tier does not, and we decide entirely from the hot entry.
    const meshtastic_NodeInfoLite *info = getMeshNode(n);
    if (info)
        return info->public_key.size == 32 && nodeInfoLiteHasXeddsaSigned(info) && memcmp(info->public_key.bytes, key32, 32) == 0;
#if WARM_NODE_COUNT > 0
    uint8_t warmKey[32];
    if (warmStore.copyKey(n, warmKey) && memcmp(warmKey, key32, 32) == 0)
        return warmStore.hasXeddsaSigned(n);
#endif
    return false;
}

bool NodeDB::isKnownXeddsaSigner(NodeNum n)
{
    // A node lives in the hot XOR warm tier, so the hot verdict is final when present.
    const meshtastic_NodeInfoLite *info = getMeshNode(n);
    if (info)
        return nodeInfoLiteHasXeddsaSigned(info);
#if WARM_NODE_COUNT > 0
    return warmStore.hasXeddsaSigned(n);
#else
    return false;
#endif
}

void NodeDB::commitRemoteKey(NodeNum n, const uint8_t key32[32], KeyCommitTrust trust)
{
    if (!key32 || n == 0)
        return;
    // Local copy first: callers may pass the node's own key bytes back in (e.g. manual
    // verification re-committing an already-stored key), and memcpy forbids overlap.
    uint8_t key[32];
    memcpy(key, key32, 32);

    meshtastic_NodeInfoLite *info = getOrCreateMeshNode(n);
    if (!info)
        return;
    // Unconditional overwrite - deliberately NOT updateUser()'s "don't replace a known key" pin.
    // That pin protects against unauthenticated NodeInfo broadcasts; the only callers here are
    // possession/authority-proven (ManuallyVerified = user confirmed the key; AdminChannelProven =
    // decrypted via the admin key with p->from bound into the AEAD nonce), i.e. exactly the paths
    // meant to establish or rotate a key. Keep new call sites to that same trust bar.
    memcpy(info->public_key.bytes, key, 32);
    info->public_key.size = 32;

#if HAS_TRAFFIC_MANAGEMENT
    // Write-through, mirroring updateUser()'s identity hook: without it the TrafficManagement
    // NodeInfo cache diverges until the next hourly reconcile.
    if (trafficManagementModule)
        trafficManagementModule->onNodeKeyCommitted(n, key, trust == KeyCommitTrust::ManuallyVerified);
#endif
}

meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n)
{
    const meshtastic_NodeInfoLite *info = getMeshNode(n);
    if (nodeInfoLiteHasUser(info))
        return info->role;
#if WARM_NODE_COUNT > 0
    // Hot-store miss: fall back to the role the warm tier cached at eviction.
    uint8_t role = 0, prot = 0;
    if (warmStore.lookupMeta(n, role, prot))
        return static_cast<meshtastic_Config_DeviceConfig_Role>(role);
#endif
    return meshtastic_Config_DeviceConfig_Role_CLIENT;
}

void NodeDB::recordHeardWhileClockUntrusted(NodeNum num, uint32_t heardAtUptime)
{
    // Update in place if the node already has a stamp.
    for (auto &h : heardAt) {
        if (h.num == num) {
            h.heardAtUptimeSecs = heardAtUptime;
            return;
        }
    }
    // Otherwise take an empty slot, or reuse the oldest stamp.
    NodeHeardAt *victim = &heardAt[0];
    for (auto &h : heardAt) {
        if (h.num == 0) {
            victim = &h;
            break;
        }
        if (h.heardAtUptimeSecs < victim->heardAtUptimeSecs)
            victim = &h;
    }
    victim->num = num;
    victim->heardAtUptimeSecs = heardAtUptime;
}

bool NodeDB::getHeardAtUptimeSecs(NodeNum num, uint32_t &stamp) const
{
    for (const auto &h : heardAt) {
        if (h.num == num) {
            stamp = h.heardAtUptimeSecs;
            return true;
        }
    }
    return false;
}

NodeDB::EvictionRecency NodeDB::evictionRecency(const meshtastic_NodeInfoLite *n) const
{
    uint32_t stamp = 0;
    const bool heardThisBoot = getHeardAtUptimeSecs(n->num, stamp);
    return {heardThisBoot ? stamp : n->last_heard, heardThisBoot};
}

bool NodeDB::evictionRecencyOlder(EvictionRecency candidate, EvictionRecency incumbent)
{
    if (candidate.heardThisBoot != incumbent.heardThisBoot)
        return !candidate.heardThisBoot;
    return candidate.value < incumbent.value;
}

void NodeDB::stampContactHeardNow(meshtastic_NodeInfoLite *info)
{
    const uint32_t nowEpoch = getValidTime(RTCQualityFromNet);
    if (nowEpoch)
        info->last_heard = nowEpoch;
    else
        recordHeardWhileClockUntrusted(info->num, Time::getUptimeSecs());
}

void NodeDB::backfillHeardAt()
{
    const uint32_t nowEpoch = getValidTime(RTCQualityFromNet);
    if (nowEpoch == 0) // called before the clock was actually valid - nothing to date against
        return;
    const uint32_t nowUptimeSecs = Time::getUptimeSecs();
    for (auto &h : heardAt) {
        if (h.num == 0)
            continue;
        meshtastic_NodeInfoLite *info = getMeshNode(h.num);
        if (info) {
            // Both stamps are monotonic uptime seconds, so the elapsed term is exact at any age.
            // Never move last_heard backwards: the node may since have been re-heard on a good clock.
            const uint32_t elapsedSecs = nowUptimeSecs - h.heardAtUptimeSecs;
            if (elapsedSecs < nowEpoch && nowEpoch - elapsedSecs > info->last_heard)
                info->last_heard = nowEpoch - elapsedSecs;
        }
        h = {}; // evicted or converted either way, the stamp's job is done
    }
}

/// Find a node in our DB, create an empty NodeInfo if missing
meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n)
{
    meshtastic_NodeInfoLite *lite = getMeshNode(n);

    if (!lite) {
        if (isFull()) {
            LOG_INFO("Node database full: %i nodes, %u bytes free. Erase oldest", numMeshNodes, memGet.getFreeHeap());
            // look for oldest node and erase it
            // Newest-possible sentinel: a zeroed init ranks older than every candidate, so nothing
            // would ever be selected. Keep it maximal even though the index guards below also cover it.
            EvictionRecency oldest = {UINT32_MAX, true};
            EvictionRecency oldestBoring = {UINT32_MAX, true};
            int oldestIndex = -1;
            int oldestBoringIndex = -1;
            for (int i = 1; i < numMeshNodes; i++) {
                const meshtastic_NodeInfoLite *cand = &meshNodes->at(i);
                const bool isFavoriteNode = nodeInfoLiteIsFavorite(cand);
                const bool isIgnored = nodeInfoLiteIsIgnored(cand);
                const bool isVerified = nodeInfoLiteIsKeyManuallyVerified(cand);
                // last_heard, except that nodes heard this boot before the clock became trusted
                // rank by their RAM arrival stamp instead of the 0 in the stored field.
                const EvictionRecency candRecency = evictionRecency(cand);
                // Simply the oldest non-favorite, non-ignored, non-verified node
                if (!isFavoriteNode && !isIgnored && !isVerified &&
                    (oldestIndex == -1 || evictionRecencyOlder(candRecency, oldest))) {
                    oldest = candRecency;
                    oldestIndex = i;
                }
                // The oldest "boring" node
                if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 &&
                    (oldestBoringIndex == -1 || evictionRecencyOlder(candRecency, oldestBoring))) {
                    oldestBoring = candRecency;
                    oldestBoringIndex = i;
                }
            }
            // if we found a "boring" node, evict it
            if (oldestBoringIndex != -1) {
                oldestIndex = oldestBoringIndex;
            }

            if (oldestIndex != -1) {
                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,
                                 evicted.role, warmProtectedCategory(evicted), nodeInfoLiteHasXeddsaSigned(&evicted));
#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);
                }
                (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 = warmTimeOf(warm); // mask off the stolen metadata bits
            // Restore the role the warm tier cached, so re-admission isn't stuck at CLIENT
            // until the next NodeInfo arrives.
            lite->role = static_cast<meshtastic_Config_DeviceConfig_Role>(warmRoleOf(warm));
            // Restore the XEdDSA-signed bit too: it is learned from verified traffic, not from
            // NodeInfo, so a round trip through the warm tier must not relearn it from zero.
            nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, warmXeddsaSignedOf(warm));
            if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) {
                lite->public_key.size = 32;
                memcpy(lite->public_key.bytes, warm.public_key, 32);
            }
            if (warmProtOf(warm) == static_cast<uint8_t>(WarmProtected::XeddsaSigner))
                nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
            LOG_MIGRATION("Rehydrated node 0x%08x from warm tier (key=%d)", n, lite->public_key.size == 32);
        }
#endif
#if HAS_TRAFFIC_MANAGEMENT
        // Name rehydration: the warm tier keeps a node's key but not its name, so a re-admitted
        // long-tail node is nameless until its next NodeInfo. The TrafficManagement NodeInfo
        // cache is much larger and often still holds the full User. Restore it - but only when
        // its cached key matches the key we just restored from warm, so a name never attaches to
        // a different identity than the one we encrypt to. No-op without the TMM NodeInfo cache
        // or when no key is present (key-matched by design). CopyUserToNodeInfoLite sets only the
        // user-related bits, so the warm-restored XEdDSA-signed bit survives.
        if (lite->public_key.size == 32 && !nodeInfoLiteHasUser(lite) && trafficManagementModule) {
            meshtastic_User tmmUser = meshtastic_User_init_zero;
            if (trafficManagementModule->copyUser(n, tmmUser) && tmmUser.public_key.size == 32 &&
                memcmp(tmmUser.public_key.bytes, lite->public_key.bytes, 32) == 0) {
                TypeConversions::CopyUserToNodeInfoLite(lite, tmmUser);
                LOG_INFO("Rehydrated node 0x%08x identity from TMM NodeInfo cache", n);
            }
        }
#endif
        LOG_INFO("Add node to database: %i nodes, %u bytes free", numMeshNodes, memGet.getFreeHeap());
    }

    return lite;
}

/// Sometimes we will have Position objects that only have a time, so check for
/// valid lat/lon
bool NodeDB::hasValidPosition(const meshtastic_NodeInfoLite *n)
{
    if (!n)
        return false;
    if (n->num == getNodeNum()) {
        return localPosition.latitude_i != 0 || localPosition.longitude_i != 0;
    }
    meshtastic_PositionLite pos;
    return copyNodePosition(n->num, pos) && (pos.latitude_i != 0 || pos.longitude_i != 0);
}

/// If we have a node / user and they report is_licensed = true
/// we consider them licensed
UserLicenseStatus NodeDB::getLicenseStatus(uint32_t nodeNum)
{
    const meshtastic_NodeInfoLite *info = getMeshNode(nodeNum);
    if (!nodeInfoLiteHasUser(info))
        return UserLicenseStatus::NotKnown;
    return nodeInfoLiteIsLicensed(info) ? UserLicenseStatus::Licensed : UserLicenseStatus::NotLicensed;
}

#if !defined(MESHTASTIC_EXCLUDE_PKI)
bool NodeDB::checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest)
{
    if (keyToTest.size == 32) {
        uint8_t keyHash[32] = {0};
        memcpy(keyHash, keyToTest.bytes, keyToTest.size);
        crypto->hash(keyHash, 32);
        for (uint16_t i = 0; i < sizeof(LOW_ENTROPY_HASHES) / sizeof(LOW_ENTROPY_HASHES[0]); i++) {
            if (memcmp(keyHash, LOW_ENTROPY_HASHES[i], sizeof(LOW_ENTROPY_HASHES[0])) == 0) {
                return true;
            }
        }
    }
    return false;
}
#endif

bool NodeDB::generateCryptoKeyPair(const uint8_t *privateKey)
{
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN || MESHTASTIC_EXCLUDE_PKI)
    // Generate identity keys once a LoRa region is set. Licensed operation still needs the identity
    // key for plaintext signatures, even though the key is never used for PKI encryption.
    bool regionBlocksKeygen = config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET;
#if ARCH_PORTDUINO
    if (portduino_config.lora_module == use_simradio)
        regionBlocksKeygen = false;
#endif
    if (regionBlocksKeygen) {
        return false;
    }

    bool keygenSuccess = false;
    // Record whether the stored key is a known compromised/low-entropy key so main.cpp can warn the
    // user. A detected low-entropy key is regenerated below, but the flag stays set so the
    // "Compromised keys were detected and regenerated" notification still fires.
    keyIsLowEntropy = checkLowEntropyPublicKey(config.security.public_key);

    // If a specific private key was provided, use it
    if (privateKey != nullptr) {
        LOG_INFO("Using provided private key for PKI");
        memcpy(config.security.private_key.bytes, privateKey, 32);
        config.security.private_key.size = 32;
        config.security.public_key.size = 32;

        // Generate public key from the provided private key
        if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
            keygenSuccess = true;
        } else {
            LOG_ERROR("Can't generate public key from private key");
            return false;
        }
    }
    // Try to regenerate public key from existing private key if it's valid and not low entropy
    else if (config.security.private_key.size == 32 && !keyIsLowEntropy) {
        config.security.public_key.size = 32;
        LOG_DEBUG("Regenerate PKI public key from private key");
        if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
            keygenSuccess = true;
        }
    } else {
        // Generate a new key pair
        LOG_INFO("Generate new PKI keys");
        config.security.public_key.size = 32;
        config.security.private_key.size = 32;
        crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
        keygenSuccess = true;
    }

    // Update sizes and copy to owner if successful
    if (keygenSuccess) {
        owner.public_key.size = 32;
        memcpy(owner.public_key.bytes, config.security.public_key.bytes, 32);

        // Set the DH private key for crypto operations
        LOG_DEBUG("Set DH private key for crypto operations");
        crypto->setDHPrivateKey(config.security.private_key.bytes);

        if (createNewIdentity() && owner.is_licensed)
            licensedIdentityMigrationPending = true;
    }
    return keygenSuccess;
#else
    return false;
#endif
}

bool NodeDB::notifyPendingLicensedIdentityMigration()
{
    if (!licensedIdentityMigrationPending || !service)
        return false;
    meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
    if (!notification)
        return false;
    notification->level = meshtastic_LogRecord_Level_WARNING;
    notification->time = getValidTime(RTCQualityFromNet);
    snprintf(notification->message, sizeof(notification->message), "%s", LICENSED_IDENTITY_MIGRATION_WARNING);
    service->sendClientNotification(notification);
    licensedIdentityMigrationPending = false;
    return true;
}

bool NodeDB::createNewIdentity()
{
    uint32_t oldNodeNum = getNodeNum();
    uint32_t newNodeNum = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size);

    // If the key hasn't changed, nothing to do
    if (newNodeNum == oldNodeNum)
        return false;

    // Remove the old node entry entirely rather than retiring it in place. Flagging the old identity as
    // ignored (the previous behavior) left a keyless ghost of ourselves that survived cleanup/eviction, was
    // still streamed to clients, and made any DM/admin aimed at it fail forever with PKI_SEND_FAIL_PUBLIC_KEY.
    // removeNodeByNum() drops the lite entry, its satellite stores, and the warm-tier copy.
    if (getMeshNode(oldNodeNum) != NULL) {
        LOG_DEBUG("Old node num 0x%08x now 0x%08x, remove stale identity", oldNodeNum, newNodeNum);
        removeNodeByNum(oldNodeNum);
    } else {
        // Lite entry already absent: drop any orphaned satellite-store entries directly.
        eraseNodeSatellites(oldNodeNum);
    }

    myNodeInfo.my_node_num = newNodeNum;

    meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum());
    if (!info)
        return false;
    TypeConversions::CopyUserToNodeInfoLite(info, owner);

    return true;
}

bool NodeDB::backupPreferences(meshtastic_AdminMessage_BackupLocation location)
{
    bool success = false;
    lastBackupAttempt = millis();
#ifdef FSCom
    if (location == meshtastic_AdminMessage_BackupLocation_FLASH) {
        meshtastic_BackupPreferences backup = meshtastic_BackupPreferences_init_zero;
        backup.version = DEVICESTATE_CUR_VER;
        backup.timestamp = getValidTime(RTCQuality::RTCQualityDevice, false);
        backup.has_config = true;
        backup.config = config;
        backup.has_module_config = true;
        backup.module_config = moduleConfig;
        backup.has_channels = true;
        backup.channels = channelFile;
        backup.has_owner = true;
        backup.owner = owner;

        size_t backupSize;
        pb_get_encoded_size(&backupSize, meshtastic_BackupPreferences_fields, &backup);

        spiLock->lock();
        FSCom.mkdir("/backups");
        spiLock->unlock();
        success = saveProto(backupFileName, backupSize, &meshtastic_BackupPreferences_msg, &backup);

        if (success) {
            LOG_INFO("Saved backup preferences");
        } else {
            LOG_ERROR("Save backup prefs to file failed");
        }
    } else if (location == meshtastic_AdminMessage_BackupLocation_SD) {
        // TODO: After more mainline SD card support
    }
#endif
    return success;
}

bool NodeDB::restorePreferences(meshtastic_AdminMessage_BackupLocation location, int restoreWhat)
{
    bool success = false;
#ifdef FSCom
    if (location == meshtastic_AdminMessage_BackupLocation_FLASH) {
        spiLock->lock();
        if (!FSCom.exists(backupFileName)) {
            spiLock->unlock();
            LOG_WARN("Can't restore, no backup file");
            return false;
        } else {
            spiLock->unlock();
        }
        meshtastic_BackupPreferences backup = meshtastic_BackupPreferences_init_zero;
        success = loadProto(backupFileName, meshtastic_BackupPreferences_size, sizeof(meshtastic_BackupPreferences),
                            &meshtastic_BackupPreferences_msg, &backup);
        if (success) {
            if (restoreWhat & SEGMENT_CONFIG) {
                config = backup.config;
                LOG_DEBUG("Restored config");
            }
            if (restoreWhat & SEGMENT_MODULECONFIG) {
                moduleConfig = backup.module_config;
                LOG_DEBUG("Restored module config");
            }
            if (restoreWhat & SEGMENT_DEVICESTATE) {
                devicestate.owner = backup.owner;
                LOG_DEBUG("Restored device state");
            }
            if (restoreWhat & SEGMENT_CHANNELS) {
                channelFile = backup.channels;
                LOG_DEBUG("Restored channels");
            }

            if (owner.is_licensed && channels.ensureLicensedOperation()) {
                restoreWhat |= SEGMENT_CHANNELS;
                LOG_WARN("Licensed operation sanitized restored channel encryption/admin access");
            }
            if (restoreWhat & SEGMENT_CHANNELS)
                channels.onConfigChanged();

            success = saveToDisk(restoreWhat);
            if (success) {
                LOG_INFO("Restored prefs from backup");
            } else {
                LOG_ERROR("Save restored prefs to flash failed");
            }
        } else {
            LOG_ERROR("Restore prefs from backup failed");
        }
    } else if (location == meshtastic_AdminMessage_BackupLocation_SD) {
        // TODO: After more mainline SD card support
    }
#endif
    return success;
}

/// Record an error that should be reported via analytics
void recordCriticalError(meshtastic_CriticalErrorCode code, uint32_t address, const char *filename)
{
    if (filename) {
        LOG_ERROR("NOTE! Record critical error %d at %s:%lu", code, filename, address);
    } else {
        LOG_ERROR("NOTE! Record critical error %d, address=0x%lx", code, address);
    }

    // Record error to DB
    error_code = code;
    error_address = address;

    // Currently portuino is mostly used for simulation.  Make sure the user notices something really bad happened
#ifdef ARCH_PORTDUINO
    LOG_ERROR("Critical failure");
    // TODO: Determine if other critical errors should also cause an immediate exit
    if (code == meshtastic_CriticalErrorCode_FLASH_CORRUPTION_RECOVERABLE ||
        code == meshtastic_CriticalErrorCode_FLASH_CORRUPTION_UNRECOVERABLE)
        exit(2);
#endif
}
