Improve repeater reliability and build tooling

This commit is contained in:
mikecarper committed 2026-07-13 01:03:27 -07:00
1 parent 2b4aed4ef1
commit da00a00436
31 files changed
+1079 -229

No files matched your search

+29 -14
View File
@@ -29,6 +29,12 @@ void Dispatcher::begin() {
_radio->begin();
prev_isrecv_mode = _radio->isInRecvMode();
#ifdef WITH_MQTT_BRIDGE
// Use begin() as the watchdog baseline even if the radio never reports an
// RX/IRQ timestamp. This lets a radio that is dead from startup recover.
last_radio_active_ms = _ms->getMillis();
last_watchdog_recovery = last_radio_active_ms;
#endif
}
float Dispatcher::getAirtimeBudgetFactor() const {
@@ -107,20 +113,20 @@ void Dispatcher::loop() {
{
const uint32_t watchdog_ms = getRadioWatchdogMillis();
if (watchdog_ms > 0) {
unsigned long last_recv = _radio->getLastRecvMillis();
unsigned long last_irq = _radio->getLastRadioInterruptMillis();
unsigned long last_active = (last_recv > last_irq ? last_recv : last_irq);
if (last_radio_active_ms > last_active) last_active = last_radio_active_ms;
if (is_recv && last_active > 0) {
unsigned long silent_ms = _ms->getMillis() - last_active;
unsigned long since_recovery = _ms->getMillis() - last_watchdog_recovery;
if (silent_ms > watchdog_ms && since_recovery > watchdog_ms) {
_err_flags |= ERR_EVENT_RADIO_WATCHDOG;
MESH_DEBUG_PRINTLN("Radio watchdog: silent %lu ms, state=%d, recovering", silent_ms, _radio->getRadioState());
_radio->idle();
_radio->startRecv();
last_watchdog_recovery = _ms->getMillis();
}
const unsigned long now = _ms->getMillis();
unsigned long silent_ms = now - last_radio_active_ms;
const unsigned long last_recv = _radio->getLastRecvMillis();
const unsigned long last_irq = _radio->getLastRadioInterruptMillis();
if (last_recv != 0 && now - last_recv < silent_ms) silent_ms = now - last_recv;
if (last_irq != 0 && now - last_irq < silent_ms) silent_ms = now - last_irq;
const unsigned long since_recovery = now - last_watchdog_recovery;
if (is_recv && silent_ms >= watchdog_ms && since_recovery >= watchdog_ms) {
_err_flags |= ERR_EVENT_RADIO_WATCHDOG;
MESH_DEBUG_PRINTLN("Radio watchdog: silent %lu ms, state=%d, recovering", silent_ms, _radio->getRadioState());
_radio->idle();
_radio->startRecv();
last_watchdog_recovery = now;
last_radio_active_ms = now;
}
}
}
@@ -358,6 +364,15 @@ void Dispatcher::checkSend() {
outbound = _mgr->getNextOutbound(_ms->getMillis());
if (outbound) {
if (!allowPacketTransmit(outbound)) {
MESH_DEBUG_PRINTLN("%s Dispatcher::checkSend(): packet no longer allowed, type=%u", getLogDateTime(),
(uint32_t)outbound->getPayloadType());
onSendFail(outbound);
releasePacket(outbound);
outbound = NULL;
return;
}
int len = 0;
uint8_t raw[MAX_TRANS_UNIT];
+1
View File
@@ -207,6 +207,7 @@ protected:
virtual uint32_t getCADFailRetryDelay() const;
virtual uint32_t getCADFailMaxDuration() const;
virtual uint8_t getDefaultTxCodingRate() const { return 0; }
virtual bool allowPacketTransmit(const Packet* packet) const { return true; }
virtual int getInterferenceThreshold() const { return 0; } // disabled by default
virtual bool getCADEnabled() const { return false; } // hardware CAD disabled by default
virtual int getAGCResetInterval() const { return 0; } // disabled by default
+3 -1
View File
@@ -11,6 +11,7 @@ Identity::Identity() {
}
Identity::Identity(const char* pub_hex) {
memset(pub_key, 0, sizeof(pub_key));
Utils::fromHex(pub_key, PUB_KEY_SIZE, pub_hex);
}
@@ -39,6 +40,7 @@ LocalIdentity::LocalIdentity() {
memset(prv_key, 0, sizeof(prv_key));
}
LocalIdentity::LocalIdentity(const char* prv_hex, const char* pub_hex) : Identity(pub_hex) {
memset(prv_key, 0, sizeof(prv_key));
Utils::fromHex(prv_key, PRV_KEY_SIZE, prv_hex);
}
@@ -140,4 +142,4 @@ void LocalIdentity::calcSharedSecret(uint8_t* secret, const uint8_t* other_pub_k
ed25519_key_exchange(secret, other_pub_key, prv_key);
}
}
}
+60 -19
View File
@@ -165,6 +165,7 @@ void Mesh::begin() {
_flood_retries[i].retry_at = 0;
_flood_retries[i].retry_delay = 0;
_flood_retries[i].retry_attempts_sent = 0;
memset(_flood_retries[i].retry_key, 0, sizeof(_flood_retries[i].retry_key));
_flood_retries[i].priority = 0;
_flood_retries[i].progress_marker = 0;
_flood_retries[i].waiting_final_echo = false;
@@ -221,6 +222,18 @@ void Mesh::loop() {
if (_direct_retries[i].packet == getOutboundInFlight()) {
continue; // currently transmitting; keep slot until onSendComplete/onSendFail emits event
}
uint32_t elapsed_millis = _direct_retries[i].retry_started_at == 0
? 0
: (uint32_t)(_ms->getMillis() - _direct_retries[i].retry_started_at);
onDirectRetryEvent("dropped_queue_removed", NULL, elapsed_millis,
_direct_retries[i].retry_attempts_sent + 1,
_direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len,
_direct_retries[i].payload_type);
onDirectRetryEvent("failure", NULL, elapsed_millis,
_direct_retries[i].retry_attempts_sent + 1,
_direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len,
_direct_retries[i].payload_type);
onDirectRetryFailed(_direct_retries[i].next_hop_hash, _direct_retries[i].next_hop_hash_len);
clearDirectRetrySlot(i);
}
}
@@ -252,6 +265,13 @@ void Mesh::loop() {
if (_flood_retries[i].packet == getOutboundInFlight()) {
continue;
}
uint32_t elapsed_millis = _flood_retries[i].retry_started_at == 0
? 0
: (uint32_t)(_ms->getMillis() - _flood_retries[i].retry_started_at);
onFloodRetryEvent("dropped_queue_removed", NULL, elapsed_millis,
_flood_retries[i].retry_attempts_sent + 1);
onFloodRetryEvent("failure", NULL, elapsed_millis,
_flood_retries[i].retry_attempts_sent + 1);
clearFloodRetrySlot(i);
}
}
@@ -333,6 +353,17 @@ void Mesh::loop() {
#endif
}
bool Mesh::allowPacketTransmit(const Packet* packet) const {
#if defined(ENABLE_OTA)
if (packet != NULL && packet->getPayloadType() == PAYLOAD_TYPE_OTA) {
return isTempRadioActive();
}
#else
(void)packet;
#endif
return true;
}
bool Mesh::allowPacketForward(const mesh::Packet* packet) {
return false; // by default, Transport NOT enabled
}
@@ -1250,6 +1281,9 @@ void Mesh::maybeScheduleDirectRetry(const Packet* packet, uint8_t priority) {
}
void Mesh::clearFloodRetrySlot(int idx) {
if (_flood_retries[idx].active) {
onFloodRetrySlotReleased(_flood_retries[idx].retry_key);
}
if (_flood_retries[idx].waiting_final_echo && _flood_retries[idx].packet != NULL) {
releasePacket(_flood_retries[idx].packet);
}
@@ -1259,6 +1293,7 @@ void Mesh::clearFloodRetrySlot(int idx) {
_flood_retries[idx].retry_at = 0;
_flood_retries[idx].retry_delay = 0;
_flood_retries[idx].retry_attempts_sent = 0;
memset(_flood_retries[idx].retry_key, 0, sizeof(_flood_retries[idx].retry_key));
_flood_retries[idx].priority = 0;
_flood_retries[idx].progress_marker = 0;
_flood_retries[idx].waiting_final_echo = false;
@@ -1342,16 +1377,9 @@ void Mesh::armFloodRetryOnSendComplete(const Packet* packet) {
max_attempts = FLOOD_RETRY_MAX_ATTEMPTS_HARD_MAX;
}
if (_flood_retries[i].retry_attempts_sent >= max_attempts) {
Packet* final_wait = obtainNewPacket();
if (final_wait == NULL) {
onFloodRetryEvent("dropped_no_packet", packet, elapsed_millis, _flood_retries[i].retry_attempts_sent);
onFloodRetryEvent("failure", packet, elapsed_millis, _flood_retries[i].retry_attempts_sent);
clearFloodRetrySlot(i);
continue;
}
*final_wait = *packet;
_flood_retries[i].packet = final_wait;
// Dispatcher releases the transmitted packet after this hook. Keep only
// retry metadata during the final echo window so RX retains the pool slot.
_flood_retries[i].packet = NULL;
_flood_retries[i].retry_at = futureMillis(_flood_retries[i].retry_delay);
_flood_retries[i].waiting_final_echo = true;
_flood_retries[i].queued = false;
@@ -1455,6 +1483,15 @@ void Mesh::maybeScheduleFloodRetry(const Packet* packet, uint8_t priority) {
return;
}
uint8_t retry_key[MAX_HASH_SIZE];
packet->calculatePacketHash(retry_key);
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
if (_flood_retries[i].active
&& memcmp(retry_key, _flood_retries[i].retry_key, MAX_HASH_SIZE) == 0) {
return; // the normal flood still sends, but only one retry sequence owns this logical packet
}
}
int slot_idx = -1;
for (int i = 0; i < MAX_FLOOD_RETRY_SLOTS; i++) {
if (!_flood_retries[i].active) {
@@ -1473,7 +1510,7 @@ void Mesh::maybeScheduleFloodRetry(const Packet* packet, uint8_t priority) {
}
uint32_t retry_delay = getFloodRetryAttemptDelay(packet, 0);
packet->calculatePacketHash(_flood_retries[slot_idx].retry_key);
memcpy(_flood_retries[slot_idx].retry_key, retry_key, sizeof(retry_key));
_flood_retries[slot_idx].packet = NULL;
_flood_retries[slot_idx].trigger_packet = const_cast<Packet*>(packet);
_flood_retries[slot_idx].retry_started_at = 0;
@@ -1755,14 +1792,16 @@ void Mesh::sendOtaFlood(Packet* packet, uint32_t delay_millis) {
}
#endif
void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_size) {
bool Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_size) {
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
return;
releasePacket(packet);
return false;
}
if (path_hash_size == 0 || path_hash_size > 3) {
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): invalid path_hash_size", getLogDateTime());
return;
releasePacket(packet);
return false;
}
packet->header &= ~PH_ROUTE_MASK;
@@ -1780,17 +1819,19 @@ void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_si
pri = 1;
}
maybeScheduleFloodRetry(packet, pri);
sendPacket(packet, pri, delay_millis);
return sendPacket(packet, pri, delay_millis);
}
void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis, uint8_t path_hash_size) {
bool Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis, uint8_t path_hash_size) {
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
return;
releasePacket(packet);
return false;
}
if (path_hash_size == 0 || path_hash_size > 3) {
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): invalid path_hash_size", getLogDateTime());
return;
releasePacket(packet);
return false;
}
packet->header &= ~PH_ROUTE_MASK;
@@ -1810,7 +1851,7 @@ void Mesh::sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_m
pri = 1;
}
maybeScheduleFloodRetry(packet, pri);
sendPacket(packet, pri, delay_millis);
return sendPacket(packet, pri, delay_millis);
}
void Mesh::sendDirect(Packet* packet, const uint8_t* path, uint8_t path_len, uint32_t delay_millis) {
+9 -2
View File
@@ -117,6 +117,7 @@ protected:
DispatcherAction onRecvPacket(Packet* pkt) override;
void onSendComplete(Packet* packet) override;
void onSendFail(Packet* packet) override;
bool allowPacketTransmit(const Packet* packet) const override;
virtual uint32_t getCADFailRetryDelay() const override;
@@ -215,9 +216,15 @@ protected:
/**
* \brief Optional hook for logging flood-retry lifecycle events.
* packet is null when the retained packet has already been released.
*/
virtual void onFloodRetryEvent(const char* event, const Packet* packet, uint32_t delay_millis, uint8_t retry_attempt) { }
/**
* \brief Called exactly once whenever an active flood-retry slot is released.
*/
virtual void onFloodRetrySlotReleased(const uint8_t* retry_key) { }
/**
* \returns number of extra (Direct) ACK transmissions wanted.
*/
@@ -409,13 +416,13 @@ public:
/**
* \brief send a locally-generated Packet with flood routing
*/
void sendFlood(Packet* packet, uint32_t delay_millis=0, uint8_t path_hash_size=1);
bool sendFlood(Packet* packet, uint32_t delay_millis=0, uint8_t path_hash_size=1);
/**
* \brief send a locally-generated Packet with flood routing
* \param transport_codes array of 2 codes to attach to packet
*/
void sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0, uint8_t path_hash_size=1);
bool sendFlood(Packet* packet, uint16_t* transport_codes, uint32_t delay_millis=0, uint8_t path_hash_size=1);
/**
* \brief send a locally-generated Packet with Direct routing
+3 -1
View File
@@ -120,6 +120,7 @@ bool Utils::isHexChar(char c) {
}
bool Utils::fromHex(uint8_t* dest, int dest_size, const char *src_hex) {
if (dest == NULL || src_hex == NULL || dest_size < 0) return false;
int len = strlen(src_hex);
if (len != dest_size*2) return false; // incorrect length
@@ -127,6 +128,7 @@ bool Utils::fromHex(uint8_t* dest, int dest_size, const char *src_hex) {
while (dp - dest < dest_size) {
char ch = *src_hex++;
char cl = *src_hex++;
if (!isHexChar(ch) || !isHexChar(cl)) return false;
*dp++ = (hexVal(ch) << 4) | hexVal(cl);
}
return true;
@@ -150,4 +152,4 @@ int Utils::parseTextParts(char* text, const char* parts[], int max_num, char sep
return num;
}
}
}
+6 -8
View File
@@ -178,9 +178,9 @@ bool AlertReporter::sendChannel(const char* text) {
uint16_t codes[2];
codes[0] = scope.calcTransportCode(pkt);
codes[1] = 0;
_mesh->sendFlood(pkt, codes, 0, path_hash_size);
if (!_mesh->sendFlood(pkt, codes, 0, path_hash_size)) return false;
} else {
_mesh->sendFlood(pkt, 0, path_hash_size);
if (!_mesh->sendFlood(pkt, 0, path_hash_size)) return false;
}
ALERT_DEBUG_PRINTLN("sent: %s", text);
return true;
@@ -232,7 +232,7 @@ void AlertReporter::onLoop(unsigned long now_ms) {
if (_wifi.state == OK) {
if (wifi_down && down_ms >= thresh_ms &&
(now_ms - _wifi.fired_at_ms) >= min_interval_ms) {
(_wifi.fired_at_ms == 0 || (now_ms - _wifi.fired_at_ms) >= min_interval_ms)) {
char age[16];
formatAge(down_ms, age, sizeof(age));
uint8_t reason = MQTTBridge::getLastWifiDisconnectReason();
@@ -256,8 +256,7 @@ void AlertReporter::onLoop(unsigned long now_ms) {
formatAge(total, age, sizeof(age));
char text[80];
snprintf(text, sizeof(text), "WiFi recovered after %s", age);
sendChannel(text);
_wifi.state = OK;
if (sendChannel(text)) _wifi.state = OK;
}
}
} else if (_wifi.state == FIRING) {
@@ -282,7 +281,7 @@ void AlertReporter::onLoop(unsigned long now_ms) {
if (f.state == OK) {
if (down && down_ms >= thresh_ms &&
(now_ms - f.fired_at_ms) >= min_interval_ms) {
(f.fired_at_ms == 0 || (now_ms - f.fired_at_ms) >= min_interval_ms)) {
char age[16];
formatAge(down_ms, age, sizeof(age));
char text[100];
@@ -303,8 +302,7 @@ void AlertReporter::onLoop(unsigned long now_ms) {
char text[100];
snprintf(text, sizeof(text), "MQTT slot %d (%s) recovered after %s",
i + 1, _bridge->getSlotPresetName(i), age);
sendChannel(text);
f.state = OK;
if (sendChannel(text)) f.state = OK;
}
}
}
+6 -2
View File
@@ -98,6 +98,7 @@ bool ClientACL::clear() {
}
ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) {
if (pubkey == NULL || key_len <= 0 || key_len > PUB_KEY_SIZE) return NULL;
for (int i = 0; i < num_clients; i++) {
if (memcmp(pubkey, clients[i].id.pub_key, key_len) == 0) return &clients[i]; // already known
}
@@ -106,7 +107,7 @@ ClientInfo* ClientACL::getClient(const uint8_t* pubkey, int key_len) {
ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
uint32_t min_time = 0xFFFFFFFF;
ClientInfo* oldest = &clients[MAX_CLIENTS - 1];
ClientInfo* oldest = NULL;
for (int i = 0; i < num_clients; i++) {
if (id.matches(clients[i].id)) return &clients[i]; // already known
if ( (!clients[i].isAdmin() && !clients[i].isRegionMgr())
@@ -120,6 +121,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
if (num_clients < MAX_CLIENTS) {
c = &clients[num_clients++];
} else {
if (oldest == NULL) return NULL; // every entry is protected
c = oldest; // evict least active contact
}
memset(c, 0, sizeof(*c));
@@ -131,6 +133,7 @@ ClientInfo* ClientACL::putClient(const mesh::Identity& id, uint8_t init_perms) {
}
bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8_t* pubkey, int key_len, uint8_t perms) {
if (pubkey == NULL || key_len <= 0 || key_len > PUB_KEY_SIZE) return false;
ClientInfo* c;
if ((perms & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST) { // guest role is not persisted in contacts
c = getClient(pubkey, key_len);
@@ -143,10 +146,11 @@ bool ClientACL::applyPermissions(const mesh::LocalIdentity& self_id, const uint8
i++;
}
} else {
if (key_len < PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying
if (key_len != PUB_KEY_SIZE) return false; // need complete pubkey when adding/modifying
mesh::Identity id(pubkey);
c = putClient(id, 0);
if (c == NULL) return false;
c->permissions = perms; // update their permissions
self_id.calcSharedSecret(c->shared_secret, pubkey);
+25 -9
View File
@@ -5,6 +5,7 @@
#include "AlertReporter.h" // for alertReporterBannedChannelMatch()
#include <RTClib.h>
#include <Utils.h>
#include <stddef.h>
#if defined(NRF52_PLATFORM)
#include <nrf.h>
@@ -1048,6 +1049,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->battery_alert_enabled = 0;
_prefs->battery_alert_low_percent = BATTERY_ALERT_LOW_PERCENT_DEFAULT;
_prefs->battery_alert_critical_percent = BATTERY_ALERT_CRITICAL_PERCENT_DEFAULT;
memset(_prefs->battery_alert_region, 0, sizeof(_prefs->battery_alert_region));
_prefs->direct_retry_recent_enabled = DIRECT_RETRY_RECENT_DEFAULT;
_prefs->flood_channel_data_enabled = 1;
_prefs->flood_channel_block_max_hops = FLOOD_CHANNEL_BLOCK_HOPS_ALL;
@@ -1151,6 +1153,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
file.read((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level));
file.read((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble));
}
if (file.available() >= (int)sizeof(_prefs->battery_alert_region)) {
file.read((uint8_t *)_prefs->battery_alert_region, sizeof(_prefs->battery_alert_region));
_prefs->battery_alert_region[sizeof(_prefs->battery_alert_region) - 1] = '\0';
}
}
// sanitise bad pref values
@@ -1369,7 +1375,8 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->rx_ps_sleep_us, sizeof(_prefs->rx_ps_sleep_us)); // 816
file.write((uint8_t *)&_prefs->rx_ps_level, sizeof(_prefs->rx_ps_level)); // 820
file.write((uint8_t *)&_prefs->rx_ps_preamble, sizeof(_prefs->rx_ps_preamble)); // 821
// next: 822
file.write((uint8_t *)_prefs->battery_alert_region, sizeof(_prefs->battery_alert_region)); // 822
// next: 853
file.close();
}
@@ -1422,12 +1429,15 @@ void CommonCLI::loadMQTTPrefs(FILESYSTEM* fs) {
// hadn't appended a tail field) leaves the trailing fields at their
// defaults, and a longer one (a future append) is truncated harmlessly.
size_t payload_avail = file_size - sizeof(hdr);
if (hdr.payload_len < payload_avail) payload_avail = hdr.payload_len;
size_t to_read = payload_avail < sizeof(_mqtt_prefs) ? payload_avail : sizeof(_mqtt_prefs);
size_t got = file.read((uint8_t *)&_mqtt_prefs, to_read);
if (got != to_read) {
setMQTTPrefsDefaults(&_mqtt_prefs);
} else {
has_observer_fields = true; // observer tail is part of the v1 payload
const size_t observer_tail_end = offsetof(MQTTPrefs, alert_region)
+ sizeof(_mqtt_prefs.alert_region);
has_observer_fields = to_read >= observer_tail_end;
}
} else {
// Unknown (newer) version: don't risk misreading a layout we don't know.
@@ -1775,9 +1785,10 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
} else if (memcmp(command, "neighbor.remove ", 16) == 0) {
const char* hex = &command[16];
uint8_t pubkey[PUB_KEY_SIZE];
int hex_len = min((int)strlen(hex), PUB_KEY_SIZE*2);
int pubkey_len = hex_len / 2;
if (mesh::Utils::fromHex(pubkey, pubkey_len, hex)) {
size_t hex_len = strlen(hex);
int pubkey_len = (int)(hex_len / 2);
if (hex_len > 0 && hex_len <= PUB_KEY_SIZE * 2 && (hex_len & 1) == 0
&& mesh::Utils::fromHex(pubkey, pubkey_len, hex)) {
_callbacks->removeNeighbor(pubkey, pubkey_len);
strcpy(reply, "OK");
} else {
@@ -2781,10 +2792,15 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "Error: channel must be between 1-14");
}
} else if (memcmp(config, "bridge.secret ", 14) == 0) {
StrHelper::strncpy(_prefs->bridge_secret, &config[14], sizeof(_prefs->bridge_secret));
_callbacks->restartBridge();
savePrefs();
strcpy(reply, "OK");
const char* secret = &config[14];
if (secret[0] == 0 || strlen(secret) >= sizeof(_prefs->bridge_secret)) {
sprintf(reply, "Error: secret must be 1-%u characters", (unsigned)(sizeof(_prefs->bridge_secret) - 1));
} else {
StrHelper::strncpy(_prefs->bridge_secret, secret, sizeof(_prefs->bridge_secret));
_callbacks->restartBridge();
savePrefs();
strcpy(reply, "OK");
}
#endif
} else if (memcmp(config, "adc.multiplier ", 15) == 0) {
_prefs->adc_multiplier = atof(&config[15]);
+1
View File
@@ -214,6 +214,7 @@ struct NodePrefs { // persisted to file
uint32_t rx_ps_sleep_us;
uint8_t rx_ps_level; // 0 = manual/explicit us timings; 1..10 = level-derived (auto-retunes on SF/BW change)
uint8_t rx_ps_preamble; // 0 = auto (derive from SF); else 16 or 32 = explicit override for level calc
char battery_alert_region[31]; // named scope for low-battery floods; empty = no alert scope
};
#ifdef WITH_MQTT_BRIDGE
+90 -25
View File
@@ -79,36 +79,100 @@ bool RegionMap::load(FILESYSTEM* _fs, const char* path) {
if (file) {
uint8_t pad[128];
num_regions = 0; next_id = 1;
default_id = home_id = 0;
RegionEntry loaded[MAX_REGION_ENTRIES];
uint16_t loaded_count = 0;
uint16_t loaded_default = 0;
uint16_t loaded_home = 0;
uint16_t loaded_next = 1;
uint8_t loaded_wildcard_flags = 0;
bool success = file.read(pad, 3) == 3; // reserved header
success = success && file.read((uint8_t *) &default_id, sizeof(default_id)) == sizeof(default_id);
success = success && file.read((uint8_t *) &home_id, sizeof(home_id)) == sizeof(home_id);
success = success && file.read((uint8_t *) &wildcard.flags, sizeof(wildcard.flags)) == sizeof(wildcard.flags);
success = success && file.read((uint8_t *) &next_id, sizeof(next_id)) == sizeof(next_id);
success = success && file.read((uint8_t *) &loaded_default, sizeof(loaded_default)) == sizeof(loaded_default);
success = success && file.read((uint8_t *) &loaded_home, sizeof(loaded_home)) == sizeof(loaded_home);
success = success && file.read((uint8_t *) &loaded_wildcard_flags, sizeof(loaded_wildcard_flags)) == sizeof(loaded_wildcard_flags);
success = success && file.read((uint8_t *) &loaded_next, sizeof(loaded_next)) == sizeof(loaded_next);
if (success) {
while (num_regions < MAX_REGION_ENTRIES) {
auto r = &regions[num_regions];
success = file.read((uint8_t *) &r->id, sizeof(r->id)) == sizeof(r->id);
success = success && file.read((uint8_t *) &r->parent, sizeof(r->parent)) == sizeof(r->parent);
success = success && file.read((uint8_t *) r->name, sizeof(r->name)) == sizeof(r->name);
success = success && file.read((uint8_t *) &r->flags, sizeof(r->flags)) == sizeof(r->flags);
success = success && file.read(pad, sizeof(pad)) == sizeof(pad);
if (!success) break; // EOF
if (r->id >= next_id) { // make sure next_id is valid
next_id = r->id + 1;
}
num_regions++;
while (success && file.available() > 0) {
if (loaded_count >= MAX_REGION_ENTRIES) {
success = false;
break;
}
RegionEntry& r = loaded[loaded_count];
memset(&r, 0, sizeof(r));
success = file.read((uint8_t *) &r.id, sizeof(r.id)) == sizeof(r.id);
success = success && file.read((uint8_t *) &r.parent, sizeof(r.parent)) == sizeof(r.parent);
success = success && file.read((uint8_t *) r.name, sizeof(r.name)) == sizeof(r.name);
success = success && file.read((uint8_t *) &r.flags, sizeof(r.flags)) == sizeof(r.flags);
success = success && file.read(pad, sizeof(pad)) == sizeof(pad);
if (!success) break;
const char* terminator = (const char*)memchr(r.name, 0, sizeof(r.name));
if (r.id == 0 || r.id == 0xFFFF || terminator == NULL || r.name[0] == 0) {
success = false;
break;
}
for (const char* p = r.name; *p; p++) {
if (!is_name_char((uint8_t)*p)) {
success = false;
break;
}
}
if (!success) break;
loaded_count++;
}
file.close();
return true;
// Validate IDs and parent links only after every record is present so forward
// parent references are allowed. A bounded walk also rejects cycles.
for (uint16_t i = 0; success && i < loaded_count; i++) {
for (uint16_t j = i + 1; j < loaded_count; j++) {
if (loaded[i].id == loaded[j].id) success = false;
}
uint16_t parent = loaded[i].parent;
for (uint16_t depth = 0; success && parent != 0; depth++) {
if (depth >= loaded_count || parent == loaded[i].id) {
success = false;
break;
}
const RegionEntry* found = NULL;
for (uint16_t j = 0; j < loaded_count; j++) {
if (loaded[j].id == parent) {
found = &loaded[j];
break;
}
}
if (found == NULL) {
success = false;
break;
}
parent = found->parent;
}
}
bool default_found = loaded_default == 0;
bool home_found = loaded_home == 0;
uint16_t minimum_next = 1;
for (uint16_t i = 0; success && i < loaded_count; i++) {
if (loaded[i].id == loaded_default) default_found = true;
if (loaded[i].id == loaded_home) home_found = true;
if (loaded[i].id >= minimum_next) minimum_next = loaded[i].id + 1;
}
if (!default_found || !home_found) success = false;
if (loaded_next == 0 || loaded_next == 0xFFFF || loaded_next < minimum_next) loaded_next = minimum_next;
if (loaded_next == 0xFFFF && loaded_count < MAX_REGION_ENTRIES) success = false;
if (success) {
memcpy(regions, loaded, loaded_count * sizeof(RegionEntry));
if (loaded_count < MAX_REGION_ENTRIES) {
memset(&regions[loaded_count], 0, (MAX_REGION_ENTRIES - loaded_count) * sizeof(RegionEntry));
}
num_regions = loaded_count;
default_id = loaded_default;
home_id = loaded_home;
next_id = loaded_next;
wildcard.flags = loaded_wildcard_flags;
}
return success;
}
}
return false; // failed
@@ -157,7 +221,8 @@ RegionEntry* RegionMap::putRegion(const char* name, uint16_t parent_id, uint16_t
region->parent = parent_id; // re-parent / move this region in the hierarchy
} else {
if (id == 0 && num_regions >= MAX_REGION_ENTRIES) return NULL; // full!
if (num_regions >= MAX_REGION_ENTRIES) return NULL; // full!
if (id == 0xFFFF || (id == 0 && (next_id == 0 || next_id == 0xFFFF))) return NULL;
region = &regions[num_regions++]; // alloc new RegionEntry
region->flags = REGION_DENY_FLOOD; // DENY by default
+37 -12
View File
@@ -38,24 +38,34 @@ ESPNowBridge::ESPNowBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTC
void ESPNowBridge::begin() {
BRIDGE_DEBUG_PRINTLN("Initializing...\n");
if (_initialized) return;
// Initialize WiFi in station mode
WiFi.mode(WIFI_STA);
// Set Wi-Fi channel
if (esp_wifi_set_channel(_prefs->bridge_channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) {
BRIDGE_DEBUG_PRINTLN("Error setting WIFI channel to %d\n", _prefs->bridge_channel);
WiFi.mode(WIFI_OFF);
return;
}
// Initialize ESP-NOW
if (esp_now_init() != ESP_OK) {
BRIDGE_DEBUG_PRINTLN("Error initializing ESP-NOW\n");
WiFi.mode(WIFI_OFF);
return;
}
// Register callbacks
esp_now_register_recv_cb(recv_cb);
esp_now_register_send_cb(send_cb);
if (esp_now_register_recv_cb(recv_cb) != ESP_OK || esp_now_register_send_cb(send_cb) != ESP_OK) {
BRIDGE_DEBUG_PRINTLN("Error registering ESP-NOW callbacks\n");
esp_now_register_recv_cb(nullptr);
esp_now_register_send_cb(nullptr);
esp_now_deinit();
WiFi.mode(WIFI_OFF);
return;
}
// Add broadcast peer
esp_now_peer_info_t peerInfo = {};
@@ -66,6 +76,10 @@ void ESPNowBridge::begin() {
if (esp_now_add_peer(&peerInfo) != ESP_OK) {
BRIDGE_DEBUG_PRINTLN("Failed to add broadcast peer\n");
esp_now_register_recv_cb(nullptr);
esp_now_register_send_cb(nullptr);
esp_now_deinit();
WiFi.mode(WIFI_OFF);
return;
}
@@ -102,11 +116,17 @@ void ESPNowBridge::loop() {
// Nothing to do here - ESP-NOW is callback based
}
void ESPNowBridge::xorCrypt(uint8_t *data, size_t len) {
size_t keyLen = strlen(_prefs->bridge_secret);
bool ESPNowBridge::xorCrypt(uint8_t *data, size_t len) {
size_t keyLen = 0;
while (keyLen < sizeof(_prefs->bridge_secret) && _prefs->bridge_secret[keyLen] != 0) keyLen++;
if (keyLen == 0 || keyLen == sizeof(_prefs->bridge_secret)) {
BRIDGE_DEBUG_PRINTLN("Invalid empty or unterminated bridge secret\n");
return false;
}
for (size_t i = 0; i < len; i++) {
data[i] ^= _prefs->bridge_secret[i % keyLen];
}
return true;
}
void ESPNowBridge::onDataRecv(const uint8_t *mac, const uint8_t *data, int len) {
@@ -135,7 +155,7 @@ void ESPNowBridge::onDataRecv(const uint8_t *mac, const uint8_t *data, int len)
memcpy(decrypted, data + BRIDGE_MAGIC_SIZE, encryptedDataLen);
// Try to decrypt (checksum + payload)
xorCrypt(decrypted, encryptedDataLen);
if (!xorCrypt(decrypted, encryptedDataLen)) return;
// Validate checksum
uint16_t received_checksum = (decrypted[0] << 8) | decrypted[1];
@@ -178,14 +198,19 @@ void ESPNowBridge::sendPacket(mesh::Packet *packet) {
if (!_seen_packets.wasSeen(packet)) {
_seen_packets.markSeen(packet);
// Create a temporary buffer just for size calculation and reuse for actual writing
// Check the serialized size before writing into the ESP-NOW-sized buffer.
const int expectedMeshPacketLen = packet->getRawLength();
if (expectedMeshPacketLen < 0 || (size_t)expectedMeshPacketLen > MAX_PAYLOAD_SIZE) {
BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%d)\n", expectedMeshPacketLen,
MAX_PAYLOAD_SIZE);
return;
}
uint8_t sizingBuffer[MAX_PAYLOAD_SIZE];
uint16_t meshPacketLen = packet->writeTo(sizingBuffer);
// Check if packet fits within our maximum payload size
if (meshPacketLen > MAX_PAYLOAD_SIZE) {
BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%d)\n", meshPacketLen,
MAX_PAYLOAD_SIZE);
if (meshPacketLen != (uint16_t)expectedMeshPacketLen) {
BRIDGE_DEBUG_PRINTLN("TX packet length mismatch (actual=%d, expected=%d)\n", meshPacketLen,
expectedMeshPacketLen);
return;
}
@@ -205,7 +230,7 @@ void ESPNowBridge::sendPacket(mesh::Packet *packet) {
buffer[3] = checksum & 0xFF; // Low byte
// Encrypt payload and checksum (not including magic header)
xorCrypt(buffer + BRIDGE_MAGIC_SIZE, meshPacketLen + BRIDGE_CHECKSUM_SIZE);
if (!xorCrypt(buffer + BRIDGE_MAGIC_SIZE, meshPacketLen + BRIDGE_CHECKSUM_SIZE)) return;
// Total packet size: magic header + checksum + payload
const size_t totalPacketSize = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE + meshPacketLen;
+1 -1
View File
@@ -85,7 +85,7 @@ private:
* @param data Pointer to data to encrypt/decrypt
* @param len Length of data in bytes
*/
void xorCrypt(uint8_t *data, size_t len);
bool xorCrypt(uint8_t *data, size_t len);
/**
* ESP-NOW receive callback
+7 -1
View File
@@ -38,9 +38,15 @@ public:
* These two functions do nothing for ESP-NOW, but are needed for the
* Radio interface.
*/
virtual bool setRxBoostedGainMode(bool) { }
virtual bool setRxBoostedGainMode(bool) { return false; }
virtual bool getRxBoostedGainMode() const { return false; }
// ESP-NOW has no LoRa RX power-saving watchdog or noise-floor calibration.
uint32_t getRxPsWatchdogSoftCount() const { return 0; }
uint32_t getRxPsWatchdogHardCount() const { return 0; }
bool isWatchdogObserving() const { return false; }
bool isCalibratingNoiseFloor() const { return false; }
uint32_t intID();
void setTxPower(uint8_t dbm);
};
+17 -2
View File
@@ -434,6 +434,7 @@ void OtaManager::startFetch(const uint8_t* mid, uint32_t target, bool validate)
memcpy(_fid, mid, 4);
_fstate = WANT_MANIFEST;
_mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 0; _loop_last_mfmask = 0; // fresh manifest reassembly
memset(_mf_buf, 0, sizeof(_mf_buf));
GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4); gm.want_mask = 0xFFFF; // first ask: send all fragments
uint8_t b[16];
emit(b, encode_get_manifest(b, sizeof(b), gm), false);
@@ -445,10 +446,21 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
if (_fstate != WANT_MANIFEST || memcmp(mm.manifest_id, _fid, 4) != 0) return;
if (mm.frag_total == 0 || mm.frag_total > OTA_MF_MAXFRAG || mm.frag_idx >= mm.frag_total) return;
const uint8_t expected_total = (uint8_t)((MOTA_MFL + OTA_MF_FRAG - 1) / OTA_MF_FRAG);
const uint16_t expected_len = mm.frag_idx + 1 == expected_total
? (uint16_t)(MOTA_MFL - (uint32_t)mm.frag_idx * OTA_MF_FRAG)
: (uint16_t)OTA_MF_FRAG;
if (mm.frag_total != expected_total || mm.len != expected_len) return;
// reassemble the (possibly multi-fragment) manifest into _mf_buf; place fragment frag_idx at its offset
uint32_t foff = (uint32_t)mm.frag_idx * OTA_MF_FRAG;
if (foff + mm.len > sizeof(_mf_buf)) return;
if (mm.frag_total != _mf_total) { _mf_total = mm.frag_total; _mf_mask = 0; _mf_len = 0; } // (re)start
if (mm.frag_total != _mf_total) {
_mf_total = mm.frag_total;
_mf_mask = 0;
_mf_len = 0;
memset(_mf_buf, 0, sizeof(_mf_buf));
}
memcpy(_mf_buf + foff, mm.bytes, mm.len);
_mf_mask |= (uint16_t)(1u << mm.frag_idx);
if (mm.frag_idx == mm.frag_total - 1) _mf_len = foff + mm.len; // last fragment fixes the length
@@ -461,6 +473,7 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
if (!codecOk(mf[56])) { _fstate = IDLE; return; } // codec we can't apply (lying/stale ADV) — abort
uint32_t payload_size = rd_u32le(mf + 15);
uint8_t bsl = mf[19];
if (bsl >= 32) { _fstate = FAILED; return; }
uint32_t bs = 1u << bsl;
// a block must fit our reassembly buffer (and be non-empty) — reject an oversized block_size up front
if (bs == 0 || bs > OTA_MAX_BLOCK || payload_size == 0) { _fstate = FAILED; return; }
@@ -534,7 +547,9 @@ void OtaManager::handleLeaves(const uint8_t* m, uint16_t n) {
uint8_t ftotal = (uint8_t)((leaves_len + OTA_LEAVES_FRAG - 1) / OTA_LEAVES_FRAG); if (ftotal == 0) ftotal = 1;
if (lv.frag_total != ftotal || lv.frag_idx >= ftotal) return;
uint32_t foff = (uint32_t)lv.frag_idx * OTA_LEAVES_FRAG;
if (foff + lv.len > leaves_len) return; // slice out of range
uint32_t expected_len = leaves_len - foff;
if (expected_len > OTA_LEAVES_FRAG) expected_len = OTA_LEAVES_FRAG;
if (lv.len != expected_len) return; // reject short/oversized slices
if (lv.frag_total != _lv_total) { _lv_total = lv.frag_total; _lv_mask = 0; } // (re)start
memcpy(_leaves_buf + foff, lv.bytes, lv.len);
_lv_mask |= (uint16_t)(1u << lv.frag_idx);
+3 -4
View File
@@ -203,9 +203,8 @@ void OtaStoreFlashEsp32::checkpoint() {
bool OtaStoreFlashEsp32::reopen() {
if (!acquire() || _psize < SEC) return false;
uint8_t hb[8];
// the meta/container is staged at the BOTTOM of the slot, so scan upward from there; cap the scan (a
// miss just means "fetch fresh"). 128 sectors (512 KB) covers any full image's meta + typical deltas.
uint32_t scanned = 0;
// The meta/container is staged at the bottom of the slot. Large delta containers can begin well above
// the final 512 KB, so scan the complete partition rather than silently losing resumability after reboot.
for (uint32_t o = align_down(_psize - SEC, SEC); ; o -= SEC) {
if (esp_partition_read(_part, o, hb, 8) == ESP_OK && memcmp(hb, MOTA_MAGIC, 4) == 0) {
uint32_t total = rd_u32le(hb + 4);
@@ -232,7 +231,7 @@ bool OtaStoreFlashEsp32::reopen() {
}
}
}
if (o == 0 || ++scanned >= 128) break;
if (o == 0) break;
}
return false;
}