mirror of
https://github.com/alexhopeoconnor/firmware.git
synced 2026-10-03 19:12:00 +10:00
* fix(nrf52): LTO was dropping the board variant's weak hook overrides Whole-image LTO (enabled arch-wide for nrf52840 in #10655) inlines the empty weak body of earlyInitVariant()/lateInitVariant()/variant_shutdown()/ variant_nrf52LoopHook()/variantDefault*Config() at the call site, because the weak default and the call site live in the SAME translation unit. The strong override in variants/<arch>/<board>/variant.cpp is then never linked, and the board's hardware setup silently does not run. nrf52_lto.py's -fno-lto variant recompile does not help here: the caller is the problem, not the variant object. Needs both ingredients, so this only affects 2.8: the earlyInitVariant() indirection landed in #9438 and is present in v2.7.26 too, but v2.7.26 has no -flto, so the override linked normally. Found on the muzi R1 Neo, whose earlyInitVariant() drives DCDC_EN_HOLD (P0.13, the DC-DC hold after the user button) and NRF_ON (P0.29, "tells IO controller device is on"). Both were dropped from the image, so the companion MCU never saw the nRF application come up and stayed in its DFU indication (purple LED). Verified in the ELF: pre-fix setup() runs straight from waitUntilPowerLevelSafe() to the LED_NOTIFICATION block with no earlyInitVariant symbol in the binary and no pinMode/digitalWrite on P0.13 or P0.29 anywhere; post-fix it calls the real override. HW-confirmed on an R1 Neo. Also affected on nrf52840: earlyInitVariant() on 10 variants (incl. t-echo-card, which sequences its RT9080 3V3 rail there), variant_shutdown() on 18 variants (t114, t-echo, ThinkNode M1-M8, meshlink, wio-tracker-L1 ... - sleep pin parking, so deep-sleep leakage), variant_nrf52LoopHook() on 3 RAK variants. Confirmed dropped on heltec-mesh-node-t114 by build, not just by inspection. Fix is __attribute__((noinline)) on both the weak declaration and definition - the same guard already carried by loopCanSleep(), preFSBegin(), PowerHAL and variant_enableBatteryLpcompWake(), whose comment in main-nrf52.cpp already documents this exact failure mode. Also extend _VARIANT_OVERRIDES in extra_scripts/nrf52_lto.py from just _Z11initVariantv to all eight hooks. That post-link guard already had the right logic and would have caught this on every PR - it simply was not listing Meshtastic's own weak variant hooks, only the core's. With the list extended it goes red on both r1-neo and heltec-mesh-node-t114 when the noinline is reverted, and green with it. Its failure message now names both possible causes. * review: trim the noinline rationale comments to two lines Per AGENTS.md ("keep code comments minimal - one or two lines, max"), the incident detail and extended background belong in the PR description, not the source. Keeps the LTO/noinline rationale and the pointer to the guard. --------- Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
4469 lines
182 KiB
C++
4469 lines
182 KiB
C++
#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 "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
|
|
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_FIRMWARE_EDITION
|
|
myNodeInfo.firmware_edition = USERPREFS_FIRMWARE_EDITION;
|
|
#endif
|
|
#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);
|
|
printBytes("Restored key", config.security.private_key.bytes, config.security.private_key.size);
|
|
} 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 a millis() 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_DEBUG("updateTelemetry LOCAL device");
|
|
} else {
|
|
LOG_DEBUG("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_DEBUG("updateTelemetry LOCAL env");
|
|
} else {
|
|
LOG_DEBUG("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;
|
|
TypeConversions::CopyUserToNodeInfoLite(info, contact.user);
|
|
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 set
|
|
// last_heard to now, so that the add_contact node doesn't immediately get evicted.
|
|
info->last_heard = getTime();
|
|
} 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 stamping last_heard so the
|
|
// contact still isn't the first eviction victim.
|
|
if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true))
|
|
info->last_heard = getTime();
|
|
}
|
|
|
|
// As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified
|
|
if (contact.manually_verified) {
|
|
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_DEBUG("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 a millis() placeholder.
|
|
if (mp.has_rx_time)
|
|
info->last_heard = 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;
|
|
}
|
|
|
|
/// 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
|
|
uint32_t oldest = UINT32_MAX;
|
|
uint32_t oldestBoring = UINT32_MAX;
|
|
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);
|
|
// Simply the oldest non-favorite, non-ignored, non-verified node
|
|
if (!isFavoriteNode && !isIgnored && !isVerified && cand->last_heard < oldest) {
|
|
oldest = cand->last_heard;
|
|
oldestIndex = i;
|
|
}
|
|
// The oldest "boring" node
|
|
if (!isFavoriteNode && !isIgnored && cand->public_key.size == 0 && cand->last_heard < oldestBoring) {
|
|
oldestBoring = cand->last_heard;
|
|
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
|
|
}
|