From e9a2326c4f0a105478ced7502fb485927c0591bf Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 15 Sep 2026 00:01:45 -0700 Subject: [PATCH] Add WiFi, ESP-NOW and combined wireless CLI controls --- .github/workflows/run-unit-tests.yml | 5 + docs/cli_commands.md | 54 ++++ examples/InfrastructureWireless.h | 132 ++++++++ examples/companion_radio/CompanionWireless.h | 7 + examples/companion_radio/MyMesh.cpp | 12 +- examples/companion_radio/main.cpp | 145 ++++++++- examples/simple_repeater/MyMesh.cpp | 7 + examples/simple_repeater/MyMesh.h | 11 + examples/simple_repeater/main.cpp | 6 +- examples/simple_room_server/MyMesh.h | 16 + examples/simple_room_server/main.cpp | 10 +- src/helpers/CommonCLI.cpp | 2 + src/helpers/CommonCLI.h | 9 + src/helpers/MultiSerialInterface.h | 2 + src/helpers/WiFiSetupPortal.cpp | 2 + src/helpers/WirelessControl.h | 169 +++++++++++ src/helpers/bridges/ESPNowBridge.cpp | 54 ++-- src/helpers/bridges/MQTTBridge.cpp | 4 + src/helpers/esp32/ESPNOWRadio.cpp | 72 ++++- src/helpers/esp32/ESPNOWRadio.h | 9 +- src/helpers/esp32/WebConfigServer.cpp | 32 +- src/helpers/esp32/WiFiRadioPolicy.h | 24 +- src/helpers/esp32/WiFiStationPolicy.h | 10 +- test/test_espnow_radio_lifecycle.py | 279 +++++++++++++++++ test/test_wireless_control.py | 299 +++++++++++++++++++ 25 files changed, 1311 insertions(+), 61 deletions(-) create mode 100644 examples/InfrastructureWireless.h create mode 100644 examples/companion_radio/CompanionWireless.h create mode 100644 src/helpers/WirelessControl.h create mode 100644 test/test_espnow_radio_lifecycle.py create mode 100644 test/test_wireless_control.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index d33659259..c837ba02e 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -128,6 +128,11 @@ jobs: - name: Verify Companion Bluetooth CLI connection protection run: python3 -B test/test_companion_bluetooth_control.py -v + - name: Verify WiFi, ESP-NOW and combined wireless controls + run: | + python3 -B test/test_wireless_control.py -v + python3 -B test/test_espnow_radio_lifecycle.py -v + - name: Verify message reader buttons, touch targets, and footer layouts working-directory: test run: >- diff --git a/docs/cli_commands.md b/docs/cli_commands.md index d29234057..b1fb7bddf 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1410,6 +1410,60 @@ Station G2/G3 targets default to `off`. --- +#### Turn WiFi, ESP-NOW, or all 2.4 GHz services on or off + +```text +get wifi +set wifi off +set wifi on +get espnow +set espnow off +set espnow on +get 2.4ghz +set 2.4ghz off +set 2.4ghz on +set 2.4ghz on all +``` + +| Control | Effect | +| --- | --- | +| `wifi` | Infrastructure WiFi, setup AP, TCP/WebUI, and WiFi MQTT services. ESP-NOW remains available. | +| `espnow` | The ESP-NOW bridge or primary ESP-NOW mesh transport in this build. Infrastructure WiFi remains available. | +| `2.4ghz off` | Stop WiFi, Bluetooth, and ESP-NOW; remember which were enabled. LoRa and wired transports remain available. | +| `2.4ghz on` | Restore the states saved by the first `off`. Repeated `off` preserves that snapshot. With no previous `off`, keep the current states. | +| `2.4ghz on all` | Enable every service available in this build and boot. `2.4ghz on force` is an alias. | + +These commands work in Companion, repeater, and room-server firmware where the +services are available. Changes last until reboot; saved WiFi credentials, +Bluetooth preferences, and bridge settings stay intact. `get 2.4ghz` lists each +service as `on`, `off`, or `unavailable`. An nRF52 Companion controls its Bluetooth +and reports WiFi/ESP-NOW as unavailable. Exclusive WiFi/Bluetooth builds retain +their boot transport restriction; `on all` cannot reclaim a released Bluetooth stack. + +An `off` command requires a management connection that will survive it. Bluetooth +can provide the fallback for `wifi off`; USB, Ethernet, or a CLI request over LoRa +can provide it for `2.4ghz off`. WiFi association, a listening socket, and USB power +alone do not count. On USB hardware without an observable open-client session, +send the command through USB itself. To bypass the guard: + +```text +set wifi off force +set espnow off force +set 2.4ghz off force +``` + +Shutdown starts after the reply has had time to drain (normally 250 ms, up to two +seconds for pending transport I/O). Network services stop before the shared WiFi +driver. A normal shutdown is cancelled if its fallback connection disappears +before it starts. The `get` commands report pending changes and startup/teardown +failures. After `2.4ghz off`, use `2.4ghz on` or `on all` before enabling an +individual service. The `all`/`force` suffix on **on** applies only to `2.4ghz`. + +WiFi and ESP-NOW share one hardware channel. A running ESP-NOW bridge pins setup +and station connections to `bridge.channel`; starting the bridge alongside an +existing WiFi connection requires that same channel. A mismatch reports a failed +start and preserves the WiFi connection. + #### Turn Bluetooth on or off (Companion) ```text diff --git a/examples/InfrastructureWireless.h b/examples/InfrastructureWireless.h new file mode 100644 index 000000000..7cc1a2241 --- /dev/null +++ b/examples/InfrastructureWireless.h @@ -0,0 +1,132 @@ +#pragma once + +// Included by the infrastructure main files after the mesh and transport objects. +#include +#if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE) || (defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW)) +#include +#include +#endif + +class InfrastructureWirelessBackend : public mesh::wireless::Backend { + bool wifi_saved_ = false, resume_web_ = false, resume_mqtt_ = false; +public: + uint8_t available() const override { + uint8_t mask = 0; +#if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE)) + mask |= mesh::wireless::WiFi; +#endif +#if defined(WITH_ESPNOW_BRIDGE) || (defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW) + mask |= mesh::wireless::EspNow; +#endif + return mask; + } + uint8_t enabled() const override { + uint8_t mask = 0; +#ifdef WITH_WEBCONFIG + if (the_mesh.isWebConfigActive()) mask |= mesh::wireless::WiFi; +#endif +#ifdef WITH_MQTT_BRIDGE + if (the_mesh.isBridgeRunning()) mask |= mesh::wireless::WiFi; +#endif +#ifdef WITH_ESPNOW_BRIDGE + if (the_mesh.isBridgeRunning()) mask |= mesh::wireless::EspNow; +#endif +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + if (radio_driver.isEnabled()) mask |= mesh::wireless::EspNow; +#endif + return mask; + } + uint8_t clients() const override { + uint8_t mask = 0; +#ifdef ETHERNET_ENABLED + if (ethernet_client.connected()) mask |= mesh::wireless::Independent; +#endif +#ifdef WITH_WEBCONFIG + if (the_mesh.hasWirelessNetworkClient()) mask |= mesh::wireless::WiFi; +#endif +#if defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \ + || (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \ + && (!defined(ARDUINO_USB_MODE) || ARDUINO_USB_MODE == 0)) + if (board.isUsbDataConnected()) mask |= mesh::wireless::Independent; +#endif + return mask; + } + mesh::wireless::Result set(uint8_t service, bool on) override { + using mesh::wireless::Result; +#if defined(ESP32) && (defined(WITH_WEBCONFIG) || defined(WITH_MQTT_BRIDGE)) + if (service == mesh::wireless::WiFi) { + if (!on) { + if (!wifi_saved_) { +#ifdef WITH_WEBCONFIG + resume_web_ = the_mesh.isWebConfigActive(); +#endif +#ifdef WITH_MQTT_BRIDGE + resume_mqtt_ = the_mesh.isBridgeRunning(); +#endif + wifi_saved_ = true; + } +#ifdef WITH_MQTT_BRIDGE + if (!the_mesh.setBridgeState(false)) return Result::Failed; +#endif +#ifdef WITH_WEBCONFIG + if (the_mesh.isWebConfigActive()) { + char reply[160]; + the_mesh.stopWebConfig(reply); + return Result::Pending; + } +#endif + WiFi.setAutoReconnect(false); + if (enabled() & mesh::wireless::EspNow) { + WiFi.disconnect(false, false); + WiFi.mode(WIFI_STA); + mesh::wifi::applyProtocolMask(WIFI_IF_STA); + mesh::wifi::restoreEspNowChannel(); + } else + { + WiFi.disconnect(true, false); + WiFi.mode(WIFI_OFF); + } + return Result::Done; + } +#ifdef WITH_WEBCONFIG + if (the_mesh.isWebConfigStopping()) return Result::Pending; + if (resume_web_ || (!wifi_saved_ && !(enabled() & mesh::wireless::WiFi)) + || (wifi_saved_ && !resume_web_ && !resume_mqtt_)) { + char reply[160]; + if (!the_mesh.startWebConfig(false, reply)) return Result::Failed; + } +#endif +#ifdef WITH_MQTT_BRIDGE + if (resume_mqtt_ +#ifndef WITH_WEBCONFIG + || !(enabled() & mesh::wireless::WiFi) +#endif + ) { + if (!the_mesh.setBridgeState(true)) return Result::Failed; + } +#endif + wifi_saved_ = resume_web_ = resume_mqtt_ = false; + return (enabled() & mesh::wireless::WiFi) ? Result::Done : Result::Failed; + } +#endif +#ifdef WITH_ESPNOW_BRIDGE + if (service == mesh::wireless::EspNow) + return the_mesh.setBridgeState(on) ? Result::Done : Result::Failed; +#endif +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + if (service == mesh::wireless::EspNow) { + if (on) radio_driver.init(); + else { + radio_driver.end(); + if (!(enabled() & mesh::wireless::WiFi)) { + WiFi.setAutoReconnect(false); + WiFi.mode(WIFI_OFF); + } + } + return radio_driver.isEnabled() == on ? Result::Done : Result::Failed; + } +#endif + return Result::Failed; + } +}; +static InfrastructureWirelessBackend infrastructure_wireless; diff --git a/examples/companion_radio/CompanionWireless.h b/examples/companion_radio/CompanionWireless.h new file mode 100644 index 000000000..2cc715b15 --- /dev/null +++ b/examples/companion_radio/CompanionWireless.h @@ -0,0 +1,7 @@ +#pragma once +#include +#include + +enum class CompanionWirelessSource { Other, Usb, Network, Framed }; +bool handleCompanionWirelessCommand(const char* command, char* reply, size_t size, + CompanionWirelessSource source = CompanionWirelessSource::Other); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index ad62bd72a..da941beb2 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,6 +1,7 @@ #include #include "MyMesh.h" #include "CompanionBluetooth.h" +#include "CompanionWireless.h" #if defined(MESH_SOAK_DIAGNOSTICS) #include "../../tools/hil/S3SoakDiagnostics.h" #endif @@ -2303,6 +2304,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, while (*command == ' ') command++; if (handleCompanionBluetoothCommand(command, reply, reply_size)) return true; + if (handleCompanionWirelessCommand(command, reply, reply_size)) return true; if (strcmp(command, "get powersaving") == 0 || strcmp(command, "powersaving") == 0) { snprintf(reply, reply_size, "> %s", _prefs.powersaving_enabled ? "on" : "off"); @@ -5334,7 +5336,8 @@ void MyMesh::handleCmdFrame(size_t len) { char command[MAX_FRAME_SIZE] = {0}; memcpy(command, &cmd_frame[1], command_len); char reply[MAX_FRAME_SIZE] = {0}; - if (!handleCompanionBluetoothCommand(command, reply, sizeof(reply), + if (!handleCompanionWirelessCommand(command, reply, sizeof(reply), CompanionWirelessSource::Framed) + && !handleCompanionBluetoothCommand(command, reply, sizeof(reply), CompanionBluetoothCommandSource::Framed) && !handleLocalControlCommand(command, reply, sizeof(reply))) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); @@ -7497,7 +7500,9 @@ void MyMesh::handleTerminalCommand(char* command) { #endif char local_reply[160]; - if (handleCompanionBluetoothCommand(command, local_reply, sizeof(local_reply), + if (handleCompanionWirelessCommand(command, local_reply, sizeof(local_reply), + _terminal_mode ? CompanionWirelessSource::Usb : CompanionWirelessSource::Network) + || handleCompanionBluetoothCommand(command, local_reply, sizeof(local_reply), CompanionBluetoothCommandSource::Terminal)) { terminalOutput().printf(" %s\r\n", local_reply); return; @@ -7990,6 +7995,9 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" get display.touch\r\n"); terminalOutput().print(" set display.touch (this boot only)\r\n"); terminalOutput().print(" set {name|lat|lon|freq|tx|af} {value}\r\n"); + terminalOutput().print(" get wifi|espnow|2.4ghz\r\n"); + terminalOutput().print(" set wifi|espnow on|off [force] (force only with off)\r\n"); + terminalOutput().print(" set 2.4ghz on [all|force]|off [force] (this boot)\r\n"); terminalOutput().print(" get bluetooth.name\r\n"); terminalOutput().print(" set bluetooth.name \r\n"); #if defined(BLE_PIN_CODE) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 64b6c544b..aff450b6e 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -3,6 +3,7 @@ #include #include "MyMesh.h" #include "CompanionBluetooth.h" +#include "CompanionWireless.h" #include "CompanionWiFi.h" #if MESH_PACKET_LOGGING #include @@ -1490,6 +1491,7 @@ void halt() { #endif void requestCompanionWiFiSetup() { + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return; companion_wifi_setup_stop_requested = false; companion_wifi_setup_requested = true; } @@ -1500,6 +1502,7 @@ void halt() { } bool toggleCompanionWiFi() { + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return false; #if defined(COMPANION_EXCLUSIVE_WIFI_BLE) const CompanionTransportMode current = getCompanionTransportMode(); const CompanionTransportMode selected = @@ -1798,7 +1801,8 @@ void halt() { ota_console_client.print("> "); #else char reply[160]; reply[0] = 0; - if (!handleCompanionBluetoothCommand(ota_console_line, reply, sizeof(reply), + if (!handleCompanionWirelessCommand(ota_console_line, reply, sizeof(reply), CompanionWirelessSource::Network) + && !handleCompanionBluetoothCommand(ota_console_line, reply, sizeof(reply), CompanionBluetoothCommandSource::Terminal) && !the_mesh.handleLocalCommand(ota_console_line, reply) && !mesh::ota::handle_ota_command(ota_console_line, reply, board)) @@ -1853,6 +1857,7 @@ void halt() { } static void startCompanionWiFi() { + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return; if (companion_wifi_active) return; board.setInhibitSleep(true); @@ -2326,6 +2331,10 @@ bool handleCompanionBluetoothCommand(const char* command, char* reply, return true; } if (action == CompanionBluetoothCommand::On) { + if (!mesh::wireless::control().allowService(mesh::wireless::Bluetooth)) { + snprintf(reply, reply_size, "Error: 2.4ghz is off or changing; use set 2.4ghz on first"); + return true; + } companion_bluetooth_off_at = 0; interface_manager.enableBluetooth(); snprintf(reply, reply_size, "%s", interface_manager.isBluetoothEnabled() @@ -2375,7 +2384,137 @@ bool handleCompanionBluetoothCommand(const char* command, char* reply, } #endif +class CompanionWirelessBackend : public mesh::wireless::Backend { +public: + uint8_t available() const override { + uint8_t mask = 0; +#if defined(ESP32) && defined(WIFI_SSID) +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (companionTransportWiFiActiveAtBoot()) +#endif + mask |= mesh::wireless::WiFi; +#endif +#if defined(BLE_PIN_CODE) + if (companion_bluetooth_initialized) mask |= mesh::wireless::Bluetooth; +#endif +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + mask |= mesh::wireless::EspNow; +#endif + return mask; + } + uint8_t enabled() const override { + uint8_t mask = 0; +#if defined(ESP32) && defined(WIFI_SSID) + if (companion_wifi_requested || companion_wifi_active) mask |= mesh::wireless::WiFi; +#ifdef WITH_WEBCONFIG + if (the_mesh.isWebConfigActiveOrStopping()) mask |= mesh::wireless::WiFi; +#endif +#endif +#if defined(BLE_PIN_CODE) + if (interface_manager.isBluetoothEnabled()) mask |= mesh::wireless::Bluetooth; +#endif +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + if (radio_driver.isEnabled()) mask |= mesh::wireless::EspNow; +#endif + return mask; + } + uint8_t clients() const override { + using namespace mesh::wireless; + if (!interface_manager.isEnabled()) return 0; + uint8_t mask = 0; + if (interface_manager.isInterfaceConnected(InterfaceType::Bluetooth)) mask |= Bluetooth; + if (interface_manager.isInterfaceConnected(InterfaceType::WiFi)) mask |= mesh::wireless::WiFi; + if (interface_manager.isInterfaceConnected(InterfaceType::Ethernet)) mask |= Independent; +#if COMPANION_FEATURE_TEXT_TERMINAL && (COMPANION_FEATURE_NETWORK_TERMINAL || defined(WITH_WEBCONFIG)) + if (the_mesh.isAnyNetworkTerminalMode()) mask |= mesh::wireless::WiFi; +#endif +#if defined(ENABLE_USB_INTERFACE) \ + && (defined(NRF52_PLATFORM) || defined(RP2040_PLATFORM) \ + || (defined(ESP32) && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT \ + && (!defined(ARDUINO_USB_MODE) || ARDUINO_USB_MODE == 0))) +#if COMPANION_FEATURE_USB_MOTA_SOURCE + if (!usb_mota_mode) +#endif + if (usb_serial_interface.isEnabled() && isUsbTerminalDataConnected()) mask |= Independent; +#endif + return mask; + } + bool replyBusy() const override { + return interface_manager.hasPendingIO(); + } + mesh::wireless::Result set(uint8_t service, bool on) override { + using namespace mesh::wireless; +#if defined(BLE_PIN_CODE) + if (service == Bluetooth) { + companion_bluetooth_off_at = 0; + if (on) interface_manager.enableBluetooth(); + else disableCompanionBluetoothForCli(); + return interface_manager.isBluetoothEnabled() == on ? Result::Done : Result::Failed; + } +#endif +#if defined(ESP32) && defined(WIFI_SSID) + if (service == mesh::wireless::WiFi) { + companion_wifi_requested = on; + if (!on) { + companion_wifi_setup_requested = false; + if (!finishStoppingCompanionWiFi()) return Result::Pending; + companion_wifi_disable_in_progress = false; +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + if (!radio_driver.isEnabled()) ::WiFi.mode(WIFI_OFF); +#endif + } else { + // Finish a previously requested teardown before opening new sockets. + if (companion_wifi_disable_in_progress) { + if (!finishStoppingCompanionWiFi()) return Result::Pending; + companion_wifi_disable_in_progress = false; + } + startCompanionWiFi(); + } + return companion_wifi_active == on ? Result::Done : Result::Failed; + } +#endif +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + if (service == EspNow) { + if (on) radio_driver.init(); + else { + radio_driver.end(); + if (!(enabled() & mesh::wireless::WiFi)) { + ::WiFi.setAutoReconnect(false); + ::WiFi.mode(WIFI_OFF); + } + } + return radio_driver.isEnabled() == on ? Result::Done : Result::Failed; + } +#endif + return Result::Failed; + } +}; +static CompanionWirelessBackend companion_wireless; + +bool handleCompanionWirelessCommand(const char* command, char* reply, size_t size, + CompanionWirelessSource source) { + uint8_t requester = 0; + if (source == CompanionWirelessSource::Usb) requester = mesh::wireless::Independent; + else if (source == CompanionWirelessSource::Network) requester = mesh::wireless::WiFi; + else if (source == CompanionWirelessSource::Framed) { + BaseSerialInterface* route = interface_manager.captureReplyRoute(); + if (interface_manager.isReplyRouteAvailable(route)) { +#if defined(BLE_PIN_CODE) + if (route == &bluetooth_interface) requester = mesh::wireless::Bluetooth; + else +#endif +#if defined(ESP32) && defined(WIFI_SSID) + if (route == &wifi_interface) requester = mesh::wireless::WiFi; + else +#endif + requester = mesh::wireless::Independent; + } + } + return mesh::wireless::control().handle(command, reply, size, millis(), requester); +} + void setup() { + mesh::wireless::control().begin(companion_wireless); mesh::prepareUsbLoggingPort(); Serial.begin(115200); #if MESH_PACKET_LOGGING @@ -2776,6 +2915,7 @@ void setup() { } void loop() { + mesh::wireless::control().service(millis()); #if defined(NRF52_PLATFORM) board.feedWatchdog(); #endif @@ -2851,7 +2991,8 @@ void loop() { // USB power alone (for example, a wall charger) does not inhibit sleep. // Host sessions, live logging, and button activity still need service. bool can_sleep = the_mesh.getNodePrefs()->powersaving_enabled - && !the_mesh.hasPendingWork(); + && !the_mesh.hasPendingWork() + && !mesh::wireless::control().pending(); #if defined(ESP32_PLATFORM) && MESH_USB_LOGGING_AVAILABLE // The native-USB-only light-sleep path below bypasses ESP32Board::sleep. can_sleep = can_sleep && !mesh::isUsbLoggingEnabled(); diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e67e666b2..2b40c1794 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4019,6 +4019,12 @@ void MyMesh::checkBatteryAlert() { } void MyMesh::checkRxInactivityWatchdog() { +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + if (mesh::wireless::control().blocked(mesh::wireless::EspNow)) { + rx_inactivity_watchdog.reset(); + return; + } +#endif const uint32_t interval = radio_timing.watchdogMillis(_prefs.rx_watchdog_enabled); if (rx_inactivity_watchdog.expired(millis(), getLastMeshCoreRecvMillis(), interval)) { MESH_DEBUG_PRINTLN("RX watchdog: no MeshCore packet received in %lu hours, rebooting", @@ -11172,6 +11178,7 @@ bool MyMesh::handleReplayResetCommand(ClientInfo* sender, const char* command, void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *command, char *reply, int gpio_client_index, uint8_t gpio_path_hash_size, bool usb_origin) { + _wireless_usb_command = usb_origin; #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) _gpio_reply_tracker.beginCommand(gpio_client_index, gpio_path_hash_size, sender == NULL ? NULL : sender->id.pub_key); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index b6cc78f4f..06e2b6f26 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -1077,6 +1077,15 @@ public: void handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char* command, char* reply, int gpio_client_index = -1, uint8_t gpio_path_hash_size = 1, bool usb_origin = false); + bool _wireless_usb_command = false; + uint8_t wirelessCommandSource(uint32_t sender_timestamp) const override { + if (sender_timestamp) return CommonCLICallbacks::wirelessCommandSource(sender_timestamp); + if (_wireless_usb_command) return mesh::wireless::Independent; +#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED) + if (_command_output && _command_output != _web_terminal) return mesh::wireless::Independent; +#endif + return mesh::wireless::WiFi; + } void handleCommand(uint32_t sender_timestamp, char* command, char* reply) { handleCommand(sender_timestamp, NULL, command, reply); } @@ -1398,6 +1407,8 @@ public: #ifdef WITH_WEBCONFIG bool startWebConfig(bool force_ap, char* reply) override; + bool isWebConfigStopping() const { return _webconfig && _webconfig->isStopping(); } + bool hasWirelessNetworkClient() const { return _web_terminal != nullptr; } bool stopWebConfig(char* reply) override; bool setWebUIEnabled(bool enabled, char* reply) override; bool getWebUIStatus(char* reply) const override; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 4d717121e..a6b8ac30f 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -61,6 +61,7 @@ SimpleMeshTables tables; #endif MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); +#include "../InfrastructureWireless.h" void halt() { while (1) ; @@ -87,6 +88,7 @@ static unsigned long userBtnDownAt = 0; #endif void setup() { + mesh::wireless::control().begin(infrastructure_wireless); mesh::prepareUsbLoggingPort(); Serial.begin(115200); #if MESH_ESP32_USB_CONSOLE_COOPERATIVE @@ -351,6 +353,7 @@ static void __attribute__((noinline)) serviceCommandInterfaces() { } void loop() { + mesh::wireless::control().service(millis()); #if defined(NRF52_PLATFORM) board.feedWatchdog(the_mesh.getNodePrefs()->system_watchdog_enabled != 0); #endif @@ -389,7 +392,8 @@ void loop() { external_watchdog.loop(); #endif bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled - && !board.isUsbDataConnected(); + && !board.isUsbDataConnected() + && !mesh::wireless::control().pending(); #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index b719e246d..de00c93ad 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -525,6 +525,20 @@ public: void handleCommand(uint32_t sender_timestamp, char* command, char* reply, int gpio_client_index = -1, uint8_t gpio_path_hash_size = 1); + bool _wireless_usb_command = false; + void handleUsbCommand(char* command, char* reply) { + _wireless_usb_command = true; + handleCommand(0, command, reply); + _wireless_usb_command = false; + } + uint8_t wirelessCommandSource(uint32_t sender_timestamp) const override { + if (sender_timestamp) return CommonCLICallbacks::wirelessCommandSource(sender_timestamp); + if (_wireless_usb_command) return mesh::wireless::Independent; +#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED) + if (_command_output && _command_output != _web_terminal) return mesh::wireless::Independent; +#endif + return mesh::wireless::WiFi; + } void loop(); #if defined(WITH_BRIDGE) @@ -672,6 +686,8 @@ public: #ifdef WITH_WEBCONFIG bool startWebConfig(bool force_ap, char* reply) override; + bool isWebConfigStopping() const { return _webconfig && _webconfig->isStopping(); } + bool hasWirelessNetworkClient() const { return _web_terminal != nullptr; } bool stopWebConfig(char* reply) override; bool setWebUIEnabled(bool enabled, char* reply) override; bool getWebUIStatus(char* reply) const override; diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index abfa22393..88ab8c3e8 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -29,6 +29,7 @@ StdRNG fast_rng; SimpleMeshTables tables; MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); +#include "../InfrastructureWireless.h" void halt() { while (1) ; @@ -43,6 +44,7 @@ static char ethernet_command[MAX_POST_TEXT_LEN+1]; unsigned long POWERSAVING_FIRSTSLEEP_SECS = 120; // The first sleep (if enabled) from boot void setup() { + mesh::wireless::control().begin(infrastructure_wireless); mesh::prepareUsbLoggingPort(); Serial.begin(115200); #if MESH_ESP32_USB_CONSOLE_COOPERATIVE @@ -151,6 +153,7 @@ void setup() { } void loop() { + mesh::wireless::control().service(millis()); #if defined(NRF52_PLATFORM) board.feedWatchdog(the_mesh.getNodePrefs()->system_watchdog_enabled != 0); #endif @@ -186,10 +189,10 @@ void loop() { reply[0] = 0; #ifdef ETHERNET_ENABLED if (!ethernet_handle_command(command, reply)) { - the_mesh.handleCommand(0, command, reply); + the_mesh.handleUsbCommand(command, reply); } #else - the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial! + the_mesh.handleUsbCommand(command, reply); #endif if (reply[0]) { console.printf(" -> %s\r\n", reply); @@ -231,7 +234,8 @@ void loop() { external_watchdog.loop(); #endif bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled - && !board.isUsbDataConnected(); + && !board.isUsbDataConnected() + && !mesh::wireless::control().pending(); #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 81d724304..7c2ead1f2 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -2464,6 +2464,8 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) { void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) { mesh::cli::normalizeCommandVerb(command); + if (mesh::wireless::control().handle(command, reply, 160, millis(), + _callbacks->wirelessCommandSource(sender_timestamp))) return; if (_radio_profiles.handle(command, reply)) return; if (strncmp(command, "set tempradio ", 14) == 0) { handleCommand(sender_timestamp, command + 4, reply); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index e363c41de..d42e892e7 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -14,6 +14,7 @@ #include #include #include +#include #ifndef DEFAULT_CAD_ENABLED #define DEFAULT_CAD_ENABLED 0 @@ -607,6 +608,14 @@ public: (void)reply; return false; }; + virtual uint8_t wirelessCommandSource(uint32_t sender_timestamp) const { + if (!sender_timestamp) return 0; +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + return mesh::wireless::EspNow; +#else + return mesh::wireless::Independent; // Authenticated remote CLI over LoRa. +#endif + } virtual bool getWiFiPassword(char* reply) const { (void)reply; return false; diff --git a/src/helpers/MultiSerialInterface.h b/src/helpers/MultiSerialInterface.h index 12166793f..b2385bb00 100644 --- a/src/helpers/MultiSerialInterface.h +++ b/src/helpers/MultiSerialInterface.h @@ -2,6 +2,7 @@ #include "BaseSerialInterface.h" #include "CompanionFrameQueue.h" +#include "WirelessControl.h" #ifndef MAX_INTERFACES // ble, usb, wifi, ethernet @@ -93,6 +94,7 @@ public: } void enableBluetooth() { + if (mesh::wireless::control().blocked(mesh::wireless::Bluetooth)) return; for(auto iface : _interfaces){ if(iface.instance && iface.type == InterfaceType::Bluetooth){ iface.instance->enable(); diff --git a/src/helpers/WiFiSetupPortal.cpp b/src/helpers/WiFiSetupPortal.cpp index 83336a6b2..97ce257bc 100644 --- a/src/helpers/WiFiSetupPortal.cpp +++ b/src/helpers/WiFiSetupPortal.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include namespace { @@ -319,6 +320,7 @@ static void portalTask(void* arg) { WiFiSetupPortal::WiFiSetupPortal() : _active(false), _impl(nullptr) {} bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, void* context) { + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return false; if (_active) return true; PortalImpl* impl = static_cast(_impl); if (!impl) { diff --git a/src/helpers/WirelessControl.h b/src/helpers/WirelessControl.h new file mode 100644 index 000000000..ef032a500 --- /dev/null +++ b/src/helpers/WirelessControl.h @@ -0,0 +1,169 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { +namespace wireless { + +enum Service : uint8_t { WiFi = 1, Bluetooth = 2, EspNow = 4, All = 7, Independent = 8 }; +enum class Result { Done, Pending, Failed }; +enum class Action { None, Get, On, OnAll, Off, ForceOff, Invalid }; +struct Command { uint8_t scope; Action action; }; + +inline const char* skipSpace(const char* s) { + while (*s == ' ' || *s == '\t') ++s; + return s; +} +inline bool word(const char* s, const char* expected) { + const size_t n = strlen(expected); + return strncmp(s, expected, n) == 0 && *skipSpace(s + n) == 0; +} +inline Command parse(const char* s) { + if (!s) return {0, Action::None}; + s = skipSpace(s); + const bool get = strncmp(s, "get ", 4) == 0; + if (!get && strncmp(s, "set ", 4) != 0) return {0, Action::None}; + s = skipSpace(s + 4); + const char* key = strncmp(s, "2.4ghz", 6) == 0 ? "2.4ghz" : strncmp(s, "espnow", 6) == 0 ? "espnow" : "wifi"; + const size_t n = strlen(key); + if (strncmp(s, key, n) || (s[n] && s[n] != ' ' && s[n] != '\t')) return {0, Action::None}; + const uint8_t scope = key[0] == '2' ? All : key[0] == 'e' ? EspNow : WiFi; + s = skipSpace(s + n); + if (get) return {scope, *s ? Action::Invalid : Action::Get}; + if (word(s, "on")) return {scope, Action::On}; + if (word(s, "off")) return {scope, Action::Off}; + if (!strncmp(s, "off", 3) && (s[3] == ' ' || s[3] == '\t') + && word(skipSpace(s + 3), "force")) return {scope, Action::ForceOff}; + if (scope == All && !strncmp(s, "on", 2) && (s[2] == ' ' || s[2] == '\t')) { + s = skipSpace(s + 2); + if (word(s, "all") || word(s, "force")) return {scope, Action::OnAll}; + } + return {scope, Action::Invalid}; +} + +class Backend { +public: + virtual uint8_t available() const = 0; // Services usable in this boot. + virtual uint8_t enabled() const = 0; + virtual uint8_t clients() const = 0; // Open management sessions, not association/power. + virtual bool replyBusy() const { return false; } + virtual Result set(uint8_t service, bool on) = 0; +}; + +class Control { + Backend* backend_ = nullptr; + std::atomic blocked_{0}; + uint8_t scope_ = 0, target_ = 0, saved_ = 0; + bool pending_ = false, started_ = false, force_ = false, retained_requester_ = false; + bool saved_valid_ = false, master_off_ = false; + uint32_t due_ = 0; + const char* error_ = nullptr; + +public: + void begin(Backend& backend) { backend_ = &backend; } + bool blocked(uint8_t service) const { return (blocked_.load() & service) != 0; } + bool pending() const { return pending_; } + bool masterOff() const { return master_off_; } + bool allowService(uint8_t service) { + if (master_off_ || pending_) return false; + blocked_.store(blocked_.load() & ~service); + return true; + } + uint8_t enabled() const { return backend_ ? backend_->enabled() : 0; } + + bool handle(const char* text, char* reply, size_t size, uint32_t now, uint8_t requester) { + const auto command = parse(text); + if (command.action == Action::None || !reply || !size) return false; + const uint8_t available = backend_ ? backend_->available() : 0; + const char* name = command.scope == All ? "2.4ghz" : command.scope == EspNow ? "espnow" : "wifi"; + if (command.action == Action::Invalid) { + snprintf(reply, size, "Error: use set wifi/espnow on|off [force]; set 2.4ghz on [all|force]|off [force]"); + return true; + } + const uint8_t current = enabled(); + if (command.action == Action::Get) { + const auto state = [=](uint8_t bit) { return !(available & bit) ? "unavailable" : current & bit ? "on" : "off"; }; + if (command.scope != All) snprintf(reply, size, "%s %s%s%s", name, state(command.scope), pending_ ? " (pending)" : "", error_ ? error_ : ""); + else snprintf(reply, size, "wifi=%s, bluetooth=%s, espnow=%s%s%s", state(WiFi), state(Bluetooth), state(EspNow), pending_ ? " (pending)" : "", error_ ? error_ : ""); + return true; + } + if (!(available & command.scope)) { + snprintf(reply, size, "Error: requested wireless service unavailable in this build/boot"); + return true; + } + const bool on = command.action == Action::On || command.action == Action::OnAll; + if (pending_ && command.scope != All && (command.scope & available) != scope_) { + snprintf(reply, size, "Error: wireless change pending; get 2.4ghz for status"); + return true; + } + if (command.scope != All && on && master_off_) { + snprintf(reply, size, "Error: 2.4ghz is off; use set 2.4ghz on first"); + return true; + } + const uint8_t scope = command.scope & available; + const bool retained = (requester & ~scope) != 0; + const bool force = command.action == Action::ForceOff; + if (!on && (current & scope) && !force && !retained && !(backend_->clients() & ~scope)) { + snprintf(reply, size, "Error: no remaining management connection; use off force or USB/Ethernet"); + return true; + } + if (command.scope == All && !on && !saved_valid_) { + saved_ = current; + saved_valid_ = true; + } + scope_ = scope; + target_ = !on ? 0 : command.scope == All && command.action != Action::OnAll + ? (saved_valid_ ? saved_ : current) & available : scope; + force_ = force; + retained_requester_ = retained; + pending_ = true; + started_ = false; + error_ = nullptr; + due_ = now + 250; + // Keep the group latch independent of the set of services available on a board. + if (command.scope == All) master_off_ = !on; + snprintf(reply, size, "OK - %s %s requested (this boot); get %s for status", + name, on ? "on" : "off", name); + return true; + } + + void service(uint32_t now) { + if (!pending_ || !backend_ || int32_t(now - due_) < 0) return; + if (!started_) { + if (!target_ && (enabled() & scope_) && !force_ && !retained_requester_ && !(backend_->clients() & ~scope_)) { + pending_ = false; + if (master_off_) { master_off_ = false; saved_valid_ = false; } + error_ = " (cancelled: connection lost)"; + return; + } + if (backend_->replyBusy() && uint32_t(now - due_) < 1750) return; + blocked_.store((blocked_.load() & ~scope_) | (scope_ & ~target_)); + started_ = true; + } + // Infrastructure services own tasks and sockets: stop them before ESP-NOW's + // WiFi driver. Restore ESP-NOW first so infrastructure WiFi can coexist. + const uint8_t order[] = {WiFi, EspNow, Bluetooth, EspNow, WiFi, Bluetooth}; + for (unsigned i = 0; i < sizeof(order); ++i) { + const uint8_t bit = order[i]; + const bool on = (target_ & bit) != 0; + if (!(scope_ & bit) || on != (i >= 3)) continue; + const auto result = backend_->set(bit, on); + if (result == Result::Pending && uint32_t(now - due_) < 30000) return; + if (result != Result::Done) { + pending_ = false; + error_ = " (failed: retry command; check startup/teardown)"; + return; + } + } + pending_ = false; + if (!master_off_) saved_valid_ = false; + } +}; + +inline Control& control() { static Control instance; return instance; } + +} // namespace wireless +} // namespace mesh diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index e7d7c528e..961f1fc10 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -1,9 +1,18 @@ #include "ESPNowBridge.h" +#include #include +#include #ifdef WITH_ESPNOW_BRIDGE +static void stopBridgeWiFiIfUnused() { + if (!(mesh::wireless::control().enabled() & mesh::wireless::WiFi)) { + esp_wifi_stop(); + esp_wifi_deinit(); + } +} + // Static member to handle callbacks ESPNowBridge *ESPNowBridge::_instance = nullptr; @@ -46,6 +55,7 @@ ESPNowBridge::ESPNowBridge(NodePrefs *prefs, mesh::PacketManager *mgr, mesh::RTC } void ESPNowBridge::begin() { + if (mesh::wireless::control().blocked(mesh::wireless::EspNow)) return; BRIDGE_DEBUG_PRINTLN("Initializing...\n"); if (_initialized) return; @@ -80,13 +90,21 @@ void ESPNowBridge::begin() { // ESP-NOW only needs the ESP-IDF station interface. Avoid Arduino's WiFi // facade here: it pulls DHCP, DNS, scanning, AP, and event plumbing that an // ESP-NOW-only bridge never uses. + const bool shared_wifi = (mesh::wireless::control().enabled() & mesh::wireless::WiFi) != 0; + if (shared_wifi) { + uint8_t channel = 0; + wifi_second_chan_t secondary = WIFI_SECOND_CHAN_NONE; + if (esp_wifi_get_channel(&channel, &secondary) != ESP_OK || channel != _prefs->bridge_channel) { + BRIDGE_DEBUG_PRINTLN("ESP-NOW bridge channel must match the active WiFi channel\n"); + return; + } + } wifi_init_config_t wifi_config = WIFI_INIT_CONFIG_DEFAULT(); - if (esp_wifi_init(&wifi_config) != ESP_OK || + if (!shared_wifi && (esp_wifi_init(&wifi_config) != ESP_OK || esp_wifi_set_storage(WIFI_STORAGE_RAM) != ESP_OK || esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK || - esp_wifi_start() != ESP_OK) { - esp_wifi_stop(); - esp_wifi_deinit(); + esp_wifi_start() != ESP_OK)) { + stopBridgeWiFiIfUnused(); return; } @@ -99,25 +117,22 @@ void ESPNowBridge::begin() { | WIFI_PROTOCOL_11N | WIFI_PROTOCOL_LR; if (esp_wifi_set_protocol(WIFI_IF_STA, protocols) != ESP_OK) { BRIDGE_DEBUG_PRINTLN("Error enabling ESP-NOW LR protocol\n"); - esp_wifi_stop(); - esp_wifi_deinit(); + stopBridgeWiFiIfUnused(); return; } } // Set Wi-Fi channel - if (esp_wifi_set_channel(_prefs->bridge_channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { + if (!shared_wifi && esp_wifi_set_channel(_prefs->bridge_channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { BRIDGE_DEBUG_PRINTLN("Error setting WIFI channel to %d\n", _prefs->bridge_channel); - esp_wifi_stop(); - esp_wifi_deinit(); + stopBridgeWiFiIfUnused(); return; } // Initialize ESP-NOW if (esp_now_init() != ESP_OK) { BRIDGE_DEBUG_PRINTLN("Error initializing ESP-NOW\n"); - esp_wifi_stop(); - esp_wifi_deinit(); + stopBridgeWiFiIfUnused(); return; } @@ -127,8 +142,7 @@ void ESPNowBridge::begin() { esp_now_register_recv_cb(nullptr); esp_now_register_send_cb(nullptr); esp_now_deinit(); - esp_wifi_stop(); - esp_wifi_deinit(); + stopBridgeWiFiIfUnused(); return; } @@ -145,8 +159,7 @@ void ESPNowBridge::begin() { esp_now_register_recv_cb(nullptr); esp_now_register_send_cb(nullptr); esp_now_deinit(); - esp_wifi_stop(); - esp_wifi_deinit(); + stopBridgeWiFiIfUnused(); return; } @@ -169,17 +182,18 @@ void ESPNowBridge::begin() { esp_now_register_recv_cb(nullptr); esp_now_register_send_cb(nullptr); esp_now_deinit(); - esp_wifi_stop(); - esp_wifi_deinit(); + stopBridgeWiFiIfUnused(); return; } } // Update bridge state _initialized = true; + mesh::wifi::bridgeEspNowChannel().store(_prefs->bridge_channel); } void ESPNowBridge::end() { + if (!_initialized) return; BRIDGE_DEBUG_PRINTLN("Stopping...\n"); // Remove broadcast peer @@ -197,9 +211,9 @@ void ESPNowBridge::end() { BRIDGE_DEBUG_PRINTLN("Error deinitializing ESP-NOW\n"); } - // Turn off the ESP-IDF WiFi interface. - esp_wifi_stop(); - esp_wifi_deinit(); + // Infrastructure WiFi may still own this shared radio. + mesh::wifi::bridgeEspNowChannel().store(0); + stopBridgeWiFiIfUnused(); portENTER_CRITICAL(&_rx_mux); _rx_head = 0; diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index cca873bb4..593311d2b 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -9,6 +9,7 @@ #include "../TxtDataHelpers.h" #include "../UsbLogging.h" #include "../WiFiPowerSave.h" +#include "../WirelessControl.h" #include #include #include @@ -874,6 +875,9 @@ void MQTTBridge::releaseRuntimeBuffers() { // begin() // --------------------------------------------------------------------------- void MQTTBridge::begin() { +#if defined(ESP32) + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return; +#endif MQTT_DEBUG_PRINTLN("Initializing MQTT Bridge..."); // Idempotent start: a second begin() would re-run allocation and re-create diff --git a/src/helpers/esp32/ESPNOWRadio.cpp b/src/helpers/esp32/ESPNOWRadio.cpp index 4911a931a..a76dc4c60 100644 --- a/src/helpers/esp32/ESPNOWRadio.cpp +++ b/src/helpers/esp32/ESPNOWRadio.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #if ESP_ARDUINO_VERSION_MAJOR >= 3 #include #endif @@ -32,6 +33,7 @@ static volatile uint32_t rx_dropped = 0; static uint32_t rx_dropped_reported = 0; static portMUX_TYPE rx_mux = portMUX_INITIALIZER_UNLOCKED; static mesh::espnow::ESPNowRawReassembler rx_reassembler; +static std::atomic accepting_packets{false}; // callback when data is sent #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) @@ -42,6 +44,7 @@ static void OnDataSent(const esp_now_send_info_t *info, static void OnDataSent(const uint8_t *mac_addr, esp_now_send_status_t status) { (void)mac_addr; #endif + if (!accepting_packets.load()) return; last_send_status = status; is_send_complete = true; ESPNOW_DEBUG_PRINTLN("Send Status: %d", (int)status); @@ -54,6 +57,7 @@ static void OnDataRecv(const esp_now_recv_info_t *info, const uint8_t *data, #else static void OnDataRecv(const uint8_t *mac, const uint8_t *data, int len) { #endif + if (!accepting_packets.load()) return; ESPNOW_DEBUG_PRINTLN("Recv: len = %d", len); portENTER_CRITICAL(&rx_mux); if (data != nullptr && len > 0 && len <= RX_FRAME_MAX @@ -76,6 +80,7 @@ static void OnDataRecv(const uint8_t *mac, const uint8_t *data, int len) { } void ESPNOWRadio::init() { + if (initialized_ || mesh::wireless::control().blocked(mesh::wireless::EspNow)) return; portENTER_CRITICAL(&rx_mux); rx_head = 0; rx_tail = 0; @@ -93,13 +98,15 @@ void ESPNOWRadio::init() { // driver writes in RAM prevents a later conventional WiFi image from // inheriting the proprietary protocol bit through NVS. WiFi.persistent(false); + const wifi_mode_t existing_mode = WiFi.getMode(); #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW // Arduino defaults station auto-reconnect to enabled. Disable it before the // driver starts so credentials left by another image cannot pull the radio // away from the configured mesh channel behind this policy's back. - WiFi.setAutoReconnect(false); + if (existing_mode == WIFI_OFF) WiFi.setAutoReconnect(false); #endif - WiFi.mode(WIFI_STA); + // Restarting ESP-NOW must retain a live infrastructure setup AP. + if (!WiFi.mode((existing_mode & WIFI_AP) ? WIFI_AP_STA : WIFI_STA)) return; #if defined(MESH_ESPNOW_RADIO) && MESH_ESPNOW_RADIO // Full Companion also exposes ordinary WiFi. Preserve B/G/N for normal // access points while retaining LR for ESP-NOW, and pin both transports to @@ -128,6 +135,7 @@ void ESPNOWRadio::init() { ESPNOW_DEBUG_PRINTLN("Error initializing ESP-NOW"); return; } + initialized_ = true; #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW // Modem sleep is still required when BLE and infrastructure WiFi coexist, @@ -137,15 +145,22 @@ void ESPNOWRadio::init() { // WiFi/Bluetooth coexistence policy globally. if (esp_wifi_force_wakeup_acquire() != ESP_OK) { ESPNOW_DEBUG_PRINTLN("Error keeping primary ESP-NOW receive awake"); - esp_now_deinit(); + end(); return; } + wake_held_ = true; #endif - esp_wifi_set_max_tx_power(80); // should be 20dBm + if (!setTxPower(tx_power_dbm_)) { + end(); + return; + } - esp_now_register_send_cb(OnDataSent); - esp_now_register_recv_cb(OnDataRecv); + if (esp_now_register_send_cb(OnDataSent) != ESP_OK + || esp_now_register_recv_cb(OnDataRecv) != ESP_OK) { + end(); + return; + } // Register peer memset(&peerInfo, 0, sizeof(peerInfo)); @@ -175,26 +190,58 @@ void ESPNOWRadio::init() { rate_config.dcm = false; if (esp_now_set_peer_rate_config(broadcastAddress, &rate_config) != ESP_OK) { ESPNOW_DEBUG_PRINTLN("Error configuring ESP-NOW LR peer rate"); + end(); + return; } #else if (esp_wifi_config_espnow_rate(WIFI_IF_STA, WIFI_PHY_RATE_LORA_250K) != ESP_OK) { ESPNOW_DEBUG_PRINTLN("Error configuring ESP-NOW LR interface rate"); + end(); + return; } #endif #endif + accepting_packets.store(true); ESPNOW_DEBUG_PRINTLN("init success"); } else { - // ESPNOW_DEBUG_PRINTLN("Failed to add peer"); + end(); } } +void ESPNOWRadio::end() { + if (send_active_) send_cancelled_ = true; + accepting_packets.store(false); + if (initialized_) { + esp_now_unregister_send_cb(); + esp_now_unregister_recv_cb(); + esp_now_deinit(); + initialized_ = false; + } +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW + if (wake_held_) { + esp_wifi_force_wakeup_release(); + wake_held_ = false; + } +#endif + portENTER_CRITICAL(&rx_mux); + rx_head = rx_tail = rx_count = 0; + portEXIT_CRITICAL(&rx_mux); + rx_reassembler.reset(); + tx_second_pending = false; + tx_second_length = 0; + // A cancelled transmission must time out, never become a false TX success. + is_send_complete = false; +} + uint32_t ESPNOWRadio::getRngSeed() { return millis() + intID(); // TODO: where to get some entropy? } bool ESPNOWRadio::setTxPower(int8_t dbm) { - return esp_wifi_set_max_tx_power(dbm * 4) == ESP_OK; + if (esp_wifi_set_max_tx_power(dbm * 4) != ESP_OK) return false; + tx_power_dbm_ = dbm; + return true; } uint32_t ESPNOWRadio::intID() { @@ -209,6 +256,7 @@ uint32_t ESPNOWRadio::intID() { } bool ESPNOWRadio::startSendRaw(const uint8_t* bytes, int len) { + if (!initialized_ || send_cancelled_) return false; mesh::espnow::ESPNowRawFrames frames; if (len < 0 || !mesh::espnow::encodeEspNowRawFrames( @@ -232,6 +280,7 @@ bool ESPNOWRadio::startSendRaw(const uint8_t* bytes, int len) { esp_err_t result = esp_now_send( broadcastAddress, frames.data[0], frames.lengths[0]); if (result == ESP_OK) { + send_active_ = true; n_sent++; ESPNOW_DEBUG_PRINTLN("Send started: frames=%u raw_len=%d", (unsigned)frames.count, len); @@ -246,6 +295,7 @@ bool ESPNOWRadio::startSendRaw(const uint8_t* bytes, int len) { } bool ESPNOWRadio::isSendComplete() { + if (!initialized_ || send_cancelled_) return false; if (!is_send_complete) return false; if (last_send_status != ESP_NOW_SEND_SUCCESS) { // A failed callback means the logical packet was not transmitted. Do not @@ -279,6 +329,8 @@ bool ESPNOWRadio::isSendComplete() { return is_send_complete; } void ESPNOWRadio::onSendFinished() { + send_active_ = false; + send_cancelled_ = false; tx_second_pending = false; tx_second_length = 0; last_send_status = ESP_NOW_SEND_SUCCESS; @@ -286,14 +338,14 @@ void ESPNOWRadio::onSendFinished() { } bool ESPNOWRadio::isInRecvMode() const { - return is_send_complete && !tx_second_pending; + return initialized_ && !send_cancelled_ && is_send_complete && !tx_second_pending; } float ESPNOWRadio::getLastRSSI() const { return 0; } float ESPNOWRadio::getLastSNR() const { return 0; } int ESPNOWRadio::recvRaw(uint8_t* bytes, int sz) { - if (bytes == nullptr || sz <= 0) return 0; + if (!initialized_ || bytes == nullptr || sz <= 0) return 0; uint32_t dropped = 0; portENTER_CRITICAL(&rx_mux); diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index 2d3d27582..c977598d2 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -5,6 +5,11 @@ class ESPNOWRadio : public mesh::Radio { protected: uint32_t n_recv, n_sent, n_recv_errors; + bool initialized_ = false; + bool wake_held_ = false; + int8_t tx_power_dbm_ = 20; + bool send_active_ = false; + bool send_cancelled_ = false; public: ESPNOWRadio() { n_recv = n_sent = n_recv_errors = 0; } @@ -24,9 +29,11 @@ public: const uint32_t* rx_ps_timings = nullptr) { return trySetParams(freq, bw, sf, cr, rx_ps_timings) == mesh::RadioParamApplyResult::APPLIED; } - void powerOff() { /* no-op */ } + void powerOff() { end(); } void init(); + void end(); + bool isEnabled() const { return initialized_; } int recvRaw(uint8_t* bytes, int sz) override; uint32_t getEstAirtimeFor(int len_bytes) override; bool startSendRaw(const uint8_t* bytes, int len) override; diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index fe0a69122..2f3131344 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -1,4 +1,5 @@ #include "WebConfigServer.h" +#include #ifdef WITH_WEBCONFIG @@ -187,18 +188,17 @@ static uint8_t effectiveWiFiPowerSave(uint8_t configured) { } static void stopOwnedWiFiRadio() { -#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW - // ESP-NOW is this target's mesh radio. Stop only infrastructure-WiFi - // ownership; powering the driver down would also remove the mesh transport. - WiFi.setAutoReconnect(false); - WiFi.disconnect(false, false); - WiFi.mode(WIFI_STA); - mesh::wifi::applyProtocolMask(WIFI_IF_STA); - mesh::wifi::restoreEspNowChannel(); -#else - WiFi.disconnect(true); - WiFi.mode(WIFI_OFF); -#endif + if (mesh::wifi::espNowChannelConstrained()) { + // Keep a primary ESP-NOW radio or running bridge on its mesh channel. + WiFi.setAutoReconnect(false); + WiFi.disconnect(false, false); + WiFi.mode(WIFI_STA); + mesh::wifi::applyProtocolMask(WIFI_IF_STA); + mesh::wifi::restoreEspNowChannel(); + } else { + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + } } // Placeholder sent instead of stored secrets; POSTs carrying it are dropped @@ -780,6 +780,10 @@ bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t // --------------------------------------------------------------------------- bool WebConfigServer::startSetupMode(char reply[]) { + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { + strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); + return false; + } const bool promote_lan = _mode == MODE_LAN && !_owns_wifi && !_stopping; if ((_mode != MODE_OFF && !promote_lan) || _stopping) { strcpy(reply, "Err: webconfig busy"); @@ -892,6 +896,10 @@ bool WebConfigServer::startLanMode(char reply[]) { } bool WebConfigServer::startAutoMode(char reply[]) { + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { + strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); + return false; + } if (_wifi_ssid[0] == 0) return startSetupMode(reply); if (WiFi.status() == WL_CONNECTED) return startLanMode(reply); if (_mode != MODE_OFF || _stopping) { diff --git a/src/helpers/esp32/WiFiRadioPolicy.h b/src/helpers/esp32/WiFiRadioPolicy.h index 24c5b94ef..0d5166d13 100644 --- a/src/helpers/esp32/WiFiRadioPolicy.h +++ b/src/helpers/esp32/WiFiRadioPolicy.h @@ -4,6 +4,7 @@ #include #include #include +#include #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW #include @@ -28,7 +29,7 @@ static constexpr bool kKeepEspNowRadioRunning = false; static constexpr uint8_t kDefaultEspNowChannel = 1; #endif -#if defined(MESH_ESPNOW_RADIO) && MESH_ESPNOW_RADIO +#if (defined(MESH_ESPNOW_RADIO) && MESH_ESPNOW_RADIO) || defined(WITH_ESPNOW_BRIDGE) static constexpr uint8_t kProtocolMask = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N | WIFI_PROTOCOL_LR; #elif defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW @@ -45,6 +46,16 @@ static constexpr uint8_t kProtocolMask = WIFI_PROTOCOL_11B static constexpr uint8_t kAccessPointProtocolMask = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N; +// A bridge can start/stop independently of infrastructure WiFi. Its channel +// constraint exists only while it runs; primary ESP-NOW keeps its boot policy. +inline std::atomic& bridgeEspNowChannel() { + static std::atomic channel{0}; + return channel; +} +inline bool espNowChannelConstrained() { + return kPrimaryEspNowRadio || bridgeEspNowChannel().load() != 0; +} + #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW struct EspNowBootChannelState { bool loaded; @@ -96,7 +107,8 @@ inline uint8_t loadConfiguredEspNowChannel() { return kDefaultEspNowChannel; } inline uint8_t activeEspNowChannel() { - return kDefaultEspNowChannel; + const uint8_t bridge_channel = bridgeEspNowChannel().load(); + return bridge_channel ? bridge_channel : kDefaultEspNowChannel; } inline bool saveConfiguredEspNowChannel(uint8_t) { return false; @@ -116,21 +128,21 @@ inline esp_err_t applyAccessPointProtocolMask() { // association on the mesh channel. Passing zero on ordinary targets preserves // Arduino's normal all-channel scan behavior. inline int32_t stationChannelHint() { - return kPrimaryEspNowRadio + return espNowChannelConstrained() ? static_cast(activeEspNowChannel()) : 0; } inline int accessPointChannel() { - return kPrimaryEspNowRadio + return espNowChannelConstrained() ? static_cast(activeEspNowChannel()) : 1; } inline uint8_t stationScanChannel() { - return kPrimaryEspNowRadio ? activeEspNowChannel() : 0; + return espNowChannelConstrained() ? activeEspNowChannel() : 0; } inline esp_err_t restoreEspNowChannel() { - if (!kKeepEspNowRadioRunning) return ESP_OK; + if (!espNowChannelConstrained()) return ESP_OK; const uint8_t target = activeEspNowChannel(); uint8_t current = 0; wifi_second_chan_t secondary = WIFI_SECOND_CHAN_NONE; diff --git a/src/helpers/esp32/WiFiStationPolicy.h b/src/helpers/esp32/WiFiStationPolicy.h index 7532e8c88..1a9968da0 100644 --- a/src/helpers/esp32/WiFiStationPolicy.h +++ b/src/helpers/esp32/WiFiStationPolicy.h @@ -4,6 +4,7 @@ #include #include #include +#include #if defined(MESH_SOAK_WIFI_KEY_OVERRIDE) #include "../../../tools/hil/S3SoakWiFiKey.h" #endif @@ -17,18 +18,19 @@ namespace wifi { // drag ESP-NOW (and a setup AP) with it. Existing application retry timers call // beginStation() and therefore retain the fixed-channel/BSSID constraint. inline void setStationAutoReconnect(bool enabled) { - WiFi.setAutoReconnect(enabled && !kPrimaryEspNowRadio); + WiFi.setAutoReconnect(enabled && !espNowChannelConstrained()); } inline wl_status_t beginStation(const char* ssid, const char* password) { + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return WL_CONNECT_FAILED; #if defined(MESH_SOAK_WIFI_KEY_OVERRIDE) password = mesh::hil::soakStationPassword(password); #endif - if (!kPrimaryEspNowRadio) { + if (!espNowChannelConstrained()) { return WiFi.begin(ssid, password); } -#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW +#if (defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW) || defined(WITH_ESPNOW_BRIDGE) if (!ssid || !ssid[0]) return WL_CONNECT_FAILED; setStationAutoReconnect(false); if (applyProtocolMask(WIFI_IF_STA) != ESP_OK @@ -81,7 +83,7 @@ inline wl_status_t beginStation(const char* ssid, const char* password) { // channel (for example after an AP channel-switch announcement). Callers then // follow their existing disconnected/retry path. inline bool enforceStationChannel() { - if (!kPrimaryEspNowRadio) return true; + if (!espNowChannelConstrained()) return true; // beginStation(), disconnect handling, and setup-AP startup already restore // the selected channel. Avoid polling the driver and attempting a channel // write from every WebConfig and Companion loop while it is disconnected. diff --git a/test/test_espnow_radio_lifecycle.py b/test/test_espnow_radio_lifecycle.py new file mode 100644 index 000000000..fab7a7bb3 --- /dev/null +++ b/test/test_espnow_radio_lifecycle.py @@ -0,0 +1,279 @@ +"""Execute production ESP-NOW driver startup/shutdown with fault-injected SDK APIs.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] +STUBS = r''' +#include +#include +#include +#include +#include +#define ESP_IDF_VERSION_VAL(a,b,c) ((a)*10000+(b)*100+(c)) +#define ESPNOW_DEBUG_PRINTLN(...) ((void)0) +#define portENTER_CRITICAL(x) ((void)0) +#define portEXIT_CRITICAL(x) ((void)0) +#define portMUX_INITIALIZER_UNLOCKED 0 +using portMUX_TYPE=int; +using esp_err_t=int; +constexpr int ESP_OK=0,WIFI_IF_STA=0,WIFI_PROTOCOL_LR=8,WIFI_PHY_MODE_LR=1,WIFI_PHY_RATE_LORA_250K=2; +enum wifi_mode_t {WIFI_OFF=0,WIFI_STA=1,WIFI_AP=2,WIFI_AP_STA=3}; +enum esp_now_send_status_t {ESP_NOW_SEND_SUCCESS=0,ESP_NOW_SEND_FAIL=1}; +struct esp_now_send_info_t {}; +struct esp_now_recv_info_t {const uint8_t* src_addr;}; +struct esp_now_peer_info_t {uint8_t peer_addr[6];int channel,ifidx;bool encrypt;}; +struct esp_now_rate_config_t {int phymode,rate;bool ersu,dcm;}; +int failure=0,init_calls=0,deinit_calls=0,wake_refs=0,send_calls=0,power=0; +bool sdk_active=false;uint32_t millis(){return 1234;} +struct FakeWiFi { + wifi_mode_t current=WIFI_OFF;bool autoreconnect=true; + void persistent(bool){} + wifi_mode_t getMode(){return current;} + void setAutoReconnect(bool value){autoreconnect=value;} + bool mode(wifi_mode_t value){if(failure==1)return false;current=value;return true;} +} WiFi; +namespace mesh { + enum class RadioParamApplyResult {APPLIED}; + namespace wifi { + int applyProtocolMask(int){return failure==2?-1:0;} + int restoreEspNowChannel(){return failure==3?-1:0;} + } +} +int esp_wifi_set_protocol(int,int){return failure==2?-1:0;} +int esp_now_init(){++init_calls;if(failure==4)return -1;sdk_active=true;return 0;} +int esp_now_deinit(){assert(sdk_active);sdk_active=false;++deinit_calls;return 0;} +int esp_wifi_force_wakeup_acquire(){if(failure==5)return -1;++wake_refs;return 0;} +int esp_wifi_force_wakeup_release(){assert(wake_refs==1);--wake_refs;return 0;} +int esp_wifi_set_max_tx_power(int value){if(failure==6)return -1;power=value;return 0;} +template int esp_now_register_send_cb(T){return failure==7?-1:0;} +template int esp_now_register_recv_cb(T){return failure==8?-1:0;} +int esp_now_unregister_send_cb(){return 0;} +int esp_now_unregister_recv_cb(){return 0;} +int esp_now_add_peer(esp_now_peer_info_t* peer){assert(peer->channel==0);return failure==9?-1:0;} +int esp_now_set_peer_rate_config(const uint8_t*,esp_now_rate_config_t*){return failure==10?-1:0;} +int esp_wifi_config_espnow_rate(int,int){return failure==10?-1:0;} +int esp_now_send(const uint8_t*,const uint8_t*,size_t){assert(sdk_active);++send_calls;return 0;} +void esp_efuse_mac_get_default(uint8_t* mac){memset(mac,1,6);} +''' + +CHECKS = r''' +void deliver(const uint8_t* bytes,int len){ + static uint8_t mac[6]={1,2,3,4,5,6}; +#if ESP_ARDUINO_VERSION_MAJOR >= 3 + esp_now_recv_info_t info{mac};OnDataRecv(&info,bytes,len); +#else + OnDataRecv(mac,bytes,len); +#endif +} +int main(){ + ESPNOWRadio radio; + assert(!radio.isEnabled()); + for(int stage=1;stage<=10;++stage){ + failure=stage;radio.init();assert(!radio.isEnabled()); + assert(!sdk_active&&wake_refs==0); + assert(!radio.isInRecvMode()&&!radio.isSendComplete()); + radio.end();assert(wake_refs==0); // failed startup and repeated off are safe + } + failure=0;WiFi.current=WIFI_OFF;radio.init(); + assert(radio.isEnabled()&&sdk_active&&wake_refs==1&&!WiFi.autoreconnect); + const int started=init_calls;radio.init();assert(init_calls==started&&wake_refs==1); + assert(radio.setTxPower(13)&&power==52); + uint8_t raw[255]={0x15,1,2,3},out[255]; + deliver(raw,4);assert(radio.recvRaw(out,sizeof(out))==4&&!memcmp(raw,out,4)); + // Stop in the middle of a fragmented TX: do not report success or send fragment 2. + assert(radio.startSendRaw(raw,sizeof(raw))&&tx_second_pending); + OnDataSent(nullptr,ESP_NOW_SEND_SUCCESS); + radio.end();const int sent=send_calls; + assert(!radio.isSendComplete()&&!radio.startSendRaw(raw,4)&&send_calls==sent); + OnDataSent(nullptr,ESP_NOW_SEND_SUCCESS); // callback arriving while disabled + deliver(raw,4);assert(radio.recvRaw(out,sizeof(out))==0); + radio.end();assert(wake_refs==0&&!sdk_active); + // Starting alongside a setup AP preserves it and restores the configured TX power. + WiFi.current=WIFI_AP_STA;WiFi.autoreconnect=true;radio.init(); + assert(WiFi.current==WIFI_AP_STA&&WiFi.autoreconnect&&power==52); + assert(!radio.isSendComplete()); // restarting before Dispatcher's timeout cannot fake TX success + radio.onSendFinished(); // Dispatcher acknowledges the cancelled operation + assert(radio.recvRaw(out,sizeof(out))==0&&radio.isInRecvMode()); + // Incomplete RX fragments cannot cross a stop/start boundary. + mesh::espnow::ESPNowRawFrames frames; + assert(mesh::espnow::encodeEspNowRawFrames(raw,sizeof(raw),frames)); + deliver(frames.data[0],frames.lengths[0]);assert(radio.recvRaw(out,sizeof(out))==0); + radio.end();radio.init(); + deliver(frames.data[1],frames.lengths[1]);assert(radio.recvRaw(out,sizeof(out))==0); + // A fresh two-fragment packet is received normally after restarting. + deliver(frames.data[0],frames.lengths[0]); + assert(radio.recvRaw(out,sizeof(out))==255&&!memcmp(raw,out,255)); + radio.end();assert(wake_refs==0&&!sdk_active); +} +''' + +class EspNowLifecycleTests(unittest.TestCase): + def test_sdk_failures_stop_and_restart(self): + header=(ROOT/'src/helpers/esp32/ESPNOWRadio.h').read_text() + source=(ROOT/'src/helpers/esp32/ESPNOWRadio.cpp').read_text() + # Substitute only the abstract radio base and SDK includes; all driver + # state, callbacks, queues, fragmentation, and method bodies are production. + header=header[:header.index('#if ESPNOW_DEBUG_LOGGING')] + header=re.sub(r'^\s*#(?:include|pragma).*$', '', header, flags=re.MULTILINE) + header=header.replace(' : public mesh::Radio','').replace(' override','') + source=re.sub(r'^\s*#include.*$', '', source, flags=re.MULTILINE) + with tempfile.TemporaryDirectory() as tmp: + folder=Path(tmp) + (folder/'driver.cpp').write_text(STUBS+header+source+CHECKS) + for arduino,idf in [(2,40400),(3,50200),(3,50500)]: + with self.subTest(arduino=arduino,idf=idf): + binary=folder/f'driver-{idf}' + subprocess.run([os.environ.get('CXX','g++'),'-std=c++17', + '-I',str(ROOT/'src'),'-DMESH_PRIMARY_ESPNOW=1','-DMESH_ESPNOW_RADIO=1', + f'-DESP_ARDUINO_VERSION_MAJOR={arduino}',f'-DESP_IDF_VERSION={idf}', + str(folder/'driver.cpp'),'-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True) + + def test_bridge_preserves_infrastructure_wifi(self): + source=(ROOT/'src/helpers/bridges/ESPNowBridge.cpp').read_text() + methods=method(source,'static void stopBridgeWiFiIfUnused(')+'\n' + methods+='\n'.join(method(source,f'void ESPNowBridge::{name}(').replace('ESPNowBridge::','Bridge::') + for name in ('begin','end')) + harness=STUBS.replace('assert(peer->channel==0)', 'assert(peer->channel==6)')+r''' +#include +#include +#define BRIDGE_DEBUG_PRINTLN(...) ((void)0) +#define ESP_NOW_ETH_ALEN 6 +#define WIFI_INIT_CONFIG_DEFAULT() 0 +using wifi_init_config_t=int; +using wifi_second_chan_t=int; +constexpr int WIFI_STORAGE_RAM=0,WIFI_MODE_STA=1,WIFI_SECOND_CHAN_NONE=0; +constexpr int WIFI_PROTOCOL_11B=1,WIFI_PROTOCOL_11G=2,WIFI_PROTOCOL_11N=4; +int wifi_stops=0,wifi_starts=0;uint8_t wifi_channel=6; +int esp_wifi_init(wifi_init_config_t*){return 0;} +int esp_wifi_set_storage(int){return 0;} +int esp_wifi_set_mode(int){return 0;} +int esp_wifi_start(){++wifi_starts;return 0;} +int esp_wifi_stop(){++wifi_stops;return 0;} +int esp_wifi_deinit(){return 0;} +int esp_wifi_get_channel(uint8_t* channel,wifi_second_chan_t*){*channel=wifi_channel;return 0;} +int esp_wifi_set_channel(int channel,int){wifi_channel=channel;return 0;} +int esp_now_del_peer(const uint8_t*){return 0;} +namespace mesh {namespace wifi { + std::atomic& bridgeEspNowChannel(){static std::atomic channel{0};return channel;} +}} +struct Backend : mesh::wireless::Backend { + bool wifi=true; + uint8_t available()const override{return mesh::wireless::WiFi|mesh::wireless::EspNow;} + uint8_t enabled()const override{return wifi?mesh::wireless::WiFi:0;} + uint8_t clients()const override{return mesh::wireless::Independent;} + mesh::wireless::Result set(uint8_t,bool)override{return mesh::wireless::Result::Done;} +}; +class Bridge { +public: + struct Prefs {int bridge_format=mesh::bridge::ESPNOW_FORMAT_RAW;uint8_t bridge_channel=6;} prefs; + Prefs* _prefs=&prefs;bool _initialized=false; + int _active_format=0,_rx_mux=0,_rx_head=0,_rx_tail=0,_rx_count=0,_rx_dropped=0,_rx_dropped_reported=0; + int _tx_mux=0,_tx_head=0,_tx_tail=0,_tx_count=0,_tx_waiting=0,_tx_callback_done=0; + int _tx_callback_status=0,_tx_dropped=0,_tx_dropped_reported=0; + mesh::espnow::ESPNowRawReassembler _raw_reassembler; + struct QueuedTransmit {bool started=false;int packet=0;}; + static constexpr int TX_QUEUE_DEPTH=2; + QueuedTransmit _tx_queue[TX_QUEUE_DEPTH]; + struct {void clear(const int*){}} _seen_packets; + static void recv_cb(){}static void send_cb(){} + void begin();void end(); +}; +'''+methods+r''' +int main(){ + Backend backend;mesh::wireless::control().begin(backend);Bridge bridge; + // A channel conflict must leave the active WiFi connection untouched. + wifi_channel=3;bridge.begin();assert(!bridge._initialized&&init_calls==0&&wifi_stops==0); + wifi_channel=6; + for(int stage:{2,4,7,8,9,10}){ + failure=stage;bridge.begin();assert(!bridge._initialized&&!sdk_active&&wifi_stops==0); + bridge.end();assert(wifi_stops==0); + } + failure=0;bridge.begin();assert(bridge._initialized&&sdk_active&&wifi_starts==0); + assert(mesh::wifi::bridgeEspNowChannel().load()==6); + bridge.end();assert(!sdk_active&&wifi_stops==0&&mesh::wifi::bridgeEspNowChannel().load()==0); + // When no infrastructure service owns WiFi, the bridge owns driver shutdown. + backend.wifi=false;bridge.begin();assert(bridge._initialized&&wifi_starts==1); + bridge.end();assert(!sdk_active&&wifi_stops==1); + bridge.end();assert(wifi_stops==1); +} +''' + with tempfile.TemporaryDirectory() as tmp: + folder=Path(tmp) + (folder/'bridge.cpp').write_text(harness) + binary=folder/'bridge' + subprocess.run([os.environ.get('CXX','g++'),'-std=c++17','-I',str(ROOT/'src'), + '-DESP_IDF_VERSION=50200',str(folder/'bridge.cpp'),'-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True) + + def test_bridge_channel_constraint_tracks_runtime_state(self): + with tempfile.TemporaryDirectory() as tmp: + folder=Path(tmp) + (folder/'esp_err.h').write_text('#pragma once\nusing esp_err_t=int;\nconstexpr int ESP_OK=0;\n') + (folder/'esp_wifi.h').write_text(r''' +#pragma once +#include +#include +using wifi_interface_t=int;using wifi_second_chan_t=int; +constexpr int WIFI_IF_STA=0,WIFI_IF_AP=1,WIFI_SECOND_CHAN_NONE=0; +constexpr int WIFI_PROTOCOL_11B=1,WIFI_PROTOCOL_11G=2,WIFI_PROTOCOL_11N=4,WIFI_PROTOCOL_LR=8; +uint8_t current_channel=1,last_protocol=0; +int esp_wifi_set_protocol(int,uint8_t p){last_protocol=p;return 0;} +int esp_wifi_get_channel(uint8_t* p,int*){*p=current_channel;return 0;} +int esp_wifi_set_channel(uint8_t p,int){current_channel=p;return 0;} +''') + (folder/'channel.cpp').write_text(r''' +#include +#include +#include +int main(){using namespace mesh::wifi; + assert(!espNowChannelConstrained()&&stationChannelHint()==0&&stationScanChannel()==0); + bridgeEspNowChannel().store(6); + assert(espNowChannelConstrained()&&stationChannelHint()==6&&stationScanChannel()==6&&accessPointChannel()==6); + assert(restoreEspNowChannel()==ESP_OK&¤t_channel==6); + applyProtocolMask(WIFI_IF_STA);assert(last_protocol==15); + setStationAutoReconnect(true);assert(!WiFi.auto_reconnect); + assert(beginStation("mesh","password")==WL_CONNECTED); + assert(WiFi.scan_channel==6&&WiFi.join_channel==6); + current_channel=11;assert(!enforceStationChannel()&¤t_channel==6); + bridgeEspNowChannel().store(0);current_channel=11; + assert(!espNowChannelConstrained()&&stationChannelHint()==0&&stationScanChannel()==0); + assert(restoreEspNowChannel()==ESP_OK&¤t_channel==11); + setStationAutoReconnect(true);assert(WiFi.auto_reconnect); + assert(beginStation("mesh","password")==WL_CONNECTED&&WiFi.join_channel==0); +} +''') + (folder/'WiFi.h').write_text(r''' +#pragma once +#include +#include +enum wl_status_t {WL_IDLE_STATUS,WL_CONNECTED,WL_NO_SSID_AVAIL,WL_CONNECT_FAILED}; +constexpr int WIFI_SCAN_RUNNING=-1; +struct { + bool auto_reconnect=true;uint8_t scan_channel=0;int join_channel=0; + wl_status_t state=WL_IDLE_STATUS; + void setAutoReconnect(bool value){auto_reconnect=value;} + wl_status_t begin(const char*,const char*,int channel=0,const uint8_t* =nullptr){ + join_channel=channel;state=WL_CONNECTED;return state; + } + int scanComplete(){return -2;}void scanDelete(){} + int scanNetworks(bool,bool,bool,int,uint8_t channel,const char*,const uint8_t*){scan_channel=channel;return 2;} + int channel(int index=-1){return index<0?current_channel:index==0?1:6;} + std::string SSID(int){return "mesh";}int RSSI(int){return -50;} + const uint8_t* BSSID(int){static uint8_t mac[6]={1};return mac;} + wl_status_t status(){return state;} + void disconnect(bool,bool){state=WL_IDLE_STATUS;} +} WiFi; +''') + binary=folder/'channel' + subprocess.run([os.environ.get('CXX','g++'),'-std=c++17','-DWITH_ESPNOW_BRIDGE=1', + '-I',str(folder),'-I',str(ROOT/'src'),str(folder/'channel.cpp'),'-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True) + +if __name__=='__main__': + unittest.main() diff --git a/test/test_wireless_control.py b/test/test_wireless_control.py new file mode 100644 index 000000000..0b7f1b52e --- /dev/null +++ b/test/test_wireless_control.py @@ -0,0 +1,299 @@ +"""Compile and execute the production wireless command/state controller.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +using namespace mesh::wireless; +struct Fake : Backend { + uint8_t supported=All, active=All, sessions=0, wait=0, fail=0; + bool busy=false; + std::vector calls; + uint8_t available()const override{return supported;} + uint8_t enabled()const override{return active;} + uint8_t clients()const override{return sessions;} + bool replyBusy()const override{return busy;} + Result set(uint8_t bit,bool on)override { + calls.push_back(on?bit:-bit); + if(wait==bit)return Result::Pending; + if(fail==bit)return Result::Failed; + if(on)active|=bit;else active&=~bit; + return Result::Done; + } +}; +struct Fixture { + Fake b; Control c; char reply[160]={}; uint32_t now=100; + Fixture(){c.begin(b);} + void run(const char* text,uint8_t requester=Independent){assert(c.handle(text,reply,sizeof(reply),now,requester));} + void tick(unsigned ms=250){now+=ms;c.service(now);} +}; +int main(int argc,char** argv) { + const int test=argc>1?atoi(argv[1]):0; + if(test==0){ + for(const char* text:{"set wifi","set espnow on force","set wifi off force extra", + "set 2.4ghz on all force","set 2.4ghz on forced","get wifi junk","set 2.4ghz off all"}) + assert(parse(text).action==Action::Invalid); + for(const char* text:{"set wifi.ssid x","get wifi.status","set espnow.channel 3","set bluetooth off","get 2.4ghzx"}) + assert(parse(text).action==Action::None); + assert(parse(nullptr).action==Action::None); + assert(parse(" set espnow\t off\tforce \t").action==Action::ForceOff); + assert(parse("set 2.4ghz on all").action==Action::OnAll); + assert(parse("set 2.4ghz on force").action==Action::OnAll); + } else if(test==1){ + for(unsigned mask=0;mask<=All;++mask){ + Fixture f;f.b.active=mask; + f.run("set 2.4ghz on");f.tick();assert(f.b.active==mask); // on before off is idempotent + f.run("set 2.4ghz off");f.tick();assert(f.b.active==0); + f.run("set 2.4ghz off");f.tick(); // never overwrite the saved state + f.run("set wifi on");assert(strstr(f.reply,"Error:")); + assert(!f.c.allowService(Bluetooth)); + f.run("set 2.4ghz on");f.tick();assert(f.b.active==mask); + assert(f.c.allowService(Bluetooth)); + f.run("set 2.4ghz on all");f.tick();assert(f.b.active==All); + f.run("set 2.4ghz off");f.tick(); + f.run("set 2.4ghz on force");f.tick();assert(f.b.active==All); + } + } else if(test==2){ + Fixture f;f.run("set wifi off",WiFi);assert(strstr(f.reply,"Error:")); + f.b.sessions=Bluetooth;f.run("set wifi off",WiFi);assert(f.c.pending()); + f.tick(249);assert(f.b.active==All);f.b.sessions=0;f.tick(1); + assert(f.b.active==All&&!f.c.pending());f.run("get wifi");assert(strstr(f.reply,"cancelled")); + f.b.sessions=Bluetooth;f.run("set wifi off",WiFi);f.tick();assert(f.b.active==(Bluetooth|EspNow)); + f.run("set wifi on");f.tick();assert(f.b.active==All); + f.b.sessions=Bluetooth|WiFi;f.run("set 2.4ghz off",Bluetooth);assert(strstr(f.reply,"Error:")); + f.run("set espnow off",EspNow);f.tick();assert(f.b.active==(WiFi|Bluetooth)); + } else if(test==3){ + Fixture f;f.b.busy=true;f.run("set 2.4ghz off force",Bluetooth); + f.tick();assert(f.b.active==All);f.tick(1749);assert(f.b.active==All); + f.tick(1);assert(f.b.active==0);assert(f.c.blocked(All)); + assert((f.b.calls==std::vector{-WiFi,-EspNow,-Bluetooth})); + f.b.calls.clear();f.b.busy=false;f.run("set 2.4ghz on");f.tick(); + assert((f.b.calls==std::vector{EspNow,WiFi,Bluetooth})); + } else if(test==4){ + Fixture f;f.b.wait=WiFi;f.run("set 2.4ghz off");f.tick(); + assert(f.c.pending()&&f.b.calls.size()==1&&f.b.active==All); + f.run("set espnow off");assert(strstr(f.reply,"pending")); + f.b.wait=0;f.tick(1);assert(f.b.active==0&&!f.c.pending()); + f.b.fail=EspNow;f.run("set 2.4ghz on");f.tick(); + assert(f.b.active==0&&!f.c.pending());f.run("get 2.4ghz");assert(strstr(f.reply,"failed")); + f.b.fail=0;f.run("set 2.4ghz on");f.tick();assert(f.b.active==All); // saved state survives failure + } else if(test==5){ + Fixture f;f.run("set 2.4ghz off");f.tick(249); + f.run("set 2.4ghz on");f.tick();assert(f.b.active==All&&!f.c.masterOff()); + f.b.sessions=Independent;f.run("set 2.4ghz off",Bluetooth);f.b.sessions=0;f.tick(); + assert(f.b.active==All&&!f.c.masterOff()); + f.now=UINT32_MAX-249;f.run("set 2.4ghz off");f.tick(249);assert(f.b.active==All); + f.tick(1);assert(f.b.active==0); + } else if(test==6){ + Fixture f;f.b.supported=Bluetooth;f.b.active=Bluetooth; + f.run("set wifi on");assert(strstr(f.reply,"unavailable")); + f.run("get 2.4ghz");assert(strstr(f.reply,"wifi=unavailable")&&strstr(f.reply,"bluetooth=on")); + f.run("set 2.4ghz off");f.tick();assert(f.b.active==0); + f.run("set 2.4ghz on all");f.tick();assert(f.b.active==Bluetooth); + f.b.supported=0;f.run("set 2.4ghz on");assert(strstr(f.reply,"unavailable")); + } else if(test==7){ + Fixture f;f.b.wait=WiFi;f.run("set 2.4ghz off");f.tick(30250); + assert(!f.c.pending()&&f.b.active==All&&f.b.calls.size()==1); + f.run("get 2.4ghz");assert(strstr(f.reply,"failed")); + f.b.wait=0;f.run("set 2.4ghz off");f.tick();assert(f.b.active==0); + } +} +''' + +class WirelessControlTests(unittest.TestCase): + def test_compiled_controller(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp)/'wireless.cpp' + source.write_text('#include \n'+HARNESS) + binary = Path(tmp)/'wireless' + subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++11', '-Wall', '-Wextra', '-Werror', + '-I', str(ROOT/'src'), str(source), '-o', str(binary)], check=True) + for scenario in range(8): + with self.subTest(scenario=scenario): + subprocess.run([str(binary), str(scenario)], check=True) + + def test_actual_companion_transport_adapter(self): + from test_companion_bluetooth_control import HARNESS as BT_HARNESS + main = (ROOT/'examples/companion_radio/main.cpp').read_text() + prefix = BT_HARNESS.split('@METHODS@')[0].replace( + ' bool isAnyNetworkTerminalMode()', + ' bool isWebConfigActiveOrStopping(){return false;}\n bool isAnyNetworkTerminalMode()') + adapter = method(main, 'class CompanionWirelessBackend')+';\n' + adapter += method(main, 'static void disableCompanionBluetoothForCli(')+'\n' + adapter += method(main, 'bool handleCompanionWirelessCommand(') + source_text = prefix+r''' +#include "examples/companion_radio/CompanionWireless.h" +bool companion_wifi_requested=true,companion_wifi_active=true; +bool companion_wifi_setup_requested=false,companion_wifi_disable_in_progress=false; +bool stop_pending=false; +struct {int mode_value=0;void mode(int value){mode_value=value;}void setAutoReconnect(bool){}} WiFi; +#define WIFI_OFF 0 +bool finishStoppingCompanionWiFi(){ + if(stop_pending)return false; + companion_wifi_active=false;wifi_interface.disable();return true; +} +void startCompanionWiFi(){companion_wifi_active=true;wifi_interface.enable();} +struct { + bool active=true; + bool isEnabled(){return active;} + void end(){active=false;} + void init(){active=true;} +} radio_driver; +static void disableCompanionBluetoothForCli(); +'''+adapter+r''' +int main(){ + CompanionWirelessBackend backend;mesh::wireless::control().begin(backend); + interface_manager.addInterface(InterfaceType::Bluetooth,&bluetooth_interface); + interface_manager.addInterface(InterfaceType::USB,&usb_serial_interface); + interface_manager.addInterface(InterfaceType::WiFi,&wifi_interface); + interface_manager.addInterface(InterfaceType::Ethernet,ðernet_interface); + interface_manager.enable();bluetooth_interface.connected=true; + assert(backend.available()==mesh::wireless::All); + assert(backend.enabled()==mesh::wireless::All); + // UART/HWCDC power cannot authorize cutting all wireless connections. + assert(!(backend.clients()&mesh::wireless::Independent)); + char reply[160]; + handleCompanionWirelessCommand("set 2.4ghz off",reply,sizeof(reply),CompanionWirelessSource::Network); + assert(strstr(reply,"Error:")); + // A stale framed BLE route must not disguise an actual USB ASCII request. + bluetooth_interface.frame=true;uint8_t frame[176];interface_manager.checkRecvFrame(frame); + handleCompanionWirelessCommand("set 2.4ghz off",reply,sizeof(reply),CompanionWirelessSource::Usb); + assert(strstr(reply,"requested"));stop_pending=true;now_ms+=250; + mesh::wireless::control().service(now_ms); + assert(radio_driver.active&&bluetooth_interface.enabled); // wait for WiFi tasks/sockets first + stop_pending=false;mesh::wireless::control().service(++now_ms); + assert(!radio_driver.active&&!bluetooth_interface.enabled&&!companion_wifi_active); + assert(!companion_wifi_requested); + handleCompanionWirelessCommand("set 2.4ghz on",reply,sizeof(reply),CompanionWirelessSource::Usb); + now_ms+=250;mesh::wireless::control().service(now_ms); + assert(radio_driver.active&&bluetooth_interface.enabled&&companion_wifi_active); + assert(companion_wifi_requested); + // WiFi's framed client must not be mistaken for independent USB. + wifi_interface.frame=true;wifi_interface.connected=true;bluetooth_interface.connected=false; + interface_manager.checkRecvFrame(frame); + handleCompanionWirelessCommand("set wifi off",reply,sizeof(reply),CompanionWirelessSource::Framed); + assert(strstr(reply,"Error:")); + // An actual framed USB request is proof even without DTR on a UART bridge. + usb_serial_interface.frame=true;usb_serial_interface.connected=true; + interface_manager.checkRecvFrame(frame); + handleCompanionWirelessCommand("set espnow off",reply,sizeof(reply),CompanionWirelessSource::Framed); + now_ms+=250;mesh::wireless::control().service(now_ms); + assert(!radio_driver.active&&companion_wifi_active&&bluetooth_interface.enabled); +} +''' + with tempfile.TemporaryDirectory() as tmp: + folder = Path(tmp) + (folder/'Arduino.h').write_text('#pragma once\n#include \n#include \n#include \n') + (folder/'adapter.cpp').write_text(source_text) + binary=folder/'adapter' + subprocess.run([os.environ.get('CXX','g++'), '-std=c++17', + '-I',str(folder),'-I',str(ROOT),'-I',str(ROOT/'src'), + '-DESP32=1','-DBLE_PIN_CODE=1','-DWIFI_SSID=""', + '-DWITH_WEBCONFIG=1','-DMESH_PRIMARY_ESPNOW=1', + '-DENABLE_USB_INTERFACE=1','-DCOMPANION_FEATURE_TEXT_TERMINAL=1', + '-DCOMPANION_FEATURE_NETWORK_TERMINAL=1', + str(folder/'adapter.cpp'),'-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True) + + def test_role_and_restart_wiring(self): + for role, backend in [('companion_radio','companion_wireless'), + ('simple_repeater','infrastructure_wireless'), + ('simple_room_server','infrastructure_wireless')]: + source = (ROOT/'examples'/role/'main.cpp').read_text() + self.assertIn(f'control().begin({backend})', source) + self.assertIn('control().service(millis())', source[source.index('\nvoid loop()'):]) + self.assertIn('&& !mesh::wireless::control().pending()', source[source.index('\nvoid loop()'):]) + gates = { + 'src/helpers/bridges/MQTTBridge.cpp': 'WiFi', + 'src/helpers/bridges/ESPNowBridge.cpp': 'EspNow', + 'src/helpers/esp32/ESPNOWRadio.cpp': 'EspNow', + 'src/helpers/esp32/WebConfigServer.cpp': 'WiFi', + 'src/helpers/WiFiSetupPortal.cpp': 'WiFi', + 'src/helpers/MultiSerialInterface.h': 'Bluetooth', + } + for name, bit in gates.items(): + with self.subTest(file=name): + self.assertIn(f'control().blocked(mesh::wireless::{bit})', (ROOT/name).read_text()) + + def test_actual_infrastructure_transport_adapter(self): + source = r''' +#include +#include +enum {WIFI_STA=1,WIFI_OFF=0,WIFI_IF_STA=0}; +struct {bool usb=false;bool isUsbDataConnected(){return usb;}} board; +struct { + int mode_value=WIFI_STA;bool autoreconnect=true; + void setAutoReconnect(bool value){autoreconnect=value;} + void disconnect(bool,bool){} + void mode(int value){mode_value=value;} +} WiFi; +namespace mesh {namespace wifi {void applyProtocolMask(int){}void restoreEspNowChannel(){}}} +struct { + bool active=true;bool isEnabled(){return active;} + void init(){active=true;}void end(){active=false;} +} radio_driver; +struct { + bool web=true,stopping=false,bridge=true,network=false; + bool isWebConfigActive(){return web||stopping;} + bool isWebConfigStopping(){return stopping;} + bool hasWirelessNetworkClient(){return network;} + bool isBridgeRunning(){return bridge;} + bool setBridgeState(bool on){bridge=on;return true;} + bool stopWebConfig(char*){web=false;stopping=true;return true;} + bool startWebConfig(bool,char*){web=true;return true;} +} the_mesh; +#include "examples/InfrastructureWireless.h" +int main(){ + InfrastructureWirelessBackend b;mesh::wireless::Control c;c.begin(b); + char reply[160];using namespace mesh::wireless; +#ifdef WITH_MQTT_BRIDGE + assert(b.available()==(mesh::wireless::WiFi|EspNow)); + assert(b.enabled()==(mesh::wireless::WiFi|EspNow)); + assert(b.clients()==0);the_mesh.network=true;assert(b.clients()==mesh::wireless::WiFi); + c.handle("set 2.4ghz off",reply,sizeof(reply),0,mesh::wireless::WiFi);assert(strstr(reply,"Error:")); + c.handle("set 2.4ghz off",reply,sizeof(reply),0,Independent);c.service(250); + assert(c.pending()&&!the_mesh.bridge&&the_mesh.stopping&&radio_driver.active); + the_mesh.stopping=false;c.service(251); + assert(!c.pending()&&!radio_driver.active&&::WiFi.mode_value==WIFI_OFF); + c.handle("set 2.4ghz on",reply,sizeof(reply),300,Independent);c.service(550); + assert(the_mesh.bridge&&the_mesh.web&&radio_driver.active); + // Individual WiFi off retains the ESP-NOW driver and stops both its own services. + c.handle("set wifi off",reply,sizeof(reply),600,Independent);c.service(850); + the_mesh.stopping=false;c.service(851); + assert(!the_mesh.bridge&&!the_mesh.web&&radio_driver.active&&::WiFi.mode_value==WIFI_STA); + c.handle("set wifi on",reply,sizeof(reply),900,Independent);c.service(1150); + assert(the_mesh.bridge&&the_mesh.web&&radio_driver.active); + c.handle("set espnow off",reply,sizeof(reply),1200,Independent);c.service(1450); + assert(!radio_driver.active&&the_mesh.web&&the_mesh.bridge&&::WiFi.mode_value==WIFI_STA); +#else + assert(b.available()==EspNow&&b.enabled()==EspNow); + c.handle("set espnow off",reply,sizeof(reply),0,Independent);c.service(250);assert(!the_mesh.bridge); + c.handle("set espnow on",reply,sizeof(reply),300,Independent);c.service(550);assert(the_mesh.bridge); +#endif +} +''' + with tempfile.TemporaryDirectory() as tmp: + folder=Path(tmp) + (folder/'helpers/esp32').mkdir(parents=True) + (folder/'WiFi.h').write_text('#pragma once\n') + (folder/'helpers/esp32/WiFiRadioPolicy.h').write_text('#pragma once\n') + (folder/'infrastructure.cpp').write_text(source) + for defines in [('WITH_MQTT_BRIDGE','WITH_WEBCONFIG','MESH_PRIMARY_ESPNOW'),('WITH_ESPNOW_BRIDGE',)]: + with self.subTest(features=defines): + binary=folder/'infrastructure' + subprocess.run([os.environ.get('CXX','g++'),'-std=c++17','-DESP32=1', + '-I',str(folder),'-I',str(ROOT),'-I',str(ROOT/'src'), + *['-D'+d+'=1' for d in defines],str(folder/'infrastructure.cpp'), + '-o',str(binary)],check=True) + subprocess.run([str(binary)],check=True) + +if __name__ == '__main__': + unittest.main()