From 9a149eaea11595301dace39d93996d60bfe02347 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 17 Sep 2026 00:37:32 -0700 Subject: [PATCH] Combine ESP-NOW and MQTT in ESP32 Full builds --- build.sh | 52 ++++++++++++--- docs/_javascript/firmware_picker.js | 16 +++-- docs/firmware_picker.md | 5 +- examples/simple_repeater/MyMesh.cpp | 38 ++++++++++- examples/simple_repeater/MyMesh.h | 86 ++++++++++++++++++++++++ examples/simple_room_server/MyMesh.cpp | 27 ++++++++ examples/simple_room_server/MyMesh.h | 91 ++++++++++++++++++++++++++ scripts/generate_picker_controls.py | 5 +- test/test_build_profiles.sh | 22 +++++++ test/test_firmware_picker.js | 12 ++++ 10 files changed, 336 insertions(+), 18 deletions(-) diff --git a/build.sh b/build.sh index 54c839da..c41080f7 100755 --- a/build.sh +++ b/build.sh @@ -121,7 +121,7 @@ Commands: build-firmwares: Build canonical firmwares for all targets. Runtime-setting aliases and Terminal Chat targets replaced by Full Companion remain available as explicit builds. build-firmwares-logging-matrix: Build canonical standard artifacts with merged runtime USB logging plus unified FULL ESP32 USB+WiFi and FULL fallback profiles, logging each target under out/build-logs/ and continuing after failures. MQTT observers and ESP-NOW bridges always use FULL. KISS, BLE-only Companion, and constrained LoRa-OTA repeater contracts do not gain plaintext USB logging. build-companion-firmwares-logging-matrix: Build canonical Companion targets with merged runtime USB logging where the transport is safe, plus applicable MQTT and expanded FULL profiles. Full Companion replaces separate USB, BLE, WiFi, Terminal Chat, and USB-logging artifacts where an exact combined recipe exists. - build-full-esp32-firmwares: Build feature-complete ESP32 profiles with up to 254 neighbors, USB packet logging, WiFi MQTT where supported, LoRa OTA, and expanded dual-OTA partitions. + build-full-esp32-firmwares: Build feature-complete ESP32 profiles with up to 254 neighbors, USB packet logging, WiFi MQTT plus ESP-NOW where a matching MQTT recipe exists, LoRa OTA, and expanded dual-OTA partitions. build-full-esp32-logging-firmwares: Build only the FULL USB-logging fallback for targets without a matching WiFi MQTT environment. build-matching-firmwares : Build all firmwares for build targets containing the string given for . build-companion-firmwares: Build canonical companion firmwares; legacy setting aliases remain available as direct builds. @@ -699,7 +699,7 @@ prompt_for_build_mode() { "Build canonical companion firmwares (Full replaces separate transports; power saving and FEM/RX gain are runtime configurable)" "Build all chat room server firmwares" "Build all sensor firmwares" - "Build FULL ESP32 firmwares (all features, USB logging, WiFi MQTT where available, and LoRa OTA)" + "Build FULL ESP32 firmwares (all features, USB logging, WiFi MQTT + ESP-NOW where available, and LoRa OTA)" "Build only FULL ESP32 USB-logging fallbacks for targets without WiFi MQTT" "Build canonical full Companion firmwares (runtime FEM control and host-backed LoRa OTA)" ) @@ -795,7 +795,7 @@ prompt_for_single_target_build_profile() { local options=( "Auto (keep target capabilities and enforce the current partition)" "Standard portable image (allow documented legacy-slot reductions)" - "FULL everything (all features, 254 neighbors, USB logging, WiFi MQTT where available, LoRa OTA, expanded dual-OTA partitions)" + "FULL everything (all features, 254 neighbors, USB logging, WiFi MQTT + ESP-NOW where available, LoRa OTA, expanded dual-OTA partitions)" ) echo "Select the Option 1 build profile:" @@ -821,7 +821,7 @@ prompt_for_single_target_build_profile() { 3) BUILD_PROFILE_OVERRIDE="full" SINGLE_TARGET_FULL_BUILD=1 - echo "Using FULL everything: all features, 254 neighbors, USB logging, WiFi MQTT where available, LoRa OTA, and expanded dual-OTA partitions." + echo "Using FULL everything: all features, 254 neighbors, USB logging, WiFi MQTT + ESP-NOW where available, LoRa OTA, and expanded dual-OTA partitions." return 0 ;; esac @@ -2348,9 +2348,17 @@ get_unified_full_infrastructure_target() { local target=$1 local candidate local base=${target%_} + local mqtt_base=$base [ "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" = "ESP32_PLATFORM" ] || return 1 - case "${base,,}" in + # A Full ESP-NOW bridge is the same combined infrastructure image as the + # matching WiFi/MQTT observer. Preserve the ESP-NOW target as an accepted + # input, but resolve it to the MQTT recipe which supplies WiFi, TLS, and the + # bridge implementation; the Full overlay adds ESP-NOW back in below. + case "${mqtt_base,,}" in + *_repeater_bridge_espnow) mqtt_base=${mqtt_base%_bridge_espnow} ;; + esac + case "${mqtt_base,,}" in *_repeater|*_room_server|*_repeater_observer_mqtt|*_room_server_observer_mqtt) ;; *) return 1 ;; esac @@ -2358,7 +2366,7 @@ get_unified_full_infrastructure_target() { # These plain Full recipes retain more routing capacity than their MQTT # siblings (T-Beam flood rules / room neighbors, TLora repeater neighbors). # They are intentional alternatives, not duplicate Full artifacts. - case "${base,,}" in + case "${mqtt_base,,}" in tbeam_sx1262_repeater|tbeam_sx1276_repeater|\ tbeam_sx1262_room_server|tbeam_sx1276_room_server|\ lilygo_tlora_v2_1_1_6_repeater) return 1 ;; @@ -2367,7 +2375,7 @@ get_unified_full_infrastructure_target() { # Some exact hardware recipes have a trailing underscore on either side. # Require the same board as well as the same role/hardware name; never fold # a display, radio, storage, Ethernet, or serial-bridge variant into this. - for candidate in "$target" "${base}_observer_mqtt" "${base}_observer_mqtt_"; do + for candidate in "$target" "${mqtt_base}_observer_mqtt" "${mqtt_base}_observer_mqtt_"; do if is_mqtt_bridge_target "$candidate" \ && supports_esp32_full_build "$candidate" \ && [ -n "${PIO_ENV_BOARD_BY_NAME[$target]:-}" ] \ @@ -2379,6 +2387,24 @@ get_unified_full_infrastructure_target() { return 1 } +apply_esp32_full_shared_bridge_profile() { + local env_name=$1 + + # Full infrastructure images use the MQTT observer recipe for WiFi/TLS and + # add the cooperative ESP-NOW bridge. Companion Full is a different client + # transport role, not an infrastructure packet bridge. + if [ "$ESP32_FULL_BUILD" != "1" ] \ + || [ "${PIO_ENV_PLATFORM_BY_NAME[$env_name]:-}" != "ESP32_PLATFORM" ] \ + || is_esp32_companion_build "$env_name" \ + || ! is_mqtt_bridge_target "$env_name"; then + return 0 + fi + + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DWITH_ESPNOW_BRIDGE=1" + append_platformio_build_src_filter "+" + record_build_capability "bridge.espnow" +} + configure_unified_full_infrastructure_output() { [ "$ESP32_FULL_BUILD" = "1" ] && is_mqtt_bridge_target "$1" || return 0 case "${1,,}" in @@ -4316,6 +4342,7 @@ build_firmware() { apply_esp32_lora_ota_size_profile "$env_name" apply_esp32_constrained_companion_size_profile "$env_name" apply_esp32_full_size_profile "$env_name" + apply_esp32_full_shared_bridge_profile "$env_name" apply_esp32_full_async_tcp_profile "$env_name" apply_repeater_neighbor_capacity "$env_name" apply_nrf52_size_profile "$env_name" @@ -5090,7 +5117,7 @@ configure_effective_build_profile() { BUILD_PROFILE_EFFECTIVE="full" SINGLE_TARGET_FULL_BUILD=1 RESOLVED_BUILD_TARGETS=("$unified_target") - echo "Using ${unified_target} as the combined Full image for ${target}; logging/MQTT are runtime settings." + echo "Using ${unified_target} as the combined Full image for ${target}; logging, MQTT, and ESP-NOW are runtime settings." echo "Use --build-profile standard for the original portable partition/OTA target contract." elif supports_esp32_full_build "$target"; then BUILD_PROFILE_EFFECTIVE="full" @@ -5135,6 +5162,11 @@ configure_effective_build_profile() { fi if is_companion_radio_full_target "$target"; then BUILD_PROFILE_EFFECTIVE="full" + elif unified_target=$(get_unified_full_infrastructure_target "$target"); then + BUILD_PROFILE_EFFECTIVE="full" + SINGLE_TARGET_FULL_BUILD=1 + RESOLVED_BUILD_TARGETS=("$unified_target") + echo "Using ${unified_target} as the combined Full image for ${target}; MQTT and ESP-NOW are runtime services." elif supports_esp32_full_build "$target"; then BUILD_PROFILE_EFFECTIVE="full" SINGLE_TARGET_FULL_BUILD=1 @@ -5537,7 +5569,7 @@ run_full_esp32_profile() { MQTT_BRIDGE_OVERRIDE="off" FIRMWARE_FILENAME_INFIX="full-logging" else - echo "${profile_label}: building ${#full_targets[@]} unified feature-complete ESP32 target(s) with up to ${ESP32_FULL_MAX_NEIGHBOURS} neighbors (target DRAM limits apply), USB packet logging, direct WiFi MQTT, and expanded dual-OTA partitions." + echo "${profile_label}: building ${#full_targets[@]} unified feature-complete ESP32 target(s) with up to ${ESP32_FULL_MAX_NEIGHBOURS} neighbors (target DRAM limits apply), USB packet logging, direct WiFi MQTT plus ESP-NOW, and expanded dual-OTA partitions." echo "Unified FULL artifacts include LoRa OTA, keep verbose debug off, and use filename form: name-full-usb-wifi-ota-version." MESHDEBUG_OVERRIDE="off" PACKET_LOGGING_OVERRIDE="on" @@ -6225,7 +6257,7 @@ main() { INTERACTIVE_BUILD_SELECTION=1 prompt_for_build_mode if [ "$SINGLE_TARGET_FULL_BUILD" = "1" ]; then - echo "Skipping separate debug and MQTT prompts; FULL everything enables USB logging and WiFi MQTT where the hardware supports it." + echo "Skipping separate debug and MQTT prompts; FULL everything enables USB logging and WiFi MQTT + ESP-NOW where the hardware supports it." elif is_automatic_profile_command "${SELECTED_COMMAND_ARGS[0]}"; then if is_logging_matrix_command "${SELECTED_COMMAND_ARGS[0]}"; then echo "Skipping debug and MQTT prompts; this action builds standard artifacts with merged runtime USB logging and unified FULL profiles automatically." diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 36c9bbd5..b600f2a8 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -707,6 +707,10 @@ if (profile.controls.rs232 && profile.role === "repeater" && profile.mode === "standard") { profile.connectionModes = ["standard", "rs232"]; } + if (profile.controls.espnowBridge && + ["repeater", "room"].includes(profile.role) && profile.mode === "standard") { + profile.connectionModes = ["standard", "espnow"]; + } } profile.hardwareFamily = hardwareFamilyFor( profile.hardware, @@ -1150,8 +1154,10 @@ ], "Separate from device sleep and LoRa RXPS. Bluetooth coexistence can constrain the effective mode."); } if (info.mqtt) { - toggle("MQTT broker connections", "set mqtt.enabled", "get mqtt.enabled", - "Configure WiFi and broker slots first, using WebConfig or set mqtt1.preset/settings. Off keeps those settings. get mqtt.running checks the runtime; get mqtt.status shows connections."); + toggle(info.espnowBridge ? "MQTT + ESP-NOW bridge" : "MQTT broker connections", "set mqtt.enabled", "get mqtt.enabled", + info.espnowBridge + ? "This Full image has one shared bridge switch: On starts WiFi/MQTT and then ESP-NOW after WiFi associates; Off stops both. Configure WiFi and broker slots first, and set bridge.channel to your access point's fixed 2.4 GHz channel." + : "Configure WiFi and broker slots first, using WebConfig or set mqtt1.preset/settings. Off keeps those settings. get mqtt.running checks the runtime; get mqtt.status shows connections."); ["status", "packets", "raw", "rx"].forEach(function (name) { toggle("MQTT " + (name === "rx" ? "receive capture" : name + " publication"), "set mqtt." + name, "get mqtt." + name, name === "status" ? "Only controls status publishing; connections remain enabled. The check command reports connection status." : ""); @@ -1177,10 +1183,12 @@ }), "Use the UART and pin map for this exact board. Canonical GPS-enabled RAK4631 uses UART2; select it with set bridge.uart 2 while the bridge is stopped. UART1 needs a compatible GPS-free image."); } if (info.espnowBridge && infrastructure) { - toggle("ESP-NOW bridge", "set bridge.enabled", "get bridge.running"); + if (!info.mqtt) toggle("ESP-NOW bridge", "set bridge.enabled", "get bridge.running"); section("ESP-NOW bridge framing", ["wrapped", "raw"].map(function (mode) { return { label: mode, commands: ["set bridge.format " + mode] }; - }), "Set bridge.channel to the bridge channel. Primary ESP-NOW mesh uses set espnow.channel instead; see the board guide before changing channels."); + }), info.mqtt + ? "This Full image shares one 2.4 GHz radio between WiFi/MQTT and ESP-NOW. ESP-NOW begins after WiFi connects and only when bridge.channel matches that access point's fixed channel. Primary ESP-NOW mesh uses set espnow.channel instead." + : "Set bridge.channel to the bridge channel. Primary ESP-NOW mesh uses set espnow.channel instead; see the board guide before changing channels."); } if (full && /sensecapindicator/i.test(profile.target)) section("Indicator wireless transport", [ { label: "WiFi", commands: ["set companion.transport wifi", "reboot"] }, diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 0dad2223..fd471a7e 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -178,7 +178,10 @@ Ethernet bridge firmware because those paths use different compiled drivers. For commands and option explanations, follow the [ESP-NOW bridge setup guide](espnow_bridge_setup.md). -An ESP-NOW bridge target keeps LoRa as its primary mesh radio. Its runtime +An ESP-NOW bridge target keeps LoRa as its primary mesh radio. Expanded ESP32 +Full repeater and room-server images combine ESP-NOW with WiFi MQTT in the +same firmware. Both use one 2.4 GHz radio, so the ESP-NOW bridge channel must +match the connected WiFi access point's fixed channel. Its runtime `bridge.format` setting chooses the peer protocol: `wrapped` (the backward-compatible bridge-to-bridge default using `bridge.secret`) or `raw` (direct MeshCore ESP-NOW LR frames for `Generic_ESPNOW`, diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 9219a004..a6aea8c5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1147,6 +1147,13 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed RX packets - bridge decides based on mqtt.rx setting if (mqtt_bridge) mqtt_bridge->onPacketReceived(pkt); + #ifdef WITH_ESPNOW_BRIDGE + // ESP-NOW follows bridge.source, independently of MQTT's mqtt.rx policy. + ESPNowBridge* espnow = &espnow_bridge; + if (_prefs.bridge_pkt_src == 1 && espnow && espnow->isRunning()) { + espnow->sendPacket(pkt); + } + #endif #elif defined(WITH_BRIDGE) // Non-MQTT bridge: use bridge.source setting AbstractBridge* active_bridge = activeBridge(); @@ -1192,6 +1199,12 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { #ifdef WITH_MQTT_BRIDGE // MQTT bridge: always feed TX packets - bridge decides based on mqtt.tx setting if (mqtt_bridge) mqtt_bridge->sendPacket(pkt); + #ifdef WITH_ESPNOW_BRIDGE + ESPNowBridge* espnow = &espnow_bridge; + if (_prefs.bridge_pkt_src == 0 && espnow && espnow->isRunning()) { + espnow->sendPacket(pkt); + } + #endif #elif defined(WITH_BRIDGE) // Non-MQTT bridge: use bridge.source setting AbstractBridge* active_bridge = activeBridge(); @@ -3389,6 +3402,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc anon_limiter(4, 180) // max 4 every 3 minutes #if defined(WITH_MQTT_BRIDGE) , mqtt_bridge(nullptr) + #if defined(WITH_ESPNOW_BRIDGE) + , espnow_bridge(&_prefs, _mgr, &rtc) + , shared_espnow_retry_at(0) + #endif #elif defined(WITH_RS232_BRIDGE) , bridge(nullptr) #elif defined(WITH_ESPNOW_BRIDGE) @@ -3767,6 +3784,9 @@ void MyMesh::begin(FILESYSTEM *fs) { configureBridgeFilter(active_bridge); active_bridge->begin(); +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + startSharedEspNowBridgeIfReady(); +#endif } #endif } @@ -3788,6 +3808,9 @@ void MyMesh::begin(FILESYSTEM *fs) { // persistent WebUI master switch defaults off on infrastructure roles. start_webui = start_webui || _cli.getObserverPrefs()->wifi_ssid[0] == 0; if (start_webui && _cli.getObserverPrefs()->wifi_ssid[0] == 0) { + #if defined(WITH_ESPNOW_BRIDGE) + if (espnow_bridge.isRunning()) espnow_bridge.end(); + #endif if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->end(); } #endif @@ -12517,8 +12540,16 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { expireRecentRepeatersIfDue(); #endif -#if defined(WITH_BRIDGE) && !defined(WITH_MQTT_BRIDGE) - // MQTT runs its own task; serial and ESP-NOW bridges remain cooperative. +#if defined(WITH_ESPNOW_BRIDGE) + // MQTT runs on Core 0. ESP-NOW remains cooperative, including in the + // combined Full image where both transports share the WiFi station radio. + #if defined(WITH_MQTT_BRIDGE) + if (espnow_bridge.isRunning()) espnow_bridge.loop(); + else startSharedEspNowBridgeIfReady(); + #else + if (bridge.isRunning()) bridge.loop(); + #endif +#elif defined(WITH_BRIDGE) && !defined(WITH_MQTT_BRIDGE) AbstractBridge* active_bridge = activeBridge(); if (active_bridge && active_bridge->isRunning()) active_bridge->loop(); #endif @@ -13184,6 +13215,9 @@ bool MyMesh::hasPendingWork() const { #if defined(WITH_BRIDGE) const AbstractBridge* active_bridge = activeBridge(); if (active_bridge && active_bridge->isRunning()) return true; +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + if (espnow_bridge.isRunning()) return true; +#endif #endif if (radio_driver.isWatchdogObserving()) return true; // keep MCU awake for one radio duty cycle if (radio_driver.isCalibratingNoiseFloor()) return true; // keep MCU awake for the noise-floor window diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index be0029b3..f2b05bb4 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -564,6 +564,13 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks int matching_peer_indexes[MAX_CLIENTS]; #if defined(WITH_MQTT_BRIDGE) MQTTBridge* mqtt_bridge; + // Expanded ESP32 Full builds run MQTT and ESP-NOW on the same 2.4 GHz + // station interface. MQTT owns the WiFi connection; ESP-NOW must therefore + // use that AP's channel (ESPNowBridge rejects a mismatch at start). + #if defined(WITH_ESPNOW_BRIDGE) + ESPNowBridge espnow_bridge; + uint32_t shared_espnow_retry_at = 0; + #endif #elif defined(WITH_RS232_BRIDGE) RS232Bridge* bridge; uint8_t active_rs232_bridge_uart = 0; @@ -590,6 +597,21 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks #endif } #endif + +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + // MQTT creates its WiFi task asynchronously. Do not let ESP-NOW touch the + // station interface until that task has associated, otherwise it could bring + // up a second WiFi owner before the AP selects the shared channel. + bool startSharedEspNowBridgeIfReady() { + if (espnow_bridge.isRunning()) return true; + if (!mqtt_bridge || !mqtt_bridge->isRunning() || !WiFi.isConnected()) return false; + if (!millisHasNowPassed(shared_espnow_retry_at)) return false; + shared_espnow_retry_at = millis() + 5000; + configureBridgeFilter(&espnow_bridge); + espnow_bridge.begin(); + return espnow_bridge.isRunning(); + } +#endif #ifdef WITH_SNMP MeshSNMPAgent _snmp_agent; #endif @@ -1216,11 +1238,60 @@ public: #endif bool isBridgeRunning() const override { +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + return (mqtt_bridge && mqtt_bridge->isRunning()) || espnow_bridge.isRunning(); +#else const AbstractBridge* active_bridge = activeBridge(); return active_bridge != nullptr && active_bridge->isRunning(); +#endif } bool setBridgeState(bool enable) override { +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + // The combined Full bridge is intentionally one saved switch. ESP-NOW is + // started only after MQTT has brought WiFi up, so it can validate and share + // the access point channel instead of retuning the station interface. + if (!enable && !mqtt_bridge && !espnow_bridge.isRunning()) return true; + if (!mqtt_bridge) { + MQTTNodeInfo node_info; + node_info.node_name = _prefs.node_name; + node_info.freq = &_prefs.freq; + node_info.bw = &_prefs.bw; + node_info.sf = &_prefs.sf; + node_info.cr = &_prefs.cr; + node_info.repeat_flag = &_prefs.disable_fwd; + node_info.repeat_when_nonzero = false; + mqtt_bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(), + getRTCClock(), &self_id); + if (!mqtt_bridge) return false; + } + if (enable) { + if (!mqtt_bridge->isRunning()) { + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + mqtt_bridge->setDeviceID(device_id); + mqtt_bridge->setFirmwareVersion(getFirmwareVer()); + mqtt_bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + mqtt_bridge->setBuildDate(getBuildDate()); + mqtt_bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); + configureBridgeFilter(mqtt_bridge); + mqtt_bridge->begin(); + _alerter.setBridge(mqtt_bridge); + } + if (!mqtt_bridge->isRunning()) return false; + shared_espnow_retry_at = 0; + startSharedEspNowBridgeIfReady(); + // MQTT has started successfully; ESP-NOW joins after its WiFi station + // associates and validates the configured shared channel. + return true; + } + if (espnow_bridge.isRunning()) espnow_bridge.end(); + if (mqtt_bridge->isRunning()) mqtt_bridge->end(); + shared_espnow_retry_at = 0; + _alerter.setBridge(nullptr); + return !mqtt_bridge->isRunning() && !espnow_bridge.isRunning(); +#else // Disabling an already-absent heap-backed bridge is successful and must // not allocate an instance merely to stop it. The embedded ESP-NOW bridge // is always present, so it continues through the normal state check. @@ -1299,9 +1370,23 @@ public: #endif return stopped; } +#endif } bool restartBridge() override { +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#ifdef WITH_WEBCONFIG + if (_wc_batch_active) { + _wc_restart_pending = true; + return true; + } +#endif + if (espnow_bridge.isRunning()) espnow_bridge.end(); + if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->end(); + shared_espnow_retry_at = 0; + _alerter.setBridge(nullptr); + return setBridgeState(true); +#else #ifdef WITH_RS232_BRIDGE // RS-232 changes must be applied synchronously so the CLI can commit or // roll back the selected pins/baud. This branch also reconstructs a bridge @@ -1337,6 +1422,7 @@ public: configureBridgeFilter(active_bridge); active_bridge->begin(); return active_bridge->isRunning(); +#endif #endif } diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 0040cc5f..5bbe531f 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -333,6 +333,11 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { // MQTT bridge: always feed RX packets - bridge decides based on mqtt.rx setting if (_prefs.bridge_enabled && bridge) bridge->onPacketReceived(pkt); #endif +#ifdef WITH_ESPNOW_BRIDGE + if (_prefs.bridge_pkt_src == 1 && espnow_bridge.isRunning()) { + espnow_bridge.sendPacket(pkt); + } +#endif if (_logging) { File f = openAppend(PACKET_LOG_FILE); @@ -370,6 +375,11 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { // MQTT bridge: always feed TX packets - bridge decides based on mqtt.tx setting if (_prefs.bridge_enabled && bridge) bridge->sendPacket(pkt); #endif +#ifdef WITH_ESPNOW_BRIDGE + if (_prefs.bridge_pkt_src == 0 && espnow_bridge.isRunning()) { + espnow_bridge.sendPacket(pkt); + } +#endif if (_logging) { File f = openAppend(PACKET_LOG_FILE); @@ -1231,6 +1241,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc telemetry(MAX_PACKET_PAYLOAD - 4) #ifdef WITH_MQTT_BRIDGE , bridge(nullptr) + #if defined(WITH_ESPNOW_BRIDGE) + , espnow_bridge(&_prefs, _mgr, &rtc) + , shared_espnow_retry_at(0) + #endif #endif { last_millis = 0; @@ -1437,6 +1451,9 @@ void MyMesh::begin(FILESYSTEM *fs) { configureBridgeFilter(bridge); bridge->begin(); +#if defined(WITH_ESPNOW_BRIDGE) + startSharedEspNowBridgeIfReady(); +#endif } } #endif @@ -1455,6 +1472,9 @@ void MyMesh::begin(FILESYSTEM *fs) { #ifdef WITH_MQTT_BRIDGE start_webui = start_webui || _cli.getObserverPrefs()->wifi_ssid[0] == 0; if (start_webui && _cli.getObserverPrefs()->wifi_ssid[0] == 0) { +#if defined(WITH_ESPNOW_BRIDGE) + if (espnow_bridge.isRunning()) espnow_bridge.end(); +#endif if (bridge && bridge->isRunning()) bridge->end(); } #endif @@ -2519,6 +2539,10 @@ void MyMesh::loop() { #ifdef WITH_MQTT_BRIDGE // bridge.loop() is now handled by FreeRTOS task on Core 0 - no need to call it here #endif +#ifdef WITH_ESPNOW_BRIDGE + if (espnow_bridge.isRunning()) espnow_bridge.loop(); + else startSharedEspNowBridgeIfReady(); +#endif if (millisHasNowPassed(next_push) && acl.getNumClients() > 0) { // check for ACK timeouts @@ -2876,6 +2900,9 @@ bool MyMesh::hasPendingWork() const { #endif #if defined(WITH_BRIDGE) if (bridge && bridge->isRunning()) return true; // bridge needs WiFi radio, can't sleep +#if defined(WITH_ESPNOW_BRIDGE) + if (espnow_bridge.isRunning()) return true; +#endif #endif if (radio_driver.isWatchdogObserving()) return true; // keep MCU awake for one radio duty cycle if (radio_driver.isCalibratingNoiseFloor()) return true; // keep MCU awake for the noise-floor window diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 837ba17e..0828ad85 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -67,6 +67,11 @@ #include "helpers/esp32/WebConfigServer.h" // defines WITH_WEBCONFIG on ESP32 #endif +#ifdef WITH_ESPNOW_BRIDGE +#include "helpers/bridges/ESPNowBridge.h" +#define WITH_BRIDGE +#endif + #ifdef WITH_SNMP #include "helpers/SNMPAgent.h" #endif @@ -294,6 +299,10 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, #endif #ifdef WITH_MQTT_BRIDGE MQTTBridge* bridge; + #if defined(WITH_ESPNOW_BRIDGE) + ESPNowBridge espnow_bridge; + uint32_t shared_espnow_retry_at = 0; + #endif #endif #ifdef WITH_SNMP MeshSNMPAgent _snmp_agent; @@ -550,8 +559,26 @@ public: #if defined(WITH_BRIDGE) +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + // MQTT's WiFi task associates asynchronously. ESP-NOW must wait for that + // station interface so it can share the AP-selected channel safely. + bool startSharedEspNowBridgeIfReady() { + if (espnow_bridge.isRunning()) return true; + if (!bridge || !bridge->isRunning() || !WiFi.isConnected()) return false; + if (!millisHasNowPassed(shared_espnow_retry_at)) return false; + shared_espnow_retry_at = millis() + 5000; + configureBridgeFilter(&espnow_bridge); + espnow_bridge.begin(); + return espnow_bridge.isRunning(); + } +#endif + bool isBridgeRunning() const override { +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + return (bridge && bridge->isRunning()) || espnow_bridge.isRunning(); +#else return bridge != nullptr && bridge->isRunning(); +#endif } @@ -566,6 +593,55 @@ public: } bool setBridgeState(bool enable) override { +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) + if (!enable && !bridge && !espnow_bridge.isRunning()) return true; + if (!bridge) { + MQTTNodeInfo node_info; + node_info.node_name = _prefs.node_name; + node_info.freq = &_prefs.freq; + node_info.bw = &_prefs.bw; + node_info.sf = &_prefs.sf; + node_info.cr = &_prefs.cr; + node_info.repeat_flag = &_prefs.disable_fwd; + node_info.repeat_when_nonzero = false; + bridge = new MQTTBridge(node_info, _cli.getObserverPrefs(), + getRTCClock(), &self_id); + if (!bridge) return false; + } + if (enable) { + if (!bridge->isRunning()) { + char device_id[65]; + mesh::LocalIdentity self_id = getSelfId(); + mesh::Utils::toHex(device_id, self_id.pub_key, PUB_KEY_SIZE); + bridge->setDeviceID(device_id); + bridge->setFirmwareVersion(getFirmwareVer()); + bridge->setBoardModel(_cli.getBoard()->getManufacturerName()); + bridge->setBuildDate(getBuildDate()); + bridge->setStatsSources(this, _radio, _cli.getBoard(), _ms); +#ifdef WITH_SNMP + if (_cli.getObserverPrefs()->snmp_enabled) { + _snmp_agent.setNodeName(_prefs.node_name); + _snmp_agent.setFirmwareVersion(getFirmwareVer()); + bridge->setSNMPAgent(&_snmp_agent); + } +#endif + configureBridgeFilter(bridge); + bridge->begin(); + _alerter.setBridge(bridge); + } + if (!bridge->isRunning()) return false; + shared_espnow_retry_at = 0; + startSharedEspNowBridgeIfReady(); + // The MQTT task owns association. ESP-NOW starts after it sees the + // active AP channel and accepts the configured bridge channel. + return true; + } + if (espnow_bridge.isRunning()) espnow_bridge.end(); + if (bridge->isRunning()) bridge->end(); + shared_espnow_retry_at = 0; + _alerter.setBridge(nullptr); + return !bridge->isRunning() && !espnow_bridge.isRunning(); +#else // An absent MQTT bridge is already stopped. Do not allocate one solely to // satisfy an idempotent disable request. if (!enable && !bridge) return true; @@ -618,9 +694,23 @@ public: #endif } return enable ? bridge->isRunning() : !bridge->isRunning(); +#endif } bool restartBridge() override { +#if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) +#ifdef WITH_WEBCONFIG + if (_wc_batch_active) { + _wc_restart_pending = true; + return true; + } +#endif + if (espnow_bridge.isRunning()) espnow_bridge.end(); + if (bridge && bridge->isRunning()) bridge->end(); + shared_espnow_retry_at = 0; + _alerter.setBridge(nullptr); + return setBridgeState(true); +#else if (!bridge) return false; #ifdef WITH_WEBCONFIG if (_wc_batch_active) { // coalesced: applied once in onConfigBatchEnd() @@ -642,6 +732,7 @@ public: configureBridgeFilter(bridge); bridge->begin(); return bridge->isRunning(); +#endif } void restartBridgeSlot(int slot) override { diff --git a/scripts/generate_picker_controls.py b/scripts/generate_picker_controls.py index 3961233a..1eaee606 100644 --- a/scripts/generate_picker_controls.py +++ b/scripts/generate_picker_controls.py @@ -90,7 +90,10 @@ def generate(stage, config): 'webconfig': 'web.webconfig' in caps, 'mqtt': enabled('WITH_MQTT_BRIDGE'), 'rs232': enabled('WITH_RS232_BRIDGE'), - 'espnowBridge': enabled('WITH_ESPNOW_BRIDGE'), + # Expanded ESP32 Full observer builds add ESP-NOW at build + # time, so it is recorded in the qualified manifest instead + # of the base PlatformIO environment's static flags. + 'espnowBridge': enabled('WITH_ESPNOW_BRIDGE') or 'bridge.espnow' in caps, 'primaryEspnow': enabled('MESH_PRIMARY_ESPNOW'), 'snmp': enabled('WITH_SNMP'), 'updateMethods': manifest.get('ota_update_methods', []), diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 9178cf80..292e43fd 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -695,6 +695,28 @@ for logging_target in Station_G2_repeater_observer_mqtt \ verify_full_logging_contract "$logging_target" 1 off 1 yes yes verify_full_logging_contract "$logging_target" 0 "" 0 no no done + +# Expanded ESP32 infrastructure Full images keep the MQTT observer recipe and +# add the cooperative ESP-NOW bridge source. This is build-time composition, +# not a second standalone image selected by the filename. +ESP32_FULL_BUILD=1 +PLATFORMIO_BUILD_FLAGS="" +PLATFORMIO_BUILD_SRC_FILTER="" +BUILD_CAPABILITIES=() +apply_esp32_full_shared_bridge_profile Station_G2_repeater_observer_mqtt +[[ "$PLATFORMIO_BUILD_FLAGS" == *"-DWITH_ESPNOW_BRIDGE=1"* ]] \ + || fail "Station G2 Full MQTT image did not enable ESP-NOW" +[[ "$PLATFORMIO_BUILD_SRC_FILTER" == *"helpers/bridges/ESPNowBridge.cpp"* ]] \ + || fail "Station G2 Full MQTT image did not include the ESP-NOW bridge" +[[ " ${BUILD_CAPABILITIES[*]} " == *" bridge.espnow "* ]] \ + || fail "Station G2 Full MQTT image did not report ESP-NOW capability" +[[ "$(get_unified_full_infrastructure_target Station_G2_repeater_bridge_espnow)" \ + = "Station_G2_repeater_observer_mqtt" ]] \ + || fail "Station G2 Full ESP-NOW target did not resolve to the combined MQTT recipe" +ESP32_FULL_BUILD=0 +PLATFORMIO_BUILD_FLAGS="" +PLATFORMIO_BUILD_SRC_FILTER="" + for logging_target in Station_G2_repeater_bridge_espnow heltec_v4_sensor; do verify_full_logging_contract "$logging_target" 1 "" 0 yes no verify_full_logging_contract "$logging_target" 1 on 1 yes no diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 61d1e627..d6b8daf4 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1003,6 +1003,18 @@ for (const profile of capacityProfiles) { const currentObserver = currentCatalog.profiles.find(profile => profile.target === observer.target); assert.deepStrictEqual(picker.runtimeDirections(currentObserver, {logging: 'usb'})[0].actions[0].commands, ['set logging.output usb', 'get logging.output']); +const combinedControlData = JSON.parse(JSON.stringify(currentControls)); +combinedControlData.profiles[currentObserver.target].espnowBridge = true; +const combinedCatalog = picker.buildCatalog([ + release(currentControls.familyTag, '2026-09-13T00:00:00Z', currentAssets), +], combinedControlData); +const combinedObserver = combinedCatalog.profiles.find(profile => profile.target === currentObserver.target); +assert.deepStrictEqual(picker.profileFieldValues(combinedObserver, 'mode'), ['standard', 'espnow']); +assert(picker.profileMatches(combinedObserver, {mode: 'espnow'}, ['mode'])); +const combinedDirections = picker.runtimeDirections(combinedObserver, {logging: 'usb'}); +assert(combinedDirections.some(section => section.title === 'MQTT + ESP-NOW bridge')); +assert(!combinedDirections.some(section => section.title === 'ESP-NOW bridge')); +assert.strictEqual(commands(combinedDirections).filter(command => command === 'set mqtt.enabled on').length, 1); console.log('current release metadata and capacity directions tests passed'); assert.strictEqual(nrf.chipFamily, 'nrf52');