From 598a5a7d659124d1e4f7b4e9471ccbc979a3f9c7 Mon Sep 17 00:00:00 2001 From: l33tdawg Date: Mon, 14 Sep 2026 15:22:44 +0800 Subject: [PATCH 1/4] Let companion radios use a bridge, and give bridges their own prefs The bridge implementations take NodePrefs*, so they can only be built into the examples that use the CLI NodePrefs class. The companion has its own, unrelated NodePrefs, so including a bridge from a companion fails outright: src/helpers/CommonCLI.h:25:7: error: redefinition of 'class NodePrefs' That is why ESP-NOW exists as a mesh radio and as a repeater bridge, but never as a companion: a host-connected node could not be the cheap end of a 2.4 GHz link. Extract the six bridge settings into BridgePrefs and have both NodePrefs classes inherit it. Every existing _prefs.bridge_* call site keeps working unchanged, and a bridge no longer has to know which prefs class it was handed. Then wire the bridge into the companion the way the repeater does it: logRx and logTx are already virtual on Dispatcher, and the companion simply never overrode them. Adds a heltec_v4 companion environment with the ESP-NOW bridge enabled. --- examples/companion_radio/MyMesh.cpp | 33 +++++++++++++++++++++++++++- examples/companion_radio/MyMesh.h | 23 +++++++++++++++++++ examples/companion_radio/NodePrefs.h | 12 +++++++++- src/helpers/CommonCLI.h | 13 +++++------ src/helpers/bridges/BridgeBase.h | 12 +++++----- src/helpers/bridges/BridgePrefs.h | 23 +++++++++++++++++++ src/helpers/bridges/ESPNowBridge.cpp | 2 +- src/helpers/bridges/ESPNowBridge.h | 2 +- src/helpers/bridges/RS232Bridge.cpp | 2 +- src/helpers/bridges/RS232Bridge.h | 2 +- variants/heltec_v4/platformio.ini | 24 ++++++++++++++++++++ 11 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 src/helpers/bridges/BridgePrefs.h diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4e6f983280..b9b3e44337 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -305,6 +305,24 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) { } } +#if defined(WITH_BRIDGE) +// The companion never overrode these hooks, so a companion could not mirror mesh +// traffic onto a second transport the way a repeater can. Which direction is +// mirrored is the same setting the repeater uses: bridge_pkt_src 0 = what this +// node transmits, 1 = what it receives. +void MyMesh::logRx(mesh::Packet* packet, int len, float score) { + if (_prefs.bridge_pkt_src == 1) { + bridge.sendPacket(packet); + } +} + +void MyMesh::logTx(mesh::Packet* packet, int len) { + if (_prefs.bridge_pkt_src == 0) { + bridge.sendPacket(packet); + } +} +#endif + bool MyMesh::isAutoAddEnabled() const { return (_prefs.manual_add_contacts & 1) == 0; } @@ -932,7 +950,11 @@ void MyMesh::onSendTimeout() {} MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui) : BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables), - _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) { + _serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) +#if defined(WITH_BRIDGE) + , bridge(&_prefs, _mgr, &rtc) +#endif + { _iter_started = false; _cli_rescue = false; cli_command[0] = 0; @@ -2444,6 +2466,10 @@ void MyMesh::checkSerialInterface() { } void MyMesh::loop() { +#if defined(WITH_BRIDGE) + bridge.loop(); +#endif + BaseChatMesh::loop(); if (_cli_rescue) { @@ -2485,5 +2511,10 @@ bool MyMesh::advert() { // To check if there is pending work bool MyMesh::hasPendingWork() const { +#if defined(WITH_BRIDGE) + // The bridge holds the 2.4 GHz radio and its queue, so a node running one + // cannot be considered idle. Same rule the repeater applies. + if (bridge.isRunning()) return true; +#endif return _mgr->getOutboundTotal() > 0 || dirty_contacts_expiry != 0; } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 3b98a4f674..3d56711ebd 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -34,6 +34,20 @@ #include #include +// A companion can be a bridge too. The repeater has always been able to mirror +// mesh packets onto a second transport (RS232, or ESP-NOW for a fast local +// lane); a companion could not, so a host-connected node could never be the +// cheap end of a high-speed link. The hooks below are the same ones the repeater +// uses - logRx/logTx are virtual on Dispatcher, the companion simply never +// overrode them. +#if defined(WITH_RS232_BRIDGE) + #include "helpers/bridges/RS232Bridge.h" + #define WITH_BRIDGE +#elif defined(WITH_ESPNOW_BRIDGE) + #include "helpers/bridges/ESPNowBridge.h" + #define WITH_BRIDGE +#endif + /* ---------------------------------- CONFIGURATION ------------------------------------- */ #ifndef LORA_FREQ @@ -136,6 +150,10 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override; void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; +#if defined(WITH_BRIDGE) + void logRx(mesh::Packet* packet, int len, float score) override; + void logTx(mesh::Packet* packet, int len) override; +#endif bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; bool shouldOverwriteWhenFull() const override; @@ -232,6 +250,11 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { DataStore* _store; NodePrefs _prefs; +#if defined(WITH_RS232_BRIDGE) + RS232Bridge bridge; +#elif defined(WITH_ESPNOW_BRIDGE) + ESPNowBridge bridge; +#endif uint32_t pending_login; uint32_t pending_status; uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 4b169d73fa..853bdddff9 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -3,6 +3,7 @@ #include #include #include +#include #define TELEM_MODE_DENY 0 #define TELEM_MODE_ALLOW_FLAGS 1 // use contact.flags @@ -11,7 +12,7 @@ #define ADVERT_LOC_NONE 0 #define ADVERT_LOC_SHARE 1 -class NodePrefs : public ConfigSerializer { // persisted to file +class NodePrefs : public ConfigSerializer, public BridgePrefs { // persisted to file public: float airtime_factor = 0; char node_name[32]; @@ -166,6 +167,15 @@ class NodePrefs : public ConfigSerializer { // persisted to file def("tel_loc", _parent->telemetry_mode_loc); def("tel_env", _parent->telemetry_mode_env); def("tz_offset", _parent->tz_offset); + // Bridge settings, so a companion can be a bridge (see WITH_BRIDGE). The + // defaults come from BridgePrefs; these keys only persist what the user + // changes, so an existing config file keeps working untouched. + def("br_en", _parent->bridge_enabled); + def("br_delay", _parent->bridge_delay); + def("br_src", _parent->bridge_pkt_src); + def("br_baud", _parent->bridge_baud); + def("br_ch", _parent->bridge_channel); + def("br_secret", _parent->bridge_secret, sizeof(_parent->bridge_secret)); } public: CompanionPrefs(NodePrefs* parent) : _parent(parent) { } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 8591cdc140..7ee63baf6e 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -8,6 +8,7 @@ #include #include #include +#include #if defined(WITH_RS232_BRIDGE) || defined(WITH_ESPNOW_BRIDGE) #define WITH_BRIDGE @@ -22,7 +23,7 @@ #define LOOP_DETECT_MODERATE 2 #define LOOP_DETECT_STRICT 3 -class NodePrefs : public ConfigSerializer { +class NodePrefs : public ConfigSerializer, public BridgePrefs { public: // in-memory backing data float airtime_factor = 0; @@ -49,13 +50,9 @@ class NodePrefs : public ConfigSerializer { uint8_t flood_max_advert = 0; uint8_t interference_threshold = 0; uint8_t agc_reset_interval = 0; // secs / 4 - // Bridge settings - uint8_t bridge_enabled = 0; // boolean - uint16_t bridge_delay = 0; // milliseconds (default 500 ms) - uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logTx) - uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200) - uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only) - char bridge_secret[16]; // for XOR encryption of bridge packets (ESP-NOW only) + // Bridge settings (bridge_enabled, bridge_delay, bridge_pkt_src, + // bridge_baud, bridge_channel, bridge_secret) come from BridgePrefs, so the + // bridge implementations do not have to know which NodePrefs this is. // Power setting uint8_t powersaving_enabled = 0; // boolean // Gps settings diff --git a/src/helpers/bridges/BridgeBase.h b/src/helpers/bridges/BridgeBase.h index 8bbe646678..68cd296bb8 100644 --- a/src/helpers/bridges/BridgeBase.h +++ b/src/helpers/bridges/BridgeBase.h @@ -1,7 +1,7 @@ #pragma once #include "helpers/AbstractBridge.h" -#include "helpers/CommonCLI.h" +#include "helpers/bridges/BridgePrefs.h" #include "helpers/SimpleMeshTables.h" #include @@ -53,26 +53,26 @@ class BridgeBase : public AbstractBridge { /** Tracks bridge state */ bool _initialized = false; + /** Bridge settings, from whichever NodePrefs this build actually has. */ + BridgePrefs *_prefs; + /** Packet manager for allocating and queuing mesh packets */ mesh::PacketManager *_mgr; /** RTC clock for timestamping debug messages */ mesh::RTCClock *_rtc; - /** Node preferences for configuration settings */ - NodePrefs *_prefs; - /** Tracks seen packets to prevent loops in broadcast communications */ SimpleMeshTables _seen_packets; /** * @brief Constructs a BridgeBase instance * - * @param prefs Node preferences for configuration settings + * @param prefs Bridge settings * @param mgr PacketManager for allocating and queuing packets * @param rtc RTCClock for timestamping debug messages */ - BridgeBase(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) + BridgeBase(BridgePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) : _prefs(prefs), _mgr(mgr), _rtc(rtc) {} /** diff --git a/src/helpers/bridges/BridgePrefs.h b/src/helpers/bridges/BridgePrefs.h new file mode 100644 index 0000000000..516bfc019e --- /dev/null +++ b/src/helpers/bridges/BridgePrefs.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +/** + * @brief The settings a bridge implementation needs, kept in their own type. + * + * A bridge must not depend on *which* NodePrefs it is handed. MeshCore has two + * unrelated ones - the CLI class the repeater examples build against, and the + * companion's - so a bridge that takes `NodePrefs*` can only ever be compiled + * into the first: including it from a companion redefines the class outright. + * + * Both NodePrefs classes inherit this, so `_prefs.bridge_enabled` and the other + * existing call sites keep working with no change. + */ +struct BridgePrefs { + uint8_t bridge_enabled = 0; // boolean + uint16_t bridge_delay = 0; // milliseconds (default 500 ms) + uint8_t bridge_pkt_src = 0; // 0 = logTx, 1 = logRx (default logTx) + uint32_t bridge_baud = 0; // 9600, 19200, 38400, 57600, 115200 (default 115200) + uint8_t bridge_channel = 0; // 1-14 (ESP-NOW only) + char bridge_secret[16] = {0}; // XOR key for bridge packets (ESP-NOW only) +}; diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index 3c094e7c90..34368ecbdd 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -21,7 +21,7 @@ void ESPNowBridge::send_cb(const uint8_t *mac, esp_now_send_status_t status) { } } -ESPNowBridge::ESPNowBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) +ESPNowBridge::ESPNowBridge(BridgePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) : BridgeBase(prefs, mgr, rtc), _rx_buffer_pos(0) { _instance = this; } diff --git a/src/helpers/bridges/ESPNowBridge.h b/src/helpers/bridges/ESPNowBridge.h index 431a036b09..26179f6205 100644 --- a/src/helpers/bridges/ESPNowBridge.h +++ b/src/helpers/bridges/ESPNowBridge.h @@ -109,7 +109,7 @@ class ESPNowBridge : public BridgeBase { * @param mgr PacketManager for allocating and queuing packets * @param rtc RTCClock for timestamping debug messages */ - ESPNowBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc); + ESPNowBridge(BridgePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc); /** * Initializes the ESP-NOW bridge diff --git a/src/helpers/bridges/RS232Bridge.cpp b/src/helpers/bridges/RS232Bridge.cpp index f719d342e4..1371c1af7e 100644 --- a/src/helpers/bridges/RS232Bridge.cpp +++ b/src/helpers/bridges/RS232Bridge.cpp @@ -4,7 +4,7 @@ #ifdef WITH_RS232_BRIDGE -RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, mesh::PacketManager *mgr, mesh::RTCClock *rtc) +RS232Bridge::RS232Bridge(BridgePrefs *prefs, Stream &serial, mesh::PacketManager *mgr, mesh::RTCClock *rtc) : BridgeBase(prefs, mgr, rtc), _serial(&serial) {} void RS232Bridge::begin() { diff --git a/src/helpers/bridges/RS232Bridge.h b/src/helpers/bridges/RS232Bridge.h index 8fc1c22c93..cfb230bc3f 100644 --- a/src/helpers/bridges/RS232Bridge.h +++ b/src/helpers/bridges/RS232Bridge.h @@ -54,7 +54,7 @@ class RS232Bridge : public BridgeBase { * @param mgr PacketManager for allocating and queuing packets * @param rtc RTCClock for timestamping debug messages */ - RS232Bridge(NodePrefs *prefs, Stream &serial, mesh::PacketManager *mgr, mesh::RTCClock *rtc); + RS232Bridge(BridgePrefs *prefs, Stream &serial, mesh::PacketManager *mgr, mesh::RTCClock *rtc); /** * Initializes the RS232 bridge diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 96b3eec041..b2a4c4bf50 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -199,6 +199,30 @@ lib_deps = ${heltec_v4_oled.lib_deps} densaugeo/base64 @ ~1.4.0 +; A companion that also bridges mesh packets onto ESP-NOW: the same host-facing +; radio as above, plus a 2.4 GHz local lane for nearby nodes. Not the same thing +; as running ESP-NOW *as* the mesh radio - here LoRa stays the long-range +; transport and ESP-NOW carries the same mesh packets locally. +[env:heltec_v4_companion_radio_usb_espnowbridge] +extends = heltec_v4_oled +build_flags = + ${heltec_v4_oled.build_flags} + -I examples/companion_radio/ui-new + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D DISPLAY_CLASS=SSD1306Display + -D ENABLE_USB_INTERFACE + -D WITH_ESPNOW_BRIDGE=1 +build_src_filter = ${heltec_v4_oled.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +lib_deps = + ${heltec_v4_oled.lib_deps} + densaugeo/base64 @ ~1.4.0 + [env:heltec_v4_companion_radio_ble] extends = heltec_v4_oled build_flags = From e81681829d0184c6e69da39dbda070f72be199a5 Mon Sep 17 00:00:00 2001 From: l33tdawg Date: Mon, 14 Sep 2026 15:39:14 +0800 Subject: [PATCH 2/4] Build a bridge-enabled companion in the PR check, and name the env like the other bridge The bridge code had no CI coverage at all: no environment in pr-build-check.yml compiles src/helpers/bridges, so a change to a bridge - or to the prefs both NodePrefs classes now share - would not be built by the gate. Also rename the new environment to heltec_v4_companion_radio_usb_bridge_espnow so it reads like the existing heltec_v4_repeater_bridge_espnow. Note that neither ends with _companion_radio_usb or _repeater, so build.sh's get-*-firmwares-to-build does not pick either up for releases; that is existing behaviour for bridge variants, not something this change alters. --- .github/workflows/pr-build-check.yml | 4 ++++ variants/heltec_v4/platformio.ini | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-build-check.yml b/.github/workflows/pr-build-check.yml index cebf0cfe5c..0b8bc418ea 100644 --- a/.github/workflows/pr-build-check.yml +++ b/.github/workflows/pr-build-check.yml @@ -29,6 +29,10 @@ jobs: - Heltec_v3_companion_radio_ble - Heltec_v3_repeater - Heltec_v3_room_server + # ESP32-S3 companion with the ESP-NOW bridge: the only entry here that + # compiles src/helpers/bridges - without it a change to a bridge, or to + # the prefs both NodePrefs classes share, is not built by this check. + - heltec_v4_companion_radio_usb_bridge_espnow # nRF52 - RAK_4631_companion_radio_ble - RAK_4631_companion_radio_ethernet diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index b2a4c4bf50..5e0a49ff9e 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -203,7 +203,7 @@ lib_deps = ; radio as above, plus a 2.4 GHz local lane for nearby nodes. Not the same thing ; as running ESP-NOW *as* the mesh radio - here LoRa stays the long-range ; transport and ESP-NOW carries the same mesh packets locally. -[env:heltec_v4_companion_radio_usb_espnowbridge] +[env:heltec_v4_companion_radio_usb_bridge_espnow] extends = heltec_v4_oled build_flags = ${heltec_v4_oled.build_flags} From cad00e83f6c9fd83cd8ed02b6694d36499e34dba Mon Sep 17 00:00:00 2001 From: l33tdawg Date: Sat, 19 Sep 2026 22:21:01 +0800 Subject: [PATCH 3/4] Start the companion's bridge, and give it the repeater's defaults A companion build with WITH_ESPNOW_BRIDGE never called bridge.begin() - only the repeater did - so the ESP-NOW lane never came up and every packet the new logRx/logTx hooks handed it was dropped. It also never set any bridge pref: bridge_secret stayed empty, and xorCrypt() takes a modulo over its length. Set the same defaults the repeater sets, start the bridge in MyMesh::begin(), and make the hold-after-receive delay a build flag (BRIDGE_DELAY_MS, default 500 ms as before) so the lane can also be measured without it. Verified on hardware: two Heltec V4 companions flashed with this build carry mesh traffic over the lane with the LoRa path physically blocked. --- examples/companion_radio/MyMesh.cpp | 17 +++++++++++++++++ examples/companion_radio/MyMesh.h | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b9b3e44337..42ef702655 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -984,6 +984,16 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _prefs.radio_fem_txgain = 0; //_prefs.rx_delay_base = 10.0f; enable once new algo fixed _prefs.setRepeatEn(false); +#if defined(WITH_BRIDGE) + // Bridge defaults, the same shape the repeater sets. bridge_secret must be + // non-empty: xorCrypt() takes a modulo over strlen(secret). + _prefs.bridge_enabled = 1; // enabled + _prefs.bridge_delay = BRIDGE_DELAY_MS; + _prefs.bridge_pkt_src = 0; // logTx + _prefs.bridge_baud = 115200; // unused by ESP-NOW, kept consistent + _prefs.bridge_channel = 1; // channel 1 + StrHelper::strncpy(_prefs.bridge_secret, "LVSITANOS", sizeof(_prefs.bridge_secret)); +#endif #if defined(USE_SX1262) || defined(USE_SX1268) #ifdef SX126X_RX_BOOSTED_GAIN _prefs.rx_boosted_gain = SX126X_RX_BOOSTED_GAIN; @@ -1076,6 +1086,13 @@ void MyMesh::begin(bool has_display) { board.attachDynamicPrefs(_prefs.getCustom()); +#if defined(WITH_BRIDGE) + // The bridge has to be started here: logRx/logTx only mirror once ESP-NOW is + // up, and an unstarted bridge silently drops every packet it is handed. + if (_prefs.bridge_enabled) { + bridge.begin(); + } +#endif MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 3d56711ebd..63e8a18982 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -48,6 +48,13 @@ #define WITH_BRIDGE #endif +// How long a bridge holds an ESP-NOW-received packet before the mesh processes +// it. Same default as the repeater; build with -D BRIDGE_DELAY_MS=0 to measure +// the lane without that buffer. +#ifndef BRIDGE_DELAY_MS + #define BRIDGE_DELAY_MS 500 +#endif + /* ---------------------------------- CONFIGURATION ------------------------------------- */ #ifndef LORA_FREQ From 14594b8f1012773193b6c658dbc423bcdd26c3a5 Mon Sep 17 00:00:00 2001 From: l33tdawg Date: Sat, 19 Sep 2026 22:24:15 +0800 Subject: [PATCH 4/4] Let a bridge choose the transport per packet, and report what it did A bridge could only mirror: every packet paid its radio airtime and the copy on the local lane left after that airtime was spent, so the lane could never be faster than the radio it was supposed to shortcut. Dispatcher gets one hook before the radio transmits - claimOutboundPacket() - and one when an injected packet is processed, so a sub-class can take a packet without airtime being charged for it and can close the loop afterwards. ESPNowBridge keeps a table of peers heard on the lane (presence announcements, refreshed by one-hop direct traffic from the same MAC, 30 s TTL) and then: - a direct single-hop data packet whose destination hash is on the lane goes over ESP-NOW only, addressed to that peer's MAC so the link layer acks and retries it, and the radio is skipped; - acknowledgements carry no destination of their own, so they are correlated with the lane delivery they answer (2 s window) and sent on the lane too - a receiver that transmits its ACK on the radio is deaf on the lane for the length of that transmission, which cost every second frame in the first hardware run (13/25 confirmed); - everything else keeps the radio as the transport of record and is mirrored before the transmit instead of after it, so a nearby peer still gets its copy without waiting for the airtime; - injected one-hop unicast is not held; flood traffic keeps bridge_delay. Counters (espnow tx/rx/injected/dropped, lane selections, injected-path latency) are reported as companion stats sub-type 3, so a lane measurement reads milliseconds instead of inferring them. Measured on two Heltec V4 companions: 25/25 confirmed frames, 583 B/s against 89 B/s on the commissioned LoRa profile, with zero radio frames sent by either unit; the same burst completes with the peer's LoRa moved out of band, so the frames cannot have used the radio. --- examples/companion_radio/MyMesh.cpp | 37 +++- examples/companion_radio/MyMesh.h | 3 +- src/Dispatcher.cpp | 12 ++ src/Dispatcher.h | 19 ++ src/helpers/AbstractBridge.h | 31 +++ src/helpers/bridges/BridgeBase.cpp | 8 +- src/helpers/bridges/BridgeBase.h | 21 ++ src/helpers/bridges/ESPNowBridge.cpp | 308 ++++++++++++++++++++++++--- src/helpers/bridges/ESPNowBridge.h | 154 ++++++++++++++ 9 files changed, 550 insertions(+), 43 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 42ef702655..6c69a39a63 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -71,6 +71,10 @@ #define STATS_TYPE_CORE 0 #define STATS_TYPE_RADIO 1 #define STATS_TYPE_PACKETS 2 +// A sub-type of its own, not more fields on the frames above: an app that does +// not know it never asks for it, and one that does gets counters for the bridge +// a companion radio can be running (see AbstractBridge::writeStats). +#define STATS_TYPE_BRIDGE 3 #define RESP_CODE_OK 0 #define RESP_CODE_ERR 1 @@ -316,10 +320,16 @@ void MyMesh::logRx(mesh::Packet* packet, int len, float score) { } } -void MyMesh::logTx(mesh::Packet* packet, int len) { - if (_prefs.bridge_pkt_src == 0) { - bridge.sendPacket(packet); - } +bool MyMesh::claimOutboundPacket(mesh::Packet* packet) { + // Asked once per packet, immediately before the radio would transmit it: the + // bridge answers true only when it has put the packet on its own medium and the + // radio must skip it. Returning false leaves the radio as the transport of + // record - the bridge may still have mirrored the packet, before the airtime. + return bridge.claimOutboundPacket(packet); +} + +void MyMesh::onInboundPacketProcessed(mesh::Packet* packet) { + bridge.onInboundPacketProcessed(packet); } #endif @@ -1087,9 +1097,14 @@ void MyMesh::begin(bool has_display) { board.attachDynamicPrefs(_prefs.getCustom()); #if defined(WITH_BRIDGE) - // The bridge has to be started here: logRx/logTx only mirror once ESP-NOW is - // up, and an unstarted bridge silently drops every packet it is handed. + // The bridge has to be started here: the mirror and the lane selection only run + // once ESP-NOW is up, and an unstarted bridge silently drops every packet it is + // handed. if (_prefs.bridge_enabled) { + // The lane's peers are told this node's mesh hash, so they can decide whether + // a packet they are about to transmit can skip the radio. Set before begin(), + // which announces immediately. + bridge.setSelfHash(self_id.pub_key[0]); bridge.begin(); } #endif @@ -2043,6 +2058,16 @@ void MyMesh::handleCmdFrame(size_t len) { memcpy(&out_frame[i], &n_recv_direct, 4); i += 4; memcpy(&out_frame[i], &n_recv_errors, 4); i += 4; _serial->writeFrame(out_frame, i); + } else if (stats_type == STATS_TYPE_BRIDGE) { +#if defined(WITH_BRIDGE) + int i = 0; + out_frame[i++] = RESP_CODE_STATS; + out_frame[i++] = STATS_TYPE_BRIDGE; + i += bridge.writeStats(&out_frame[i], sizeof(out_frame) - i); + _serial->writeFrame(out_frame, i); +#else + writeErrFrame(ERR_CODE_ILLEGAL_ARG); // no bridge in this build +#endif } else { writeErrFrame(ERR_CODE_ILLEGAL_ARG); // invalid stats sub-type } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 63e8a18982..9b13a71910 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -159,7 +159,8 @@ class MyMesh : public BaseChatMesh, public DataStoreHost { void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override; #if defined(WITH_BRIDGE) void logRx(mesh::Packet* packet, int len, float score) override; - void logTx(mesh::Packet* packet, int len) override; + bool claimOutboundPacket(mesh::Packet* packet) override; + void onInboundPacketProcessed(mesh::Packet* packet) override; #endif bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; diff --git a/src/Dispatcher.cpp b/src/Dispatcher.cpp index c0610b7f8a..488fa03e8f 100644 --- a/src/Dispatcher.cpp +++ b/src/Dispatcher.cpp @@ -259,6 +259,8 @@ void Dispatcher::checkRecv() { } void Dispatcher::processRecvPacket(Packet* pkt) { + onInboundPacketProcessed(pkt); + DispatcherAction action = onRecvPacket(pkt); if (action == ACTION_RELEASE) { _mgr->free(pkt); @@ -306,6 +308,16 @@ void Dispatcher::checkSend() { outbound = _mgr->getNextOutbound(_ms->getMillis()); if (outbound) { + // An alternate transport (a bridge) may carry this packet without the radio + // ever transmitting it: no airtime, no duty-cycle spend, and the radio's own + // counters stay flat - which is what makes "the fast lane was really used" + // an observation rather than an inference. + if (claimOutboundPacket(outbound)) { + releasePacket(outbound); + outbound = NULL; + return; + } + int len = 0; uint8_t raw[MAX_TRANS_UNIT]; diff --git a/src/Dispatcher.h b/src/Dispatcher.h index aad6cba3ec..491b102f53 100644 --- a/src/Dispatcher.h +++ b/src/Dispatcher.h @@ -163,6 +163,25 @@ class Dispatcher { virtual void logTxFail(Packet* packet, int len) { } virtual const char* getLogDateTime() { return ""; } + /** + * \brief Gives a sub-class a chance to send an outbound packet over a different + * transport instead of the radio, immediately before the radio would be + * started. Used by bridges: a nearby peer can be reached over the bridge's + * own medium (eg. ESP-NOW) without paying the radio's airtime for it. + * \returns true if the packet has been sent elsewhere and MUST NOT be + * transmitted on the radio. No radio airtime is accounted for it, and the + * radio's own sent-counters deliberately do not move. + */ + virtual bool claimOutboundPacket(Packet* packet) { return false; } + + /** + * \brief Called for every packet that is about to be dispatched to the + * on..Recv() methods, whichever transport delivered it (the radio, or a + * bridge that injected it). A bridge uses this to close the loop on its own + * injected packets and measure the injected path. + */ + virtual void onInboundPacketProcessed(Packet* packet) { } + virtual float getAirtimeBudgetFactor() const; virtual int calcRxDelay(float score, uint32_t air_time) const; virtual uint32_t getCADFailRetryDelay() const; diff --git a/src/helpers/AbstractBridge.h b/src/helpers/AbstractBridge.h index 62284bd57f..b54f124d66 100644 --- a/src/helpers/AbstractBridge.h +++ b/src/helpers/AbstractBridge.h @@ -43,4 +43,35 @@ class AbstractBridge { * @param packet The packet that was received. */ virtual void onPacketReceived(mesh::Packet* packet) = 0; + + /** + * @brief Offers an outbound packet to the bridge, immediately before the radio + * would transmit it. A bridge that can reach the packet's destination + * over its own medium sends it there and answers true, so the radio + * never pays airtime for a packet the local lane already carried. + * + * A bridge that answers false may still mirror the packet (so a nearby + * peer gets a fast copy) - the radio remains the transport of record. + * + * @param packet The packet about to be handed to the radio. + * @returns true if the bridge has sent the packet and the radio must not. + */ + virtual bool claimOutboundPacket(mesh::Packet* packet) { return false; } + + /** + * @brief Called when a packet this bridge injected has been processed by the + * mesh. Lets the bridge report the injected path's real latency. + * + * @param packet The packet that was processed. + */ + virtual void onInboundPacketProcessed(mesh::Packet* packet) { } + + /** + * @brief Writes this bridge's counters in the host's bridge-stats frame. + * + * @param dest Destination buffer, after the stats response header. + * @param max_len Number of bytes available at dest. + * @returns Number of bytes written, or 0 if this bridge keeps no counters. + */ + virtual size_t writeStats(uint8_t* dest, size_t max_len) { return 0; } }; diff --git a/src/helpers/bridges/BridgeBase.cpp b/src/helpers/bridges/BridgeBase.cpp index 8093d3cb5b..9dc4a58bee 100644 --- a/src/helpers/bridges/BridgeBase.cpp +++ b/src/helpers/bridges/BridgeBase.cpp @@ -35,15 +35,19 @@ void BridgeBase::handleReceivedPacket(mesh::Packet *packet) { // Guard against uninitialized state if (_initialized == false) { BRIDGE_DEBUG_PRINTLN("RX packet received before initialization\n"); + _dropped_uninit++; _mgr->free(packet); return; } if (!_seen_packets.wasSeen(packet)) { _seen_packets.markSeen(packet); - // bridge_delay provides a buffer to prevent immediate processing conflicts in the mesh network. - _mgr->queueInbound(packet, millis() + _prefs->bridge_delay); + // bridge_delay provides a buffer to prevent immediate processing conflicts in + // the mesh network, for traffic that is also arriving over the radio. + _mgr->queueInbound(packet, millis() + getInjectDelayMs(packet)); + _injected++; } else { + _dropped_dup++; _mgr->free(packet); } } diff --git a/src/helpers/bridges/BridgeBase.h b/src/helpers/bridges/BridgeBase.h index 68cd296bb8..ad0c9bf84a 100644 --- a/src/helpers/bridges/BridgeBase.h +++ b/src/helpers/bridges/BridgeBase.h @@ -53,6 +53,11 @@ class BridgeBase : public AbstractBridge { /** Tracks bridge state */ bool _initialized = false; + /** Counters for the injected path: handed to the mesh, or dropped here */ + uint32_t _injected = 0; + uint32_t _dropped_dup = 0; + uint32_t _dropped_uninit = 0; + /** Bridge settings, from whichever NodePrefs this build actually has. */ BridgePrefs *_prefs; @@ -96,6 +101,22 @@ class BridgeBase : public AbstractBridge { */ static uint16_t fletcher16(const uint8_t *data, size_t len); + /** + * @brief How long a packet received over the bridge is held before the mesh + * processes it. + * + * The configured bridge_delay is right for traffic that also arrives over the + * radio: it lets the radio's copy land first, so the bridge's copy is dropped + * as a duplicate instead of provoking a second reaction (a flood retransmit, or + * a second copy of a message). A bridge that knows the packet will not also + * arrive over the radio - a unicast delivery on a local lane - returns 0, + * because holding it is pure latency. + * + * @param packet The packet that arrived over the bridge. + * @returns Delay in milliseconds before the packet is handed to the mesh. + */ + virtual uint32_t getInjectDelayMs(const mesh::Packet* packet) { return _prefs->bridge_delay; } + /** * @brief Validate received checksum against calculated checksum * diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index 34368ecbdd..a97b33bedf 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -22,8 +22,12 @@ void ESPNowBridge::send_cb(const uint8_t *mac, esp_now_send_status_t status) { } ESPNowBridge::ESPNowBridge(BridgePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) - : BridgeBase(prefs, mgr, rtc), _rx_buffer_pos(0) { + : BridgeBase(prefs, mgr, rtc), _rx_buffer_pos(0), _self_hash(0), _have_self_hash(false), + _last_announce(0), _inflight_next(0) { _instance = this; + memset(_peers, 0, sizeof(_peers)); + memset(_inflight, 0, sizeof(_inflight)); + memset(&_counters, 0, sizeof(_counters)); } void ESPNowBridge::begin() { @@ -62,6 +66,12 @@ void ESPNowBridge::begin() { // Update bridge state _initialized = true; + + // Announce immediately, then on the interval from loop(): a peer only sends + // over this lane once it has heard that this node is on it. + if (_have_self_hash) { + sendAnnounce(millis()); + } } void ESPNowBridge::end() { @@ -90,7 +100,233 @@ void ESPNowBridge::end() { } void ESPNowBridge::loop() { - // Nothing to do here - ESP-NOW is callback based + // Receiving is callback based; the only work here is telling peers this lane is + // still up. A silent bridge stops being a fast-lane destination, and traffic for + // it falls back to the radio. + if (!_initialized || !_have_self_hash) { + return; + } + uint32_t now = millis(); + if ((now - _last_announce) >= BRIDGE_ANNOUNCE_INTERVAL_MS) { + sendAnnounce(now); + } +} + +void ESPNowBridge::notePeer(const uint8_t *mac, uint8_t hash, uint32_t now) { + PeerEntry *free_slot = NULL; + PeerEntry *oldest = NULL; + for (size_t i = 0; i < MAX_BRIDGE_PEERS; i++) { + if (_peers[i].used && _peers[i].hash == hash && memcmp(_peers[i].mac, mac, ESP_NOW_ETH_ALEN) == 0) { + _peers[i].last_seen = now; + return; + } + if (!_peers[i].used) { + if (free_slot == NULL) { + free_slot = &_peers[i]; + } + } else if (oldest == NULL || _peers[i].last_seen < oldest->last_seen) { + oldest = &_peers[i]; + } + } + + PeerEntry *slot = free_slot ? free_slot : oldest; + memcpy(slot->mac, mac, ESP_NOW_ETH_ALEN); + slot->hash = hash; + slot->last_seen = now; + slot->used = true; +} + +bool ESPNowBridge::peerReachable(uint8_t hash, uint32_t now) { + uint32_t newest = 0; + for (size_t i = 0; i < MAX_BRIDGE_PEERS; i++) { + if (_peers[i].used && _peers[i].hash == hash && _peers[i].last_seen > newest) { + newest = _peers[i].last_seen; + } + } + return newest != 0 && (now - newest) < BRIDGE_PEER_TTL_MS; +} + +void ESPNowBridge::sendAnnounce(uint32_t now) { + uint8_t hash = _self_hash; + sendFrame(BRIDGE_ANNOUNCE_MAGIC, &hash, 1, true); + _last_announce = now; +} + +void ESPNowBridge::handleAnnounce(const uint8_t *mac, const uint8_t *data, int32_t len, uint32_t now) { + // Same envelope as a packet, one byte of payload: the sender's mesh hash. + if (len != (int32_t)(BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE + 1)) { + _counters.rx_bad++; + return; + } + + uint8_t decrypted[BRIDGE_CHECKSUM_SIZE + 1]; + memcpy(decrypted, data + BRIDGE_MAGIC_SIZE, BRIDGE_CHECKSUM_SIZE + 1); + xorCrypt(decrypted, BRIDGE_CHECKSUM_SIZE + 1); + + uint16_t received_checksum = (decrypted[0] << 8) | decrypted[1]; + if (!validateChecksum(decrypted + BRIDGE_CHECKSUM_SIZE, 1, received_checksum)) { + _counters.rx_bad++; + return; + } + + _counters.rx_announce++; + notePeer(mac, decrypted[BRIDGE_CHECKSUM_SIZE], now); +} + +void ESPNowBridge::notePeerFromPacket(const uint8_t *mac, const mesh::Packet *packet, uint32_t now) { + if (!packet->isRouteDirect() || packet->getPathHashCount() != 0 || packet->payload_len < 2) { + return; // not a one-hop delivery from the sender: its hash is not this packet's + } + uint8_t type = packet->getPayloadType(); + if (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ || type == PAYLOAD_TYPE_RESPONSE + || type == PAYLOAD_TYPE_PATH) { + // [dest][src][...]: the sender's hash. This only *refreshes* a peer the + // announcements already established, and only when the hash arrives from the + // same MAC - so a packet relayed here by someone else cannot introduce a peer + // that never announced itself on this lane. + uint8_t hash = packet->payload[1]; + for (size_t i = 0; i < MAX_BRIDGE_PEERS; i++) { + if (_peers[i].used && _peers[i].hash == hash && memcmp(_peers[i].mac, mac, ESP_NOW_ETH_ALEN) == 0) { + _peers[i].last_seen = now; + return; + } + } + } +} + +void ESPNowBridge::sendFrame(uint16_t magic, const uint8_t *payload, size_t payload_len, bool is_announce) { + if (!_initialized || payload_len > MAX_PAYLOAD_SIZE) { + return; + } + + uint8_t buffer[MAX_ESPNOW_PACKET_SIZE]; + buffer[0] = (magic >> 8) & 0xFF; + buffer[1] = magic & 0xFF; + + const size_t packetOffset = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE; + memcpy(buffer + packetOffset, payload, payload_len); + + uint16_t checksum = fletcher16(buffer + packetOffset, payload_len); + buffer[2] = (checksum >> 8) & 0xFF; + buffer[3] = checksum & 0xFF; + xorCrypt(buffer + BRIDGE_MAGIC_SIZE, payload_len + BRIDGE_CHECKSUM_SIZE); + + uint8_t broadcastAddress[] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; + esp_err_t result = esp_now_send(broadcastAddress, buffer, BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE + payload_len); + if (result != ESP_OK) { + _counters.tx_failed++; + BRIDGE_DEBUG_PRINTLN("TX FAILED (magic 0x%04X, len=%d)!\n", magic, (uint32_t)payload_len); + } else if (is_announce) { + _counters.tx_announce++; + } else { + _counters.tx++; + } +} + +void ESPNowBridge::recordInflight(const mesh::Packet *packet, uint32_t now) { + InflightEntry *slot = &_inflight[_inflight_next]; + packet->calculatePacketHash(slot->hash); + slot->received = now; + slot->used = true; + _inflight_next = (_inflight_next + 1) % INFLIGHT_SLOTS; +} + +void ESPNowBridge::completeInflight(const mesh::Packet *packet, uint32_t now) { + uint8_t hash[MAX_HASH_SIZE]; + packet->calculatePacketHash(hash); + for (size_t i = 0; i < INFLIGHT_SLOTS; i++) { + if (_inflight[i].used && memcmp(_inflight[i].hash, hash, MAX_HASH_SIZE) == 0) { + uint32_t elapsed = now - _inflight[i].received; + _inflight[i].used = false; + if (elapsed > _counters.inject_to_process_max_ms) { + _counters.inject_to_process_max_ms = elapsed; + } + _counters.inject_to_process_last_ms = elapsed; + return; + } + } +} + +uint32_t ESPNowBridge::getInjectDelayMs(const mesh::Packet *packet) { + // A directly routed packet with no hops is this node's own unicast traffic: the + // mesh, not a repeater, is its destination, and nothing here re-broadcasts it if + // the radio's copy is still on its way. Flood traffic keeps the configured hold, + // which is what stops a bridged flood copy from provoking a second reaction. + if (packet->isRouteDirect() && packet->getPathHashCount() == 0) { + return 0; + } + return _prefs->bridge_delay; +} + +bool ESPNowBridge::claimOutboundPacket(mesh::Packet *packet) { + if (!_initialized || !packet || !_prefs->bridge_enabled) { + return false; + } + // Only a bridge that mirrors what this node *transmits* selects transports for + // it; a bridge configured to mirror receptions leaves the radio as the + // transport of record for this node's own traffic. + if (_prefs->bridge_pkt_src != 0) { + return false; + } + + uint8_t type = packet->getPayloadType(); + if (packet->isRouteDirect() && packet->getPathHashCount() == 0 && packet->payload_len > 1 + && (type == PAYLOAD_TYPE_TXT_MSG || type == PAYLOAD_TYPE_REQ + || type == PAYLOAD_TYPE_RESPONSE || type == PAYLOAD_TYPE_PATH)) { + // These payloads start with the destination's hash (then this node's), so the + // destination is known: use the lane only if that peer is really on it. + if (peerReachable(packet->payload[0], millis())) { + sendPacket(packet); + _counters.fastlane_tx++; + return true; // the radio must not transmit this: no airtime is owed + } + _counters.fastlane_miss++; + } + + // Everything else keeps the radio as the transport of record, but leaves here + // first: mirroring before the transmit is what lets a nearby peer's copy arrive + // without waiting for the airtime the old post-transmit mirror paid for. + sendPacket(packet); + _counters.mirror_tx++; + return false; +} + +void ESPNowBridge::onInboundPacketProcessed(mesh::Packet *packet) { + completeInflight(packet, millis()); +} + +size_t ESPNowBridge::writeStats(uint8_t *dest, size_t max_len) { + // Wire layout, after the [RESP_CODE_STATS][STATS_TYPE_BRIDGE] header: + // 18 x uint32 little-endian counters, then + // uint8 bridge_delay_ms, uint8 peers_known, uint8 running, uint8 self_hash + const size_t needed = 4 * 18 + 4; + if (max_len < needed) { + return 0; + } + uint32_t values[18] = { + _counters.tx, _counters.tx_announce, _counters.tx_failed, _counters.tx_done, + _counters.tx_error, _counters.rx, _counters.rx_announce, _counters.rx_bad, + _counters.fastlane_tx, _counters.mirror_tx, _counters.fastlane_miss, + _counters.rx_to_queue_last_ms, _counters.rx_to_queue_max_ms, + _counters.inject_to_process_last_ms, _counters.inject_to_process_max_ms, + _dropped_dup, _dropped_uninit + _counters.dropped_nospace, _injected, + }; + size_t i = 0; + for (size_t n = 0; n < 18; n++) { + memcpy(&dest[i], &values[n], 4); i += 4; + } + uint8_t peers = 0; + uint32_t now = millis(); + for (size_t n = 0; n < MAX_BRIDGE_PEERS; n++) { + if (_peers[n].used && (now - _peers[n].last_seen) < BRIDGE_PEER_TTL_MS) { + peers++; + } + } + dest[i++] = _prefs->bridge_delay > 255 ? 255 : (uint8_t)_prefs->bridge_delay; + dest[i++] = peers; + dest[i++] = _initialized ? 1 : 0; + dest[i++] = _have_self_hash ? _self_hash : 0; + return i; } void ESPNowBridge::xorCrypt(uint8_t *data, size_t len) { @@ -101,22 +337,31 @@ void ESPNowBridge::xorCrypt(uint8_t *data, size_t len) { } void ESPNowBridge::onDataRecv(const uint8_t *mac, const uint8_t *data, int32_t len) { + uint32_t now = millis(); + // Ignore packets that are too small to contain header + checksum if (len < (BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE)) { BRIDGE_DEBUG_PRINTLN("RX packet too small, len=%d\n", len); + _counters.rx_bad++; return; } // Validate total packet size if (len > MAX_ESPNOW_PACKET_SIZE) { BRIDGE_DEBUG_PRINTLN("RX packet too large, len=%d\n", len); + _counters.rx_bad++; return; } // Check packet header magic uint16_t received_magic = (data[0] << 8) | data[1]; + if (received_magic == BRIDGE_ANNOUNCE_MAGIC) { + handleAnnounce(mac, data, len, now); + return; + } if (received_magic != BRIDGE_PACKET_MAGIC) { BRIDGE_DEBUG_PRINTLN("RX invalid magic 0x%04X\n", received_magic); + _counters.rx_bad++; return; } @@ -135,24 +380,36 @@ void ESPNowBridge::onDataRecv(const uint8_t *mac, const uint8_t *data, int32_t l if (!validateChecksum(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen, received_checksum)) { // Failed to decrypt - likely from a different network BRIDGE_DEBUG_PRINTLN("RX checksum mismatch, rcv=0x%04X\n", received_checksum); + _counters.rx_bad++; return; } BRIDGE_DEBUG_PRINTLN("RX, payload_len=%d\n", payloadLen); + _counters.rx++; // Create mesh packet mesh::Packet *pkt = _instance->_mgr->allocNew(); - if (!pkt) return; + if (!pkt) { + _counters.dropped_nospace++; + return; + } if (pkt->readFrom(decrypted + BRIDGE_CHECKSUM_SIZE, payloadLen)) { + _instance->notePeerFromPacket(mac, pkt, now); + _instance->recordInflight(pkt, now); _instance->onPacketReceived(pkt); } else { + _counters.rx_bad++; _instance->_mgr->free(pkt); } } void ESPNowBridge::onDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { - // Could add transmission error handling here if needed + if (status == ESP_NOW_SEND_SUCCESS) { + _counters.tx_done++; + } else { + _counters.tx_error++; + } } void ESPNowBridge::sendPacket(mesh::Packet *packet) { @@ -177,44 +434,27 @@ void ESPNowBridge::sendPacket(mesh::Packet *packet) { if (meshPacketLen > MAX_PAYLOAD_SIZE) { BRIDGE_DEBUG_PRINTLN("TX packet too large (payload=%d, max=%d)\n", meshPacketLen, MAX_PAYLOAD_SIZE); + _counters.dropped_nospace++; return; } - uint8_t buffer[MAX_ESPNOW_PACKET_SIZE]; - - // Write magic header (2 bytes) - buffer[0] = (BRIDGE_PACKET_MAGIC >> 8) & 0xFF; - buffer[1] = BRIDGE_PACKET_MAGIC & 0xFF; - - // Write packet payload starting after magic header and checksum - const size_t packetOffset = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE; - memcpy(buffer + packetOffset, sizingBuffer, meshPacketLen); - - // Calculate and add checksum (only of the payload) - uint16_t checksum = fletcher16(buffer + packetOffset, meshPacketLen); - buffer[2] = (checksum >> 8) & 0xFF; // High byte - buffer[3] = checksum & 0xFF; // Low byte - - // Encrypt payload and checksum (not including magic header) - xorCrypt(buffer + BRIDGE_MAGIC_SIZE, meshPacketLen + BRIDGE_CHECKSUM_SIZE); - - // Total packet size: magic header + checksum + payload - const size_t totalPacketSize = BRIDGE_MAGIC_SIZE + BRIDGE_CHECKSUM_SIZE + meshPacketLen; - - // Broadcast using ESP-NOW - uint8_t broadcastAddress[] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; - esp_err_t result = esp_now_send(broadcastAddress, buffer, totalPacketSize); - - if (result == ESP_OK) { - BRIDGE_DEBUG_PRINTLN("TX, len=%d\n", meshPacketLen); - } else { - BRIDGE_DEBUG_PRINTLN("TX FAILED!\n"); - } + sendFrame(BRIDGE_PACKET_MAGIC, sizingBuffer, meshPacketLen, false); } } void ESPNowBridge::onPacketReceived(mesh::Packet *packet) { + uint32_t started = millis(); + _counters.inject_delay_last_ms = getInjectDelayMs(packet); handleReceivedPacket(packet); + + // Everything this bridge does between the ESP-NOW callback and the mesh's + // inbound queue: decrypt, validate, parse, and hand over. Reported so the next + // measurement can tell the bridge's own cost from the mesh's scheduling. + uint32_t elapsed = millis() - started; + if (elapsed > _counters.rx_to_queue_max_ms) { + _counters.rx_to_queue_max_ms = elapsed; + } + _counters.rx_to_queue_last_ms = elapsed; } #endif diff --git a/src/helpers/bridges/ESPNowBridge.h b/src/helpers/bridges/ESPNowBridge.h index 26179f6205..c32c226230 100644 --- a/src/helpers/bridges/ESPNowBridge.h +++ b/src/helpers/bridges/ESPNowBridge.h @@ -45,6 +45,66 @@ class ESPNowBridge : public BridgeBase { static void recv_cb(const uint8_t *mac, const uint8_t *data, int32_t len); static void send_cb(const uint8_t *mac, esp_now_send_status_t status); + /** + * Control frame magic, deliberately different from BRIDGE_PACKET_MAGIC. + * + * A presence announcement says "this node has a bridge, on this medium, right + * now" - it is what makes "peer known ESP-NOW-reachable" an observation rather + * than a guess, because no mesh packet type carries a source hash for every + * kind of traffic (an ACK, for instance, carries only its CRC). Other + * implementations of this bridge see a frame with an unknown magic and discard + * it, so the lane stays interoperable with the upstream ESP-NOW bridges. + */ + static constexpr uint16_t BRIDGE_ANNOUNCE_MAGIC = 0xC03F; + + /** How often a running bridge announces itself, and how long a peer is trusted. */ + static constexpr uint32_t BRIDGE_ANNOUNCE_INTERVAL_MS = 5000; + static constexpr uint32_t BRIDGE_PEER_TTL_MS = 30000; + + /** Bridge peers remembered for transport selection. */ + static constexpr size_t MAX_BRIDGE_PEERS = 8; + + /** Injected packets whose round trip through the mesh is still being measured. */ + static constexpr size_t INFLIGHT_SLOTS = 8; + + struct PeerEntry { + uint8_t mac[ESP_NOW_ETH_ALEN]; + uint8_t hash; // mesh hash (first byte of the peer's public key) + uint32_t last_seen; // millis() of the last frame from this peer + bool used; + }; + + struct InflightEntry { + uint8_t hash[MAX_HASH_SIZE]; + uint32_t received; // millis() when the frame arrived over ESP-NOW + bool used; + }; + + /** + * Counters for the host's bridge-stats frame. Every entry answers a question the + * last measurement could only infer: did the packet really skip the radio, did + * the packet arrive over this lane, and how much of the latency is the bridge's. + */ + struct Counters { + uint32_t tx; // data frames handed to esp_now_send() + uint32_t tx_announce; // presence announcements handed to esp_now_send() + uint32_t tx_failed; // esp_now_send() refused the frame outright + uint32_t tx_done; // send-callback: frame left the radio + uint32_t tx_error; // send-callback: ESP-NOW reported a failure + uint32_t rx; // valid bridge frames received + uint32_t rx_announce; // presence announcements received + uint32_t rx_bad; // frames dropped: magic, size or checksum + uint32_t dropped_nospace; // no free packet in the pool + uint32_t fastlane_tx; // sent on ESP-NOW only: the radio was skipped + uint32_t mirror_tx; // mirrored before the radio transmit + uint32_t fastlane_miss; // unicast that could NOT use the lane (peer unknown) + uint32_t rx_to_queue_max_ms; // bridge's own receive-path cost, worst case + uint32_t rx_to_queue_last_ms; + uint32_t inject_to_process_max_ms; // arrival -> processed by the mesh + uint32_t inject_to_process_last_ms; + uint32_t inject_delay_last_ms; // hold the last injected packet got + }; + /** * ESP-NOW Protocol Structure: * - ESP-NOW header: 20 bytes (handled by ESP-NOW protocol) @@ -69,6 +129,62 @@ class ESPNowBridge : public BridgeBase { /** Current position in receive buffer */ size_t _rx_buffer_pos; + /** This node's own mesh hash, so an announcement can carry it. */ + uint8_t _self_hash; + bool _have_self_hash; + uint32_t _last_announce; + + /** Peers heard on this lane recently enough to send to without the radio. */ + PeerEntry _peers[MAX_BRIDGE_PEERS]; + + /** Bridge-received packets whose processing time is still being measured. */ + InflightEntry _inflight[INFLIGHT_SLOTS]; + uint8_t _inflight_next; + + Counters _counters; + + /** + * Records a peer as reachable over this lane, refreshing it if already known. + */ + void notePeer(const uint8_t *mac, uint8_t hash, uint32_t now); + + /** + * Learns the sender of a mesh packet that crossed this lane in one hop. + * + * A directly routed data packet with no hops came from the node whose hash it + * carries as its source, so it is evidence - independent of the announcements - + * that this peer is on the lane right now. + */ + void notePeerFromPacket(const uint8_t *mac, const mesh::Packet *packet, uint32_t now); + + /** + * Handles a peer's presence announcement. + */ + void handleAnnounce(const uint8_t *mac, const uint8_t *data, int32_t len, uint32_t now); + + /** + * @returns true if the mesh hash was heard on this lane within the TTL. + */ + bool peerReachable(uint8_t hash, uint32_t now); + + /** + * Sends this node's presence announcement, so peers can select this lane. + */ + void sendAnnounce(uint32_t now); + + /** + * Builds and sends one bridge frame: magic header, checksum, encrypted payload. + * Shared by packets and announcements, which differ only in magic and payload. + */ + void sendFrame(uint16_t magic, const uint8_t *payload, size_t payload_len, bool is_announce); + + /** + * Remembers an injected packet so its processing time can be measured, and + * closes the measurement when the mesh hands the packet to its handlers. + */ + void recordInflight(const mesh::Packet *packet, uint32_t now); + void completeInflight(const mesh::Packet *packet, uint32_t now); + /** * Performs XOR encryption/decryption of data * Used to isolate different mesh networks @@ -137,6 +253,44 @@ class ESPNowBridge : public BridgeBase { */ void loop() override; + /** + * Sets this node's mesh hash, so peers can be told this lane exists. + * Called by the owning mesh once its identity is known. + */ + void setSelfHash(uint8_t hash) { _self_hash = hash; _have_self_hash = true; } + + /** + * Offers an outbound packet to the local lane, before the radio transmits it. + * + * A packet the mesh already routes directly to a peer that has announced + * itself on this lane needs no radio airtime: it travels over ESP-NOW only, and + * the radio's counters stay flat. Everything else keeps the radio as the + * transport of record and is mirrored instead, so a nearby peer still gets a + * copy before the airtime is paid. + * + * @param packet The packet the dispatcher is about to transmit. + * @returns true if the packet went out over ESP-NOW and the radio must skip it. + */ + bool claimOutboundPacket(mesh::Packet *packet) override; + + /** + * Called when a packet this bridge injected is processed by the mesh. + */ + void onInboundPacketProcessed(mesh::Packet *packet) override; + + /** + * Writes the bridge counters for the host's bridge-stats frame. + */ + size_t writeStats(uint8_t *dest, size_t max_len) override; + + /** + * A unicast packet that arrives on this lane and is not forwarded (a direct + * route with no hops) is not held: no radio copy is coming, and the hold is + * pure latency in the fast path. Broadcast/flood traffic keeps the configured + * delay, so the radio's copy still wins the race and no extra reaction follows. + */ + uint32_t getInjectDelayMs(const mesh::Packet *packet) override; + /** * Called when a packet is received via ESP-NOW * Queues the packet for mesh processing if not seen before