mirror of
https://github.com/ratspeak/ratdeck.git
synced 2026-10-15 05:59:52 +00:00
524 lines
20 KiB
C++
524 lines
20 KiB
C++
// Direct port from Ratputer — node discovery and contact persistence
|
|
#include "AnnounceManager.h"
|
|
#include "config/Config.h"
|
|
#include "storage/SDStore.h"
|
|
#include "storage/FlashStore.h"
|
|
#include "transport/LoRaInterface.h"
|
|
#include "util/PerfTrace.h"
|
|
#include <ArduinoJson.h>
|
|
#include <LittleFS.h>
|
|
|
|
// Skip one MsgPack value at data[pos], return new pos (or len on error)
|
|
static size_t mpSkipValue(const uint8_t* data, size_t len, size_t pos) {
|
|
if (pos >= len) return len;
|
|
uint8_t b = data[pos];
|
|
if (b <= 0x7F || b >= 0xE0) return pos + 1;
|
|
if ((b & 0xF0) == 0x80) {
|
|
size_t n = (b & 0x0F) * 2;
|
|
pos++;
|
|
for (size_t j = 0; j < n && pos < len; j++) pos = mpSkipValue(data, len, pos);
|
|
return pos;
|
|
}
|
|
if ((b & 0xF0) == 0x90) {
|
|
size_t n = b & 0x0F;
|
|
pos++;
|
|
for (size_t j = 0; j < n && pos < len; j++) pos = mpSkipValue(data, len, pos);
|
|
return pos;
|
|
}
|
|
if ((b & 0xE0) == 0xA0) return pos + 1 + (b & 0x1F);
|
|
if (b == 0xC0 || b == 0xC2 || b == 0xC3) return pos + 1;
|
|
if (b == 0xC4 && pos + 1 < len) return pos + 2 + data[pos + 1];
|
|
if (b == 0xC5 && pos + 2 < len) return pos + 3 + ((size_t)data[pos + 1] << 8 | data[pos + 2]);
|
|
if (b == 0xCA) return pos + 5;
|
|
if (b == 0xCB) return pos + 9;
|
|
if (b == 0xCC || b == 0xD0) return pos + 2;
|
|
if (b == 0xCD || b == 0xD1) return pos + 3;
|
|
if (b == 0xCE || b == 0xD2) return pos + 5;
|
|
if (b == 0xCF || b == 0xD3) return pos + 9;
|
|
if (b == 0xD9 && pos + 1 < len) return pos + 2 + data[pos + 1];
|
|
if (b == 0xDA && pos + 2 < len) return pos + 3 + ((size_t)data[pos + 1] << 8 | data[pos + 2]);
|
|
return len;
|
|
}
|
|
|
|
// Two-pass: prefer str elements (display name), fall back to bin (NomadNet compat).
|
|
static std::string extractMsgPackName(const uint8_t* data, size_t len) {
|
|
if (len < 2) return "";
|
|
uint8_t b = data[0];
|
|
size_t pos = 0;
|
|
size_t arrLen = 0;
|
|
if ((b & 0xF0) == 0x90) { arrLen = b & 0x0F; if (arrLen == 0) return ""; pos = 1; }
|
|
else if (b == 0xDC && len >= 3) { arrLen = ((size_t)data[1] << 8) | data[2]; pos = 3; }
|
|
else return "";
|
|
|
|
// Pass 1: scan for first non-empty STR element
|
|
size_t savedPos = pos;
|
|
for (size_t i = 0; i < arrLen && pos < len; i++) {
|
|
b = data[pos];
|
|
size_t slen = 0;
|
|
if ((b & 0xE0) == 0xA0) { slen = b & 0x1F; pos++; }
|
|
else if (b == 0xD9 && pos + 1 < len) { slen = data[pos + 1]; pos += 2; }
|
|
else if (b == 0xDA && pos + 2 < len) { slen = ((size_t)data[pos + 1] << 8) | data[pos + 2]; pos += 3; }
|
|
else { pos = mpSkipValue(data, len, pos); continue; }
|
|
if (slen > 0 && pos + slen <= len) return std::string((const char*)&data[pos], slen);
|
|
pos += slen;
|
|
}
|
|
|
|
// Pass 2: scan for first non-empty BIN element (fallback)
|
|
pos = savedPos;
|
|
for (size_t i = 0; i < arrLen && pos < len; i++) {
|
|
b = data[pos];
|
|
size_t slen = 0;
|
|
if (b == 0xC4 && pos + 1 < len) { slen = data[pos + 1]; pos += 2; }
|
|
else if (b == 0xC5 && pos + 2 < len) { slen = ((size_t)data[pos + 1] << 8) | data[pos + 2]; pos += 3; }
|
|
else { pos = mpSkipValue(data, len, pos); continue; }
|
|
if (slen > 0 && pos + slen <= len) return std::string((const char*)&data[pos], slen);
|
|
pos += slen;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
static std::string sanitizeName(const std::string& raw, size_t maxLen = 16) {
|
|
std::string clean;
|
|
clean.reserve(std::min(raw.size(), maxLen));
|
|
for (char c : raw) {
|
|
if (clean.size() >= maxLen) break;
|
|
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
|
(c >= '0' && c <= '9') || c == ' ' || c == '-' || c == '_' || c == '.' || c == '\'' || c == '/') {
|
|
clean += c;
|
|
}
|
|
}
|
|
size_t start = clean.find_first_not_of(' ');
|
|
if (start == std::string::npos) return "";
|
|
size_t end = clean.find_last_not_of(' ');
|
|
return clean.substr(start, end - start + 1);
|
|
}
|
|
|
|
AnnounceManager::AnnounceManager(const char* aspectFilter) : RNS::AnnounceHandler(aspectFilter) {
|
|
_nodes.reserve(MAX_NODES);
|
|
_hashIndex.reserve(MAX_NODES);
|
|
}
|
|
|
|
void AnnounceManager::setStorage(SDStore* sd, FlashStore* flash) { _sd = sd; _flash = flash; }
|
|
|
|
void AnnounceManager::received_announce(
|
|
const RNS::Bytes& destination_hash,
|
|
const RNS::Identity& announced_identity,
|
|
const RNS::Bytes& app_data)
|
|
{
|
|
std::string name;
|
|
if (app_data.size() > 0) {
|
|
Serial.printf("[ANNOUNCE-RX] app_data: %d bytes hex: ", (int)app_data.size());
|
|
for (size_t i = 0; i < std::min((size_t)16, app_data.size()); i++)
|
|
Serial.printf("%02X", app_data.data()[i]);
|
|
Serial.println();
|
|
std::string rawName = extractMsgPackName(app_data.data(), app_data.size());
|
|
if (rawName.empty()) {
|
|
bool isText = app_data.size() > 0 && app_data.size() <= 32;
|
|
for (size_t i = 0; isText && i < app_data.size(); i++) {
|
|
uint8_t c = app_data.data()[i];
|
|
if (c < 0x20 || c > 0x7E) isText = false;
|
|
}
|
|
if (isText) {
|
|
rawName = app_data.toString();
|
|
} else {
|
|
Serial.printf("[ANNOUNCE] Unknown app_data format (%d bytes): ", (int)app_data.size());
|
|
for (size_t i = 0; i < std::min((size_t)32, app_data.size()); i++) {
|
|
Serial.printf("%02X ", app_data.data()[i]);
|
|
}
|
|
Serial.println();
|
|
}
|
|
}
|
|
name = sanitizeName(rawName);
|
|
}
|
|
// Filter out own announces
|
|
if (_localDestHash.size() > 0 && destination_hash == _localDestHash) return;
|
|
|
|
// Layer 3: Global announce rate limit — cap application-layer processing
|
|
{
|
|
unsigned long now = millis();
|
|
if (now - _globalAnnounceWindowStart >= 1000) {
|
|
_globalAnnounceWindowStart = now;
|
|
_globalAnnounceCount = 0;
|
|
}
|
|
if (++_globalAnnounceCount > MAX_GLOBAL_ANNOUNCES_PER_SEC) return;
|
|
}
|
|
|
|
std::string key = makeKey(destination_hash);
|
|
std::string idHex = announced_identity ? announced_identity.hexhash() : "";
|
|
|
|
unsigned long now = millis();
|
|
// O(1) lookup for existing node
|
|
auto it = _hashIndex.find(key);
|
|
if (it != _hashIndex.end()) {
|
|
auto& node = _nodes[it->second];
|
|
if (node.lastSeen != 0 && now >= node.lastSeen &&
|
|
now - node.lastSeen < ANNOUNCE_MIN_INTERVAL_MS) return;
|
|
bool identityChanged = !idHex.empty() && node.identityHex != idHex;
|
|
// Saved contacts own their local alias. Incoming announce app_data is
|
|
// still cached below, but must not reset the user-chosen contact name.
|
|
if (!name.empty() && !node.saved) node.name = name;
|
|
if (!idHex.empty()) node.identityHex = idHex;
|
|
node.lastSeen = now;
|
|
// hops_to() is expensive (linear routing table scan) — only call for saved contacts
|
|
if (node.saved) node.hops = RNS::Transport::hops_to(destination_hash);
|
|
if (_loraIf) { node.rssi = _loraIf->lastRxRssi(); node.snr = _loraIf->lastRxSnr(); }
|
|
// Name cache update — skip expensive toHex() for unnamed re-announces
|
|
if (!name.empty()) {
|
|
std::string destHex = destination_hash.toHex();
|
|
auto nc = _nameCache.find(destHex);
|
|
if (nc == _nameCache.end() || nc->second != name) {
|
|
_nameCache[destHex] = name;
|
|
_nameCacheDirty = true;
|
|
saveNameCache();
|
|
_nameCacheDirty = false;
|
|
}
|
|
}
|
|
if (!idHex.empty()) {
|
|
persistKnownDestinationsAfterAnnounce(
|
|
identityChanged ? "identity update" : "repeat announce",
|
|
identityChanged);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// New node — toHex needed for log + name cache
|
|
std::string destHex = destination_hash.toHex();
|
|
Serial.printf("[ANNOUNCE] New: %s name=\"%s\"\n", destHex.c_str(), name.c_str());
|
|
|
|
if (!name.empty()) {
|
|
auto nc = _nameCache.find(destHex);
|
|
if (nc == _nameCache.end() || nc->second != name) {
|
|
_nameCache[destHex] = name;
|
|
_nameCacheDirty = true;
|
|
// Cap name cache size: prune entries not in _nodes or saved contacts
|
|
if ((int)_nameCache.size() > MAX_NAME_CACHE) {
|
|
for (auto it = _nameCache.begin(); it != _nameCache.end() && (int)_nameCache.size() > MAX_NAME_CACHE; ) {
|
|
RNS::Bytes h; h.assignHex(it->first.c_str());
|
|
if (!findNode(h)) {
|
|
it = _nameCache.erase(it);
|
|
} else {
|
|
++it;
|
|
}
|
|
}
|
|
}
|
|
saveNameCache();
|
|
_nameCacheDirty = false;
|
|
}
|
|
}
|
|
|
|
// Add new node
|
|
if ((int)_nodes.size() >= MAX_NODES) {
|
|
evictStale();
|
|
if ((int)_nodes.size() >= MAX_NODES) {
|
|
uint8_t maxHops = 0;
|
|
unsigned long oldest = ULONG_MAX;
|
|
int evictIdx = -1;
|
|
for (int i = 0; i < (int)_nodes.size(); i++) {
|
|
if (_nodes[i].saved) continue;
|
|
if (_nodes[i].hops > maxHops ||
|
|
(_nodes[i].hops == maxHops && _nodes[i].lastSeen < oldest)) {
|
|
maxHops = _nodes[i].hops;
|
|
oldest = _nodes[i].lastSeen;
|
|
evictIdx = i;
|
|
}
|
|
}
|
|
if (evictIdx >= 0) {
|
|
// Swap-and-pop for O(1) eviction
|
|
int lastIdx = (int)_nodes.size() - 1;
|
|
if (evictIdx != lastIdx) {
|
|
std::string swapKey = makeKey(_nodes[lastIdx].hash);
|
|
_hashIndex[swapKey] = evictIdx;
|
|
std::swap(_nodes[evictIdx], _nodes[lastIdx]);
|
|
}
|
|
_hashIndex.erase(makeKey(_nodes[lastIdx].hash));
|
|
_nodes.pop_back();
|
|
}
|
|
}
|
|
}
|
|
if ((int)_nodes.size() >= MAX_NODES) return;
|
|
|
|
DiscoveredNode node;
|
|
node.hash = destination_hash;
|
|
if (!name.empty()) {
|
|
node.name = name;
|
|
} else {
|
|
auto cached = _nameCache.find(destHex);
|
|
node.name = (cached != _nameCache.end() && !cached->second.empty())
|
|
? cached->second
|
|
: destHex.substr(0, 12);
|
|
}
|
|
node.identityHex = idHex;
|
|
node.lastSeen = millis();
|
|
// Skip hops_to() for new (unsaved) nodes — resolved when added as contact
|
|
if (_loraIf) { node.rssi = _loraIf->lastRxRssi(); node.snr = _loraIf->lastRxSnr(); }
|
|
_hashIndex[key] = (int)_nodes.size();
|
|
_nodes.push_back(node);
|
|
if (!idHex.empty()) {
|
|
persistKnownDestinationsAfterAnnounce("new peer", true);
|
|
}
|
|
}
|
|
|
|
void AnnounceManager::loop() {
|
|
unsigned long now = millis();
|
|
if (_contactsDirty && now - _lastContactSave >= CONTACT_SAVE_INTERVAL_MS) {
|
|
_contactsDirty = false;
|
|
_lastContactSave = now;
|
|
saveContacts();
|
|
Serial.println("[ANNOUNCE] Deferred contact save complete");
|
|
}
|
|
if (_nameCacheDirty && now - _lastContactSave >= CONTACT_SAVE_INTERVAL_MS) {
|
|
_nameCacheDirty = false;
|
|
saveNameCache();
|
|
}
|
|
}
|
|
|
|
void AnnounceManager::persistKnownDestinationsAfterAnnounce(const char* reason, bool force) {
|
|
unsigned long now = millis();
|
|
if (!force && _lastKnownDestinationsPersist != 0 &&
|
|
now - _lastKnownDestinationsPersist < KNOWN_DESTINATION_PERSIST_MIN_INTERVAL_MS) {
|
|
return;
|
|
}
|
|
|
|
_lastKnownDestinationsPersist = now;
|
|
unsigned long startMs = millis();
|
|
RNS::Identity::persist_data();
|
|
unsigned long elapsed = millis() - startMs;
|
|
Serial.printf("[ANNOUNCE] Known destinations persisted after %s (force=%s in %lums)\n",
|
|
reason ? reason : "announce", force ? "yes" : "no", elapsed);
|
|
}
|
|
|
|
int AnnounceManager::nodesOnlineSince(unsigned long maxAgeMs) const {
|
|
unsigned long now = millis();
|
|
int count = 0;
|
|
for (const auto& n : _nodes) {
|
|
if (n.lastSeen != 0 && now >= n.lastSeen && now - n.lastSeen <= maxAgeMs) count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
const DiscoveredNode* AnnounceManager::findNode(const RNS::Bytes& hash) const {
|
|
auto it = _hashIndex.find(makeKey(hash));
|
|
if (it != _hashIndex.end()) return &_nodes[it->second];
|
|
return nullptr;
|
|
}
|
|
|
|
const DiscoveredNode* AnnounceManager::findNodeByHex(const std::string& hexHash) const {
|
|
RNS::Bytes target;
|
|
target.assignHex(hexHash.c_str());
|
|
auto it = _hashIndex.find(makeKey(target));
|
|
if (it != _hashIndex.end()) return &_nodes[it->second];
|
|
// Prefix match fallback (for truncated 16-char conversation hashes)
|
|
for (const auto& n : _nodes) {
|
|
std::string nodeHex = n.hash.toHex();
|
|
if (hexHash.length() < nodeHex.length() &&
|
|
nodeHex.substr(0, hexHash.length()) == hexHash) return &n;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
void AnnounceManager::addManualContact(const std::string& hexHash, const std::string& name) {
|
|
RNS::Bytes hash;
|
|
hash.assignHex(hexHash.c_str());
|
|
std::string safeName = sanitizeName(name);
|
|
std::string key = makeKey(hash);
|
|
|
|
auto it = _hashIndex.find(key);
|
|
if (it != _hashIndex.end()) {
|
|
auto& node = _nodes[it->second];
|
|
if (!safeName.empty()) node.name = safeName;
|
|
node.saved = true;
|
|
saveContact(node);
|
|
return;
|
|
}
|
|
|
|
DiscoveredNode node;
|
|
node.hash = hash;
|
|
if (!safeName.empty()) {
|
|
node.name = safeName;
|
|
} else {
|
|
auto cached = _nameCache.find(hexHash);
|
|
node.name = (cached != _nameCache.end() && !cached->second.empty())
|
|
? cached->second
|
|
: hexHash.substr(0, 12);
|
|
}
|
|
node.lastSeen = millis();
|
|
node.saved = true;
|
|
_hashIndex[key] = (int)_nodes.size();
|
|
_nodes.push_back(node);
|
|
saveContact(node);
|
|
}
|
|
|
|
void AnnounceManager::evictStale(unsigned long maxAgeMs) {
|
|
unsigned long now = millis();
|
|
_nodes.erase(std::remove_if(_nodes.begin(), _nodes.end(),
|
|
[now, maxAgeMs](const DiscoveredNode& n) {
|
|
return !n.saved && (n.lastSeen == 0 || now < n.lastSeen || now - n.lastSeen > maxAgeMs);
|
|
}), _nodes.end());
|
|
rebuildIndex();
|
|
}
|
|
|
|
void AnnounceManager::clearTransientNodes() {
|
|
int before = _nodes.size();
|
|
_nodes.erase(std::remove_if(_nodes.begin(), _nodes.end(),
|
|
[](const DiscoveredNode& n) { return !n.saved; }), _nodes.end());
|
|
int removed = before - (int)_nodes.size();
|
|
if (removed > 0) {
|
|
Serial.printf("[ANNOUNCE] Cleared %d transient nodes\n", removed);
|
|
}
|
|
rebuildIndex();
|
|
}
|
|
|
|
void AnnounceManager::clearAll() {
|
|
_nodes.clear();
|
|
_hashIndex.clear();
|
|
_nameCache.clear();
|
|
_contactsDirty = false;
|
|
_nameCacheDirty = false;
|
|
Serial.println("[ANNOUNCE] Cleared all nodes and name cache");
|
|
}
|
|
|
|
void AnnounceManager::rebuildIndex() {
|
|
_hashIndex.clear();
|
|
for (int i = 0; i < (int)_nodes.size(); i++) {
|
|
_hashIndex[makeKey(_nodes[i].hash)] = i;
|
|
}
|
|
}
|
|
|
|
void AnnounceManager::saveContact(const DiscoveredNode& node) {
|
|
std::string hexHash = node.hash.toHex();
|
|
JsonDocument doc;
|
|
doc["hash"] = hexHash; doc["name"] = node.name;
|
|
String json;
|
|
serializeJson(doc, json);
|
|
String filename = hexHash.substr(0, 16).c_str();
|
|
filename += ".json";
|
|
if (_sd && _sd->isReady()) { _sd->writeString((String(SD_PATH_CONTACTS) + "/" + filename).c_str(), json); }
|
|
if (_flash) { _flash->writeString((String(PATH_CONTACTS) + "/" + filename).c_str(), json); }
|
|
}
|
|
|
|
void AnnounceManager::removeContact(const std::string& hexHash) {
|
|
String filename = hexHash.substr(0, 16).c_str();
|
|
filename += ".json";
|
|
if (_sd && _sd->isReady()) { _sd->remove((String(SD_PATH_CONTACTS) + "/" + filename).c_str()); }
|
|
if (_flash) { _flash->remove((String(PATH_CONTACTS) + "/" + filename).c_str()); }
|
|
}
|
|
|
|
bool AnnounceManager::deleteContact(int nodeIdx) {
|
|
if (nodeIdx < 0 || nodeIdx >= (int)_nodes.size()) return false;
|
|
std::string hexHash = _nodes[nodeIdx].hash.toHex();
|
|
removeContact(hexHash);
|
|
_nameCache.erase(hexHash);
|
|
_nameCacheDirty = true;
|
|
_nodes.erase(_nodes.begin() + nodeIdx);
|
|
rebuildIndex();
|
|
Serial.printf("[ANNOUNCE] Deleted contact %s\n", hexHash.substr(0, 12).c_str());
|
|
return true;
|
|
}
|
|
|
|
void AnnounceManager::loadContacts() {
|
|
int loaded = 0;
|
|
auto loadFromDir = [&](File& dir) {
|
|
File entry = dir.openNextFile();
|
|
while (entry) {
|
|
if (!entry.isDirectory() && String(entry.name()).endsWith(".json")) {
|
|
size_t size = entry.size();
|
|
if (size > 0 && size < 2048) {
|
|
String json = entry.readString();
|
|
JsonDocument doc;
|
|
if (!deserializeJson(doc, json)) {
|
|
std::string hexHash = doc["hash"] | "";
|
|
if (!hexHash.empty()) {
|
|
RNS::Bytes hash; hash.assignHex(hexHash.c_str());
|
|
std::string key = makeKey(hash);
|
|
if (_hashIndex.find(key) == _hashIndex.end()) {
|
|
DiscoveredNode node;
|
|
node.hash = hash;
|
|
node.name = sanitizeName(doc["name"] | "");
|
|
if (node.name.empty()) node.name = hexHash.substr(0, 12);
|
|
// Contacts persist address-book data only. Radio/path state
|
|
// is boot-relative and becomes misleading after restart.
|
|
node.rssi = 0;
|
|
node.snr = 0.0f;
|
|
node.hops = 0;
|
|
node.lastSeen = 0;
|
|
node.saved = true;
|
|
_hashIndex[key] = (int)_nodes.size();
|
|
_nodes.push_back(node);
|
|
loaded++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
entry = dir.openNextFile();
|
|
}
|
|
};
|
|
if (_sd && _sd->isReady()) { File dir = _sd->openDir(SD_PATH_CONTACTS); if (dir && dir.isDirectory()) loadFromDir(dir); }
|
|
if (_flash) { File dir = LittleFS.open(PATH_CONTACTS); if (dir && dir.isDirectory()) loadFromDir(dir); }
|
|
if (loaded > 0) Serial.printf("[ANNOUNCE] Loaded %d saved contacts\n", loaded);
|
|
}
|
|
|
|
void AnnounceManager::saveContacts() {
|
|
for (const auto& n : _nodes) { if (n.saved) saveContact(n); }
|
|
}
|
|
|
|
std::string AnnounceManager::lookupName(const std::string& hexHash) const {
|
|
// Check live nodes first
|
|
const DiscoveredNode* node = findNodeByHex(hexHash);
|
|
if (node && !node->name.empty()) return node->name;
|
|
// Fall back to cached names
|
|
auto it = _nameCache.find(hexHash);
|
|
if (it != _nameCache.end()) return it->second;
|
|
return "";
|
|
}
|
|
|
|
void AnnounceManager::saveNameCache() {
|
|
unsigned long startMs = millis();
|
|
JsonDocument doc;
|
|
for (auto& kv : _nameCache) {
|
|
doc[kv.first] = kv.second;
|
|
}
|
|
String json;
|
|
unsigned long serializeStartMs = millis();
|
|
serializeJson(doc, json);
|
|
unsigned long serializeMs = millis() - serializeStartMs;
|
|
size_t bytes = json.length();
|
|
bool sdOk = false;
|
|
bool flashOk = false;
|
|
unsigned long sdMs = 0;
|
|
unsigned long flashMs = 0;
|
|
if (_sd && _sd->isReady()) {
|
|
unsigned long writeStartMs = millis();
|
|
sdOk = _sd->writeString("/ratdeck/config/names.json", json);
|
|
sdMs = millis() - writeStartMs;
|
|
}
|
|
if (_flash) {
|
|
unsigned long writeStartMs = millis();
|
|
flashOk = _flash->writeString("/config/names.json", json);
|
|
flashMs = millis() - writeStartMs;
|
|
}
|
|
unsigned long elapsed = millis() - startMs;
|
|
Serial.printf("[ANNOUNCE] Name cache saved (%d entries, bytes=%u, serialize=%lums sd=%s/%lums flash=%s/%lums total=%lums)\n",
|
|
(int)_nameCache.size(), (unsigned)bytes, serializeMs,
|
|
(_sd && _sd->isReady()) ? (sdOk ? "ok" : "fail") : "skip", sdMs,
|
|
_flash ? (flashOk ? "ok" : "fail") : "skip", flashMs,
|
|
elapsed);
|
|
}
|
|
|
|
void AnnounceManager::loadNameCache() {
|
|
String json;
|
|
if (_sd && _sd->isReady()) {
|
|
json = _sd->readString("/ratdeck/config/names.json");
|
|
}
|
|
if (json.isEmpty() && _flash) {
|
|
json = _flash->readString("/config/names.json");
|
|
}
|
|
if (json.isEmpty()) return;
|
|
JsonDocument doc;
|
|
if (deserializeJson(doc, json)) return;
|
|
for (JsonPair kv : doc.as<JsonObject>()) {
|
|
_nameCache[kv.key().c_str()] = kv.value().as<std::string>();
|
|
}
|
|
Serial.printf("[ANNOUNCE] Name cache loaded (%d entries)\n", (int)_nameCache.size());
|
|
}
|