From 598a5a7d659124d1e4f7b4e9471ccbc979a3f9c7 Mon Sep 17 00:00:00 2001 From: l33tdawg Date: Mon, 14 Sep 2026 15:22:44 +0800 Subject: [PATCH 1/3] 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/3] 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/3] 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