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/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4e6f983280..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 @@ -305,6 +309,30 @@ 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); + } +} + +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 + bool MyMesh::isAutoAddEnabled() const { return (_prefs.manual_add_contacts & 1) == 0; } @@ -932,7 +960,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; @@ -962,6 +994,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; @@ -1054,6 +1096,18 @@ void MyMesh::begin(bool has_display) { board.attachDynamicPrefs(_prefs.getCustom()); +#if defined(WITH_BRIDGE) + // 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 MESH_DEBUG_PRINTLN("RX Boosted Gain Mode: %s", radio_driver.getRxBoostedGainMode() ? "Enabled" : "Disabled"); } @@ -2004,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 } @@ -2444,6 +2508,10 @@ void MyMesh::checkSerialInterface() { } void MyMesh::loop() { +#if defined(WITH_BRIDGE) + bridge.loop(); +#endif + BaseChatMesh::loop(); if (_cli_rescue) { @@ -2485,5 +2553,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..9b13a71910 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -34,6 +34,27 @@ #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 + +// 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 @@ -136,6 +157,11 @@ 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; + bool claimOutboundPacket(mesh::Packet* packet) override; + void onInboundPacketProcessed(mesh::Packet* packet) override; +#endif bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; bool shouldOverwriteWhenFull() const override; @@ -232,6 +258,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/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/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.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 8bbe646678..ad0c9bf84a 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,31 @@ 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; + /** 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) {} /** @@ -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/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..a97b33bedf 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -21,9 +21,13 @@ void ESPNowBridge::send_cb(const uint8_t *mac, esp_now_send_status_t status) { } } -ESPNowBridge::ESPNowBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) - : BridgeBase(prefs, mgr, rtc), _rx_buffer_pos(0) { +ESPNowBridge::ESPNowBridge(BridgePrefs *prefs, mesh::PacketManager *mgr, mesh::RTCClock *rtc) + : 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 431a036b09..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 @@ -109,7 +225,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 @@ -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 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..5e0a49ff9e 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_bridge_espnow] +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 =