From 9363ad4f95fc79af96326e78dcb3b2ccc33304b6 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Thu, 15 Oct 2026 11:04:07 -0700 Subject: [PATCH] Add safe ESP32 WiFi compatibility controls and unified status --- .github/workflows/run-unit-tests.yml | 3 + docs/WiFi.md | 71 ++++ docs/cli_commands.md | 25 ++ examples/companion_radio/CompanionWiFi.h | 7 +- examples/companion_radio/MyMesh.cpp | 17 +- examples/companion_radio/main.cpp | 202 +++++++++- src/helpers/CompanionWiFiStatusFormat.h | 120 ++++++ src/helpers/bridges/MQTTBridge.cpp | 3 +- src/helpers/esp32/WebConfigServer.cpp | 3 +- src/helpers/esp32/WiFiCompatibility.h | 138 +++++++ src/helpers/esp32/WiFiRadioPolicy.h | 40 +- test/fixtures/mqtt_canonical_wifi.cpp | 5 +- .../fixtures/wifi_compatibility/Preferences.h | 37 ++ test/fixtures/wifi_compatibility/esp_err.h | 3 + test/fixtures/wifi_compatibility/esp_wifi.h | 48 +++ test/fixtures/wifi_compatibility/test.cpp | 203 ++++++++++ test/test_companion_wifi_compatibility.py | 360 ++++++++++++++++++ test/test_companion_wifi_status.py | 12 + test/test_companion_wifi_status_format.py | 149 ++++++++ test/test_indicator_exclusive_transport.py | 67 +++- test/test_mqtt_canonical_wifi.py | 10 +- test/test_wifi_compatibility.py | 70 ++++ 22 files changed, 1555 insertions(+), 38 deletions(-) create mode 100644 src/helpers/CompanionWiFiStatusFormat.h create mode 100644 src/helpers/esp32/WiFiCompatibility.h create mode 100644 test/fixtures/wifi_compatibility/Preferences.h create mode 100644 test/fixtures/wifi_compatibility/esp_err.h create mode 100644 test/fixtures/wifi_compatibility/esp_wifi.h create mode 100644 test/fixtures/wifi_compatibility/test.cpp create mode 100644 test/test_companion_wifi_compatibility.py create mode 100644 test/test_companion_wifi_status_format.py create mode 100644 test/test_wifi_compatibility.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index f8d409638..557019650 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -540,6 +540,9 @@ jobs: python3 -B test/test_wireless_control.py -v python3 -B test/test_serial_wifi_sessions.py -v python3 -B test/test_companion_wifi_listener.py -v + python3 -B test/test_wifi_compatibility.py -v + python3 -B test/test_companion_wifi_compatibility.py -v + python3 -B test/test_companion_wifi_status_format.py -v python3 -B test/test_serial_ethernet_sessions.py -v python3 -B test/test_companion_wifi_session.py -v python3 -B test/test_espnow_radio_lifecycle.py -v diff --git a/docs/WiFi.md b/docs/WiFi.md index 42b5be403..e70c49bc1 100644 --- a/docs/WiFi.md +++ b/docs/WiFi.md @@ -696,6 +696,77 @@ MQTT observer targets normally limit ESP32 WiFi transmit power to 11 dBm unless the board configuration overrides `MQTT_WIFI_TX_POWER`. This setting affects WiFi only, not LoRa transmit power. +## Companion WiFi compatibility settings + +ESP32 WiFi Companions, including Full Companion, provide two independent, +saved compatibility controls. Defaults remain `bgn` and `normal`; neither +setting changes the running connection until a reboot. They are available +through the USB terminal, framed Companion CLI over USB/BLE/TCP, and the +browser command terminal where included. + +```text +get wifi.protocol +set wifi.protocol bg +reboot +``` + +`wifi.protocol bgn` permits ordinary 802.11b/g/n. `bg` excludes 802.11n for +access-point compatibility testing; it does not change LoRa or radio2. +B/G-only is rejected when it conflicts with a primary ESP-NOW radio or an +active long-range ESP-NOW owner. The temporary setup AP retains its existing +B/G/N compatibility policy. + +The separate WiFi-only boot skips Bluetooth controller/host initialization, +not merely advertising. USB and LoRa remain available; existing Bluetooth +names, PINs and bonds are not erased. Enable and configure WiFi first, and +retain USB access when testing an unreliable WLAN: + +```text +set wifi.enabled 1 +set wifi.mode wifi-only +set wifi.powersave none +reboot +get wifi.status +``` + +Saving `none` while a WiFi-only reboot is pending queues it for that boot; +it never disables modem sleep while the current Bluetooth stack is active. +`get wifi.status` reports the connection, SSID, IP and RSSI (dBm), followed by +saved/boot/live radio settings in the same bounded reply: + +```text +> connected ssid=MyWiFi ip=192.168.11.102 rssi=-74 +protocol=bgn/bgn/0x07 mode=normal ps=min coex=yes ota=5001:listen +``` + +`protocol` lists **saved / boot-selected / live SDK bitmap** (`0x03` = B/G, +`0x07` = B/G/N). For example, `protocol=bg/bgn/0x07` shows a B/G-only change +waiting for reboot. `mode` shows one value when saved and boot choices match, +or **saved / boot-selected** when they differ, such as `wifi-only/normal`. +`ps` is the live modem-sleep mode; `coex` indicates whether Bluetooth +coexistence is required. `ota` is `off`, `:listen`, or `:client`. +`n/a` means the SDK could not read that value; it does not claim the radio is +off. IP and RSSI are omitted when not applicable. Individual getters remain +available, but no separate detailed-status command is needed. + +Test B/G-only and WiFi-only separately to identify which change helps. A +successful local test does not establish compatibility with an unavailable AP. +Turning WiFi off/on, or using `set bluetooth off`, is not equivalent to a true +WiFi-only reboot. During that boot, Bluetooth cannot be re-enabled until +`wifi.mode normal` is saved and the device is rebooted. + +To restore the usual Cascade combination: + +```text +set wifi.mode normal +set wifi.protocol bgn +set wifi.powersave min +reboot +``` + +The SenseCAP Indicator's existing exclusive `companion.transport wifi|ble` +selector remains authoritative; it does not expose `wifi.mode`. + ## Recognizing the wrong firmware `get wifi.status`, `get wifi.ssid`, `get wifi.powersave`, and `get wifi.cli` diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 1a2e4df52..315eb7938 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -372,6 +372,31 @@ reply before sending the next line. The queue exists only in that browser page; closing it stops any commands that have not yet been sent. A lost connection also stops the remaining block. +ESP32 WiFi Companions also expose AP-compatibility controls through the same +local/framed command parser: + +- `get wifi.protocol` / `set wifi.protocol ` (default `bgn`) +- `get wifi.mode` / `set wifi.mode ` (default `normal`) +- `get wifi.status` (connection plus saved/boot/live radio settings) + +The same reply includes SSID, IP address and RSSI when connected, +`protocol=//`, `mode=[/]`, live modem +sleep (`ps`), Bluetooth coexistence (`coex`), and `ota=off|:listen|:client`. +The boot mode is omitted only when it matches the saved mode; differing values +mean a reboot is needed. No `detail` argument is required. + +Both controls are saved for the next reboot, without interrupting the command +connection. `bg` excludes 802.11n and cannot override an ESP-NOW/LR owner. +`wifi-only` requires `wifi.enabled 1` and skips Bluetooth stack initialization, +while retaining USB and LoRa. To test without modem sleep, run +`set wifi.mode wifi-only`, `set wifi.powersave none`, then `reboot`; `none` +is safely deferred until that reboot if Bluetooth currently owns the radio. +Restore with `set wifi.mode normal`, `set wifi.protocol bgn`, +`set wifi.powersave min`, then `reboot`. Keep USB recovery available when +testing a WLAN that may not work. The Indicator keeps its existing +`companion.transport` selector instead of `wifi.mode`. +See [Companion WiFi compatibility settings](WiFi.md#companion-wifi-compatibility-settings). + Up/down arrow keys recall commands from the current browser session in single-command mode. A command that stops WebConfig, changes its WiFi connection, disables `wifi.cli`, or reboots the node stops the remaining block diff --git a/examples/companion_radio/CompanionWiFi.h b/examples/companion_radio/CompanionWiFi.h index cbea5cc0f..ad4994936 100644 --- a/examples/companion_radio/CompanionWiFi.h +++ b/examples/companion_radio/CompanionWiFi.h @@ -35,6 +35,7 @@ bool selectCompanionTransportMode(CompanionTransportMode mode); enum class CompanionWiFiPowerSaveResult : uint8_t { Applied, SavedForNextConnection, + SavedForNextBoot, InvalidMode, BluetoothConflict, PrimaryEspNowConflict, @@ -69,11 +70,13 @@ void formatCompanionWiFiDisplayStatus(char* reply, size_t reply_size); // Inspect the actual Companion TCP listener and completed protocol traffic; // reading this diagnostic never opens a client or restarts a live session. void formatCompanionWiFiTcpStatus(char* reply, size_t reply_size); -#ifdef WITH_WEBCONFIG +// Compatibility changes are saved for the next boot; never tear down the +// transport carrying the command or disable modem sleep under a live BLE stack. +bool handleCompanionWiFiCompatibilityCommand(const char* command, char* reply, + size_t reply_size); // Use Companion ownership state when formatting an otherwise ambiguous idle or // disconnected station; WebConfig being stopped does not mean WiFi is off. void formatCompanionWiFiStatus(char* reply, size_t reply_size); -#endif // Display callbacks only queue these requests. The main loop owns the actual // WebConfig/WiFi transitions so input handling never tears down network diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 1dc200cef..58a8fdf2b 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -4298,6 +4298,9 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, #endif #if defined(ESP32) && defined(WIFI_SSID) + if (handleCompanionWiFiCompatibilityCommand(command, reply, reply_size)) { + return true; + } if (strcmp(command, "get wifi.ip") == 0) { char ip[16] = {}; #ifdef WITH_WEBCONFIG @@ -8585,6 +8588,11 @@ bool MyMesh::applyAndSaveWiFiPowerSaving(const char* value, char* reply, snprintf(reply, reply_size, "Error: failed to save WiFi power save"); return false; } + if (result == CompanionWiFiPowerSaveResult::SavedForNextBoot) { + snprintf(reply, reply_size, + "OK - power save none saved for wifi-only boot; reboot required (current mode unchanged)"); + return true; + } syncWiFiPowerSaving(); if (result == CompanionWiFiPowerSaveResult::Applied) { @@ -10126,8 +10134,15 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, #endif terminalOutput().print(" get wifi.powersave\r\n"); terminalOutput().print(" set wifi.powersave \r\n"); + terminalOutput().print(" get wifi.protocol\r\n"); + terminalOutput().print(" get wifi.status (connection, saved/boot/live settings)\r\n"); + terminalOutput().print(" set wifi.protocol (saved; reboot required)\r\n"); +#if !defined(COMPANION_EXCLUSIVE_WIFI_BLE) + terminalOutput().print(" get wifi.mode\r\n"); + terminalOutput().print(" set wifi.mode (saved; reboot required)\r\n"); +#endif #ifdef WITH_WEBCONFIG - terminalOutput().print(" get wifi.{ssid|pwd|status}\r\n"); + terminalOutput().print(" get wifi.{ssid|pwd}\r\n"); terminalOutput().print(" set wifi.ssid \r\n"); terminalOutput().print(" set wifi.pwd \r\n"); terminalOutput().print(" get webui\r\n"); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index a9c7624d1..21fbf5d49 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -102,6 +102,7 @@ MultiSerialInterface interface_manager; #include #include #include + #include #include #include #include @@ -114,6 +115,9 @@ MultiSerialInterface interface_manager; #include #include #include + #if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) + #include + #endif SerialWifiInterface wifi_interface; static bool companion_wifi_interface_registered = false; #ifndef WIFI_PWD @@ -1584,7 +1588,9 @@ void halt() { "Companion transport: exclusive %s selected for this boot\r\n", companion_wifi_requested ? "WiFi" : "Bluetooth"); } +#endif +#if defined(BLE_PIN_CODE) static void releaseCompanionBluetoothMemoryForWiFi() { const esp_err_t result = esp_bt_mem_release(ESP_BT_MODE_BTDM); if (result == ESP_OK) { @@ -1757,7 +1763,8 @@ void halt() { : getCompanionTransportMode(); return active_mode == CompanionTransportMode::Bluetooth; #elif defined(BLE_PIN_CODE) - return true; + return mesh::wifi::activeCompanionMode() + != mesh::wifi::CompanionMode::WiFiOnly; #else return false; #endif @@ -1818,10 +1825,18 @@ void halt() { && mode == mesh::wifi::kPowerSaveMax) { return CompanionWiFiPowerSaveResult::PrimaryEspNowConflict; } - if (mesh::wifi::effectivePowerSave( + const bool bluetooth_conflict = mesh::wifi::effectivePowerSave( mode, companionWiFiBluetoothActive(), mesh::wifi::kPrimaryEspNowRadio) - != mode) { + != mode; + const bool next_boot_wifi_only = +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + false; +#else + mesh::wifi::configuredCompanionMode() + == mesh::wifi::CompanionMode::WiFiOnly; +#endif + if (bluetooth_conflict && !next_boot_wifi_only) { return CompanionWiFiPowerSaveResult::BluetoothConflict; } Preferences nvs; @@ -1829,10 +1844,17 @@ void halt() { return CompanionWiFiPowerSaveResult::StorageError; } const bool saved = - nvs.putUChar("powersave", mode) == sizeof(uint8_t); + nvs.putUChar("powersave", mode) == sizeof(uint8_t) + && nvs.getUChar("powersave", 255) == mode; nvs.end(); if (!saved) return CompanionWiFiPowerSaveResult::StorageError; + if (bluetooth_conflict) { + // Persist the requested next-boot value without feeding WIFI_PS_NONE to + // the SDK while the current boot still owns a Bluetooth controller. + return CompanionWiFiPowerSaveResult::SavedForNextBoot; + } + companion_wifi_power_save = mode; companion_wifi_power_save_loaded = true; if (WiFi.getMode() == WIFI_OFF) { @@ -1843,6 +1865,71 @@ void halt() { : CompanionWiFiPowerSaveResult::SavedForNextConnection; } + bool handleCompanionWiFiCompatibilityCommand(const char* command, char* reply, + size_t reply_size) { + if (!command || !reply || !reply_size) return false; + if (strcmp(command, "get wifi.protocol") == 0) { + snprintf(reply, reply_size, "%s (boot=%s%s)", + mesh::wifi::stationProtocolName(mesh::wifi::configuredStationProtocol()), + mesh::wifi::stationProtocolName(mesh::wifi::activeStationProtocol()), + mesh::wifi::stationProtocolRestartPending() ? "; reboot required" : ""); + return true; + } + if (strncmp(command, "set wifi.protocol", 17) == 0 + && (command[17] == 0 || command[17] == ' ' || command[17] == '\t')) { + const char* value = command + 17; + while (*value == ' ' || *value == '\t') ++value; + mesh::wifi::StationProtocol selected; + if (!mesh::wifi::parseStationProtocol(value, &selected)) { + snprintf(reply, reply_size, "Error: use set wifi.protocol bg|bgn"); + } else { + const auto result = mesh::wifi::saveStationProtocol(selected); + if (result == mesh::wifi::CompatibilitySaveResult::RadioConflict) { + snprintf(reply, reply_size, "Error: bg conflicts with ESP-NOW/LR; use bgn"); + } else if (result != mesh::wifi::CompatibilitySaveResult::Saved) { + snprintf(reply, reply_size, "Error: WiFi protocol could not be saved"); + } else { + snprintf(reply, reply_size, "OK - wifi.protocol %s saved; reboot required", value); + } + } + return true; + } +#if !defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (strcmp(command, "get wifi.mode") == 0) { + snprintf(reply, reply_size, "%s (boot=%s%s)", + mesh::wifi::companionModeName(mesh::wifi::configuredCompanionMode()), + mesh::wifi::companionModeName(mesh::wifi::activeCompanionMode()), + mesh::wifi::companionModeRestartPending() ? "; reboot required" : ""); + return true; + } + if (strncmp(command, "set wifi.mode", 13) == 0 + && (command[13] == 0 || command[13] == ' ' || command[13] == '\t')) { + const char* value = command + 13; + while (*value == ' ' || *value == '\t') ++value; + mesh::wifi::CompanionMode selected; + if (!mesh::wifi::parseCompanionMode(value, &selected)) { + snprintf(reply, reply_size, "Error: use set wifi.mode normal|wifi-only"); + } else if (selected == mesh::wifi::CompanionMode::WiFiOnly + && !the_mesh.getNodePrefs()->wifi_enabled) { + snprintf(reply, reply_size, "Error: set wifi.enabled 1 before selecting wifi-only"); + } else if (mesh::wifi::saveCompanionMode(selected) + != mesh::wifi::CompatibilitySaveResult::Saved) { + snprintf(reply, reply_size, "Error: WiFi mode could not be saved"); + } else { + snprintf(reply, reply_size, "OK - wifi.mode %s saved; reboot required%s", value, + selected == mesh::wifi::CompanionMode::WiFiOnly + ? "; Bluetooth will not start" : ""); + } + return true; + } +#endif + if (strcmp(command, "get wifi.status") == 0) { + formatCompanionWiFiStatus(reply, reply_size); + return true; + } + return false; + } + static void loadCompanionWiFiCredentials() { companion_wifi_has_credentials = WiFiSetupPortal::loadStoredCredentials( configured_wifi_ssid, sizeof(configured_wifi_ssid), @@ -1935,16 +2022,84 @@ void halt() { SerialWifiInterface::disconnectReasonName(status.lastDisconnectReason)); } -#ifdef WITH_WEBCONFIG void formatCompanionWiFiStatus(char* reply, size_t reply_size) { - const mesh::wifi::CompanionWiFiRuntimeState runtime = { - companion_wifi_requested, - companion_wifi_active, - companion_wifi_credential_reload_pending, - }; - WebConfigServer::formatWiFiStatus(reply, reply_size, &runtime); - } + if (!reply || !reply_size) return; + char ssid[33] = {}, ip[16] = {}, phy[8] = "n/a"; + const char* state = "unconfigured"; + const wl_status_t status = WiFi.status(); + bool setup_ap = false, has_rssi = false; + int rssi = 0; +#ifdef WITH_WEBCONFIG + setup_ap = WebConfigServer::getSetupInfo(ssid, sizeof(ssid), ip, sizeof(ip)); #endif + if (!setup_ap && (WiFi.getMode() & WIFI_AP) + && static_cast(WiFi.softAPIP()) != 0) { + setup_ap = true; + snprintf(ssid, sizeof(ssid), "%s", WiFi.softAPSSID().c_str()); + snprintf(ip, sizeof(ip), "%s", WiFi.softAPIP().toString().c_str()); + } + if (setup_ap) { + state = "setup-ap"; + } else { + snprintf(ssid, sizeof(ssid), "%s", configured_wifi_ssid); + if (mesh::wifi::networkPower().backgroundSuspended() + && !mesh::wifi::networkPower().foregroundActive()) { + state = "suspended(30m)"; + } else if (status == WL_CONNECTED) { + state = "connected"; + snprintf(ssid, sizeof(ssid), "%s", WiFi.SSID().c_str()); + snprintf(ip, sizeof(ip), "%s", WiFi.localIP().toString().c_str()); + has_rssi = true; + rssi = WiFi.RSSI(); + } else if (!companion_wifi_has_credentials) { + state = "unconfigured"; + } else if (!companion_wifi_requested) { + state = "off"; + } else if (!companion_wifi_active) { + state = "inactive"; + } else if (companion_wifi_credential_reload_pending) { + state = "reconnect-pending"; + } else { + switch (status) { + case WL_NO_SSID_AVAIL: state = "ssid-not-found"; break; + case WL_CONNECT_FAILED: state = "connect-failed"; break; + case WL_CONNECTION_LOST: state = "link-lost"; break; + default: state = "connecting/retrying"; break; + } + } + } + uint8_t protocols = 0; + wifi_ps_type_t power_save = WIFI_PS_MIN_MODEM; + if (esp_wifi_get_protocol(WIFI_IF_STA, &protocols) == ESP_OK) { + snprintf(phy, sizeof(phy), "0x%02x", protocols); + } + const bool ps_ready = esp_wifi_get_ps(&power_save) == ESP_OK; + const char* mode_saved; + const char* mode_boot; +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + mode_saved = getCompanionTransportMode() == CompanionTransportMode::WiFi ? "wifi" : "ble"; + mode_boot = companionTransportWiFiActiveAtBoot() ? "wifi" : "ble"; +#else + mode_saved = mesh::wifi::companionModeName(mesh::wifi::configuredCompanionMode()); + mode_boot = mesh::wifi::companionModeName(mesh::wifi::activeCompanionMode()); +#endif + bool ota_listening = false, ota_attached = false; + uint16_t ota_port = 0; +#if defined(ESP32_PLATFORM) && defined(ENABLE_OTA) + ota_listening = mesh::ota::WiFiOtaSeeder::isListening(); + ota_attached = mesh::ota::WiFiOtaSeeder::isAttached(); + ota_port = mesh::ota::WiFiOtaSeeder::port(); +#endif + const mesh::wifi::CompanionWiFiStatusSnapshot snapshot = { + state, ssid, ip, has_rssi, rssi, + mesh::wifi::stationProtocolName(mesh::wifi::configuredStationProtocol()), + mesh::wifi::stationProtocolName(mesh::wifi::activeStationProtocol()), phy, + mode_saved, mode_boot, + !ps_ready ? "n/a" : power_save == WIFI_PS_NONE ? "none" + : power_save == WIFI_PS_MAX_MODEM ? "max" : "min", + companionWiFiBluetoothActive(), ota_listening, ota_attached, ota_port}; + mesh::wifi::formatCompanionWiFiStatusSnapshot(reply, reply_size, snapshot); + } void requestCompanionWiFiSetup() { if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return; @@ -2696,6 +2851,16 @@ void halt() { static void startCompanionBluetooth() { if (companion_bluetooth_initialized) return; companion_bluetooth_start_at = 0; +#if defined(ESP32) && defined(WIFI_SSID) \ + && !defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (mesh::wifi::activeCompanionMode() + == mesh::wifi::CompanionMode::WiFiOnly) { + snprintf(companion_bluetooth_start_failure, + sizeof(companion_bluetooth_start_failure), + "wifi-only boot; set wifi.mode normal and reboot to enable Bluetooth"); + return; + } +#endif mesh::usbLoggingPort().println("Companion: starting Bluetooth"); CompanionNodePrefs* prefs = the_mesh.getNodePrefs(); @@ -3312,6 +3477,19 @@ void setup() { mesh::beginUsbLoggingPort(); board.begin(); +#if defined(ESP32) && defined(WIFI_SSID) + // MyMesh::begin can already read the power-save policy for MQTT/WebConfig. + // Freeze compatibility before that first read and before either RF stack. + mesh::wifi::loadStationProtocolForBoot(); + mesh::wifi::loadCompanionModeForBoot(); +#if defined(BLE_PIN_CODE) && !defined(COMPANION_EXCLUSIVE_WIFI_BLE) + if (mesh::wifi::activeCompanionMode() + == mesh::wifi::CompanionMode::WiFiOnly) { + releaseCompanionBluetoothMemoryForWiFi(); + } +#endif +#endif + #ifdef HAS_EXTERNAL_WATCHDOG external_watchdog.begin(); #endif diff --git a/src/helpers/CompanionWiFiStatusFormat.h b/src/helpers/CompanionWiFiStatusFormat.h new file mode 100644 index 000000000..0d26aadfb --- /dev/null +++ b/src/helpers/CompanionWiFiStatusFormat.h @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { +namespace wifi { + +// Collected by the loop-task owner. Formatting never reads NVS, touches a +// wireless driver, allocates memory, or retains pointers after the call. +struct CompanionWiFiStatusSnapshot { + const char* state; + const char* ssid; + const char* ip; + bool has_rssi; + int rssi; + const char* protocol_saved; + const char* protocol_boot; + const char* phy; + const char* mode_saved; + const char* mode_boot; + const char* powersave; + bool coexist; + bool ota_listening; + bool ota_attached; + uint16_t ota_port; +}; + +namespace status_format_detail { + +inline const char* knownText(const char* value) { + return value && value[0] ? value : "n/a"; +} + +class BoundedReply { + public: + BoundedReply(char* output, size_t capacity) + : output_(output), capacity_(capacity), used_(0), complete_(true) { + output_[0] = 0; + } + + void append(const char* text) { + for (const char* cursor = text; *cursor; ++cursor) { + if (used_ < capacity_ - 1) output_[used_++] = *cursor; + else complete_ = false; + } + output_[used_] = 0; + } + + void appendNumber(int number) { + char digits[16]; // Enough for every signed 32-bit int, including INT_MIN. + snprintf(digits, sizeof(digits), "%d", number); + append(digits); + } + + bool complete() const { return complete_; } + + private: + char* output_; + size_t capacity_; + size_t used_; + bool complete_; +}; + +} // namespace status_format_detail + +// Protocol is always saved/boot/live; mode repeats the boot choice only when +// it differs from the saved choice. All applicable fields are emitted in a +// fixed order, rather than dropping diagnostics to fit a particular buffer. +// Returns false on an invalid destination or truncation. Any nonempty valid +// destination is NUL-terminated, including a one-byte destination. +inline bool formatCompanionWiFiStatusSnapshot( + char* output, size_t capacity, const CompanionWiFiStatusSnapshot& snapshot) { + if (!output || !capacity) return false; + using status_format_detail::knownText; + status_format_detail::BoundedReply reply(output, capacity); + reply.append("> "); + reply.append(knownText(snapshot.state)); + reply.append(" ssid="); + reply.append(knownText(snapshot.ssid)); + if (snapshot.ip && snapshot.ip[0]) { + reply.append(" ip="); + reply.append(snapshot.ip); + } + if (snapshot.has_rssi) { + reply.append(" rssi="); + reply.appendNumber(snapshot.rssi); + } + reply.append("\nprotocol="); + reply.append(knownText(snapshot.protocol_saved)); + reply.append("/"); + reply.append(knownText(snapshot.protocol_boot)); + reply.append("/"); + reply.append(knownText(snapshot.phy)); + reply.append(" mode="); + const char* saved_mode = knownText(snapshot.mode_saved); + const char* boot_mode = knownText(snapshot.mode_boot); + reply.append(saved_mode); + if (strcmp(saved_mode, boot_mode) != 0) { + reply.append("/"); + reply.append(boot_mode); + } + reply.append(" ps="); + reply.append(knownText(snapshot.powersave)); + reply.append(" coex="); + reply.append(snapshot.coexist ? "yes" : "no"); + reply.append(" ota="); + if (snapshot.ota_attached || snapshot.ota_listening) { + reply.appendNumber(snapshot.ota_port); + reply.append(snapshot.ota_attached ? ":client" : ":listen"); + } else { + reply.append("off"); + } + return reply.complete(); +} + +} // namespace wifi +} // namespace mesh diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 139923ca1..ee9d471d8 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -3630,7 +3630,8 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { #if defined(COMPANION_EXCLUSIVE_WIFI_BLE) false, #elif defined(BLE_PIN_CODE) && defined(WIFI_SSID) - true, + mesh::wifi::activeCompanionMode() + != mesh::wifi::CompanionMode::WiFiOnly, #else false, #endif diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 694b0c865..68de7e7df 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -176,7 +176,8 @@ static bool bluetoothWiFiCoexistenceRequired() { // Any running WebConfig instance therefore has no active Bluetooth stack. return false; #elif defined(BLE_PIN_CODE) && defined(WIFI_SSID) - return true; + return mesh::wifi::activeCompanionMode() + != mesh::wifi::CompanionMode::WiFiOnly; #else return false; #endif diff --git a/src/helpers/esp32/WiFiCompatibility.h b/src/helpers/esp32/WiFiCompatibility.h new file mode 100644 index 000000000..cc250ec81 --- /dev/null +++ b/src/helpers/esp32/WiFiCompatibility.h @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#if defined(ESP32) +#include +#endif + +namespace mesh { +namespace wifi { + +enum class StationProtocol : uint8_t { Bgn = 0, Bg = 1 }; +enum class CompanionMode : uint8_t { Normal = 0, WiFiOnly = 1 }; +enum class CompatibilitySaveResult : uint8_t { + Saved, InvalidValue, RadioConflict, StorageError +}; +using StationProtocolSaveResult = CompatibilitySaveResult; +using CompanionModeSaveResult = CompatibilitySaveResult; + +namespace compatibility_detail { +template struct BootPreference { + bool loaded = false; + bool load_ok = false; + Value active = static_cast(0); + Value configured = static_cast(0); +}; + +inline BootPreference& protocolPreference() { + static BootPreference state; + return state; +} + +inline BootPreference& modePreference() { + static BootPreference state; + return state; +} + +#if defined(ESP32) +template +inline bool loadForBoot(const char* key, BootPreference& state, + bool reject_nondefault = false) { + if (state.loaded) return state.load_ok; + Preferences prefs; + const bool opened = prefs.begin("mesh-wifi", false); + uint8_t stored = 0; + if (opened) { + stored = prefs.getUChar(key, 0); + prefs.end(); + } + if (stored > 1 || reject_nondefault) stored = 0; + state.active = state.configured = static_cast(stored); + // Freeze even an unavailable/corrupt store to the safe default this boot. + state.loaded = true; + state.load_ok = opened; + return opened; +} + +template +inline CompatibilitySaveResult save(const char* key, Value value, + BootPreference& state) { + if (static_cast(value) > 1) { + return CompatibilitySaveResult::InvalidValue; + } + if (!state.loaded) return CompatibilitySaveResult::StorageError; + Preferences prefs; + if (!prefs.begin("mesh-wifi", false)) { + return CompatibilitySaveResult::StorageError; + } + const uint8_t stored = static_cast(value); + const bool saved = prefs.putUChar(key, stored) == sizeof(uint8_t) + && prefs.getUChar(key, 255) == stored; + if (!saved) { + // A failed verification must not knowingly leave the requested mode saved. + prefs.putUChar(key, static_cast(state.configured)); + } + prefs.end(); + if (!saved) return CompatibilitySaveResult::StorageError; + state.configured = value; + return CompatibilitySaveResult::Saved; +} +#endif +} // namespace compatibility_detail + +// Reads are deliberately NVS-free: boot explicitly loads these before either +// radio starts; saving only changes the configuration for the next reboot. +inline StationProtocol activeStationProtocol() { + return compatibility_detail::protocolPreference().active; +} +inline StationProtocol configuredStationProtocol() { + return compatibility_detail::protocolPreference().configured; +} +inline bool stationProtocolRestartPending() { + return configuredStationProtocol() != activeStationProtocol(); +} +inline const char* stationProtocolName(StationProtocol value) { + return value == StationProtocol::Bg ? "bg" : "bgn"; +} +inline bool parseStationProtocol(const char* value, StationProtocol* out) { + if (value == nullptr || out == nullptr) return false; + if (strcmp(value, "bgn") == 0) *out = StationProtocol::Bgn; + else if (strcmp(value, "bg") == 0) *out = StationProtocol::Bg; + else return false; + return true; +} + +inline CompanionMode activeCompanionMode() { + return compatibility_detail::modePreference().active; +} +inline CompanionMode configuredCompanionMode() { + return compatibility_detail::modePreference().configured; +} +inline bool companionModeRestartPending() { + return configuredCompanionMode() != activeCompanionMode(); +} +inline const char* companionModeName(CompanionMode value) { + return value == CompanionMode::WiFiOnly ? "wifi-only" : "normal"; +} +inline bool parseCompanionMode(const char* value, CompanionMode* out) { + if (value == nullptr || out == nullptr) return false; + if (strcmp(value, "normal") == 0) *out = CompanionMode::Normal; + else if (strcmp(value, "wifi-only") == 0) *out = CompanionMode::WiFiOnly; + else return false; + return true; +} + +#if defined(ESP32) +inline bool loadCompanionModeForBoot() { + return compatibility_detail::loadForBoot( + "wifi_only", compatibility_detail::modePreference()); +} +inline CompatibilitySaveResult saveCompanionMode(CompanionMode value) { + return compatibility_detail::save( + "wifi_only", value, compatibility_detail::modePreference()); +} +#endif + +} // namespace wifi +} // namespace mesh diff --git a/src/helpers/esp32/WiFiRadioPolicy.h b/src/helpers/esp32/WiFiRadioPolicy.h index 18c0f26f3..f20c7865a 100644 --- a/src/helpers/esp32/WiFiRadioPolicy.h +++ b/src/helpers/esp32/WiFiRadioPolicy.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW @@ -47,6 +48,11 @@ static constexpr uint8_t kProtocolMask = WIFI_PROTOCOL_11B static constexpr uint8_t kAccessPointProtocolMask = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N; +inline uint8_t conventionalStationProtocolMask() { + return activeStationProtocol() == StationProtocol::Bg + ? WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G : kAccessPointProtocolMask; +} + static constexpr uint8_t kLongRangeRadioOwner = 1; static constexpr uint8_t kLongRangeBridgeOwner = 2; inline std::atomic& longRangeOwners() { @@ -61,6 +67,25 @@ inline bool accessPointCompatibleWithLongRange() { return longRangeOwners().load() == 0; } +#if defined(ESP32) +inline bool loadStationProtocolForBoot() { + return compatibility_detail::loadForBoot( + "sta_protocol", compatibility_detail::protocolPreference(), + kPrimaryEspNowRadio || !accessPointCompatibleWithLongRange()); +} +inline CompatibilitySaveResult saveStationProtocol(StationProtocol value) { + if (static_cast(value) > 1) { + return CompatibilitySaveResult::InvalidValue; + } + if (value == StationProtocol::Bg + && (kPrimaryEspNowRadio || !accessPointCompatibleWithLongRange())) { + return CompatibilitySaveResult::RadioConflict; + } + return compatibility_detail::save( + "sta_protocol", value, compatibility_detail::protocolPreference()); +} +#endif + // 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() { @@ -133,6 +158,9 @@ inline bool saveConfiguredEspNowChannel(uint8_t) { // Check the SDK rather than Arduino's facade: an ESP-NOW bridge can own a // driver which Arduino has not initialized. An absent driver is safe to start. inline esp_err_t checkLongRangeRadioStart() { + if (activeStationProtocol() == StationProtocol::Bg) { + return ESP_ERR_INVALID_STATE; + } wifi_mode_t mode = WIFI_MODE_NULL; const esp_err_t result = esp_wifi_get_mode(&mode); if (result == ESP_ERR_WIFI_NOT_INIT) return ESP_OK; @@ -154,11 +182,19 @@ inline esp_err_t applyAccessPointProtocolMask() { } inline esp_err_t applyStationProtocolMask(uint8_t protocols, bool require_lr = false) { + if (activeStationProtocol() == StationProtocol::Bg) { + if (require_lr || kPrimaryEspNowRadio + || !accessPointCompatibleWithLongRange()) return ESP_ERR_INVALID_STATE; + } wifi_mode_t mode = WIFI_MODE_NULL; esp_err_t result = esp_wifi_get_mode(&mode); if (result != ESP_OK) return result; if ((mode & WIFI_MODE_AP) && require_lr) return ESP_ERR_INVALID_STATE; if (mode & WIFI_MODE_AP) protocols = kAccessPointProtocolMask; + // Setup AP beacons stay B/G/N; only the station uses compatibility BG. + if (activeStationProtocol() == StationProtocol::Bg) { + protocols = conventionalStationProtocolMask(); + } // persistent(false) only changes Arduino's next initialization preference. // Select RAM on this already initialized SDK driver before any LR write. @@ -177,7 +213,7 @@ inline esp_err_t applyStationProtocolMask(uint8_t protocols, bool require_lr = f // An established LR owner already prevents AP startup. Do not silently // downgrade that owner when a later infrastructure-only mode read fails. if (accessPointCompatibleWithLongRange()) { - esp_wifi_set_protocol(WIFI_IF_STA, kAccessPointProtocolMask); + esp_wifi_set_protocol(WIFI_IF_STA, conventionalStationProtocolMask()); applyAccessPointProtocolMask(); } return result; @@ -185,7 +221,7 @@ inline esp_err_t applyStationProtocolMask(uint8_t protocols, bool require_lr = f if (mode & WIFI_MODE_AP) { result = applyAccessPointProtocolMask(); if (result != ESP_OK) { - esp_wifi_set_protocol(WIFI_IF_STA, kAccessPointProtocolMask); + esp_wifi_set_protocol(WIFI_IF_STA, conventionalStationProtocolMask()); return result; } uint8_t actual = 0; diff --git a/test/fixtures/mqtt_canonical_wifi.cpp b/test/fixtures/mqtt_canonical_wifi.cpp index 1a6085964..de5315edd 100644 --- a/test/fixtures/mqtt_canonical_wifi.cpp +++ b/test/fixtures/mqtt_canonical_wifi.cpp @@ -3,6 +3,7 @@ #include #include #include "helpers/esp32/WiFiCredentials.h" +#include "helpers/esp32/WiFiCompatibility.h" #include "helpers/MQTTPrefsStorage.h" using mesh::wifi::CredentialState; @@ -15,7 +16,7 @@ void beginStation(const char* ssid, const char* password) { joined_ssid = ssid; joined_password = password; ++joins; } }} -uint8_t effectiveWiFiPowerSave(uint8_t value) { return value; } +@WEB_POLICY@ enum { WIFI_OFF, WIFI_STA, WIFI_PS_NONE, WIFI_PS_MAX_MODEM, WIFI_PS_MIN_MODEM }; struct { int getMode() { return WIFI_STA; } } WiFi; using wifi_ps_type_t = int; @@ -41,7 +42,6 @@ struct WebConfigServer { struct Entry { const char* key; char reply[160] = {}; }; Entry _batch[3] = {{"wifi.ssid"}, {"wifi.pwd"}, {"wifi.powersave"}}; int _batch_count = 3; - bool bluetoothWiFiCoexistenceRequired() { return false; } bool reloadStandaloneWiFi(); bool acceptWiFi(Entry& e, const char* value) { @CREDENTIAL_BATCH@ @@ -62,6 +62,7 @@ struct WebConfigServer { static bool setStandaloneWiFiPassword(const char*, char*, size_t, const void* = nullptr); static bool setStandaloneWiFiPowerSave(const char*, char*, size_t, const void* = nullptr); static bool formatWiFiPassword(char*, size_t, const void* = nullptr); + static bool formatWiFiPowerSave(char*, size_t, const void* = nullptr); }; @WEB_METHODS@ diff --git a/test/fixtures/wifi_compatibility/Preferences.h b/test/fixtures/wifi_compatibility/Preferences.h new file mode 100644 index 000000000..068e12c0c --- /dev/null +++ b/test/fixtures/wifi_compatibility/Preferences.h @@ -0,0 +1,37 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace fake_nvs { +inline std::map bytes; +inline int opens = 0, closes = 0, reads = 0, writes = 0; +inline bool open_fails = false, write_fails = false, verify_fails = false; +} + +class Preferences { + bool open = false; +public: + bool begin(const char* name, bool read_only) { + assert(std::string(name) == "mesh-wifi" && !read_only); + ++fake_nvs::opens; + open = !fake_nvs::open_fails; + return open; + } + void end() { assert(open); open = false; ++fake_nvs::closes; } + bool isKey(const char* key) { assert(open); return fake_nvs::bytes.count(key); } + uint8_t getUChar(const char* key, uint8_t fallback) { + assert(open); ++fake_nvs::reads; + if (fake_nvs::verify_fails && fallback == 255) return fallback; + auto found = fake_nvs::bytes.find(key); + return found == fake_nvs::bytes.end() ? fallback : found->second; + } + size_t putUChar(const char* key, uint8_t value) { + assert(open); ++fake_nvs::writes; + if (fake_nvs::write_fails) return 0; + fake_nvs::bytes[key] = value; + return 1; + } +}; diff --git a/test/fixtures/wifi_compatibility/esp_err.h b/test/fixtures/wifi_compatibility/esp_err.h new file mode 100644 index 000000000..783a61dd1 --- /dev/null +++ b/test/fixtures/wifi_compatibility/esp_err.h @@ -0,0 +1,3 @@ +#pragma once +using esp_err_t = int; +constexpr int ESP_OK = 0, ESP_ERR_INVALID_STATE = -2, ESP_ERR_WIFI_NOT_INIT = -3; diff --git a/test/fixtures/wifi_compatibility/esp_wifi.h b/test/fixtures/wifi_compatibility/esp_wifi.h new file mode 100644 index 000000000..7182f1857 --- /dev/null +++ b/test/fixtures/wifi_compatibility/esp_wifi.h @@ -0,0 +1,48 @@ +#pragma once +#include +#include "esp_err.h" +using wifi_interface_t = int; +using wifi_mode_t = int; +using wifi_second_chan_t = int; +constexpr int WIFI_MODE_NULL = 0, WIFI_MODE_STA = 1, WIFI_MODE_AP = 2; +constexpr int WIFI_IF_STA = 0, WIFI_IF_AP = 1, WIFI_STORAGE_RAM = 0; +constexpr int WIFI_SECOND_CHAN_NONE = 0; +constexpr int WIFI_PROTOCOL_11B = 1, WIFI_PROTOCOL_11G = 2; +constexpr int WIFI_PROTOCOL_11N = 4, WIFI_PROTOCOL_LR = 8; +namespace fake_wifi { +inline wifi_mode_t mode = WIFI_MODE_STA; +inline uint8_t station = 7, access_point = 7; +inline int station_writes = 0, ap_writes = 0, storage_writes = 0, mode_reads = 0; +inline int mode_read_failure = 0; +inline bool mode_fails = false, station_fails = false, ap_fails = false; +inline bool ap_appears = false; +} +inline int esp_wifi_get_mode(wifi_mode_t* value) { + ++fake_wifi::mode_reads; + if (fake_wifi::mode_fails || fake_wifi::mode_read_failure == fake_wifi::mode_reads) { + return ESP_ERR_WIFI_NOT_INIT; + } + *value = fake_wifi::mode; return ESP_OK; +} +inline int esp_wifi_set_protocol(wifi_interface_t interface, uint8_t mask) { + if (interface == WIFI_IF_STA) { + ++fake_wifi::station_writes; + if (fake_wifi::station_fails) return -4; + fake_wifi::station = mask; + if (fake_wifi::ap_appears) fake_wifi::mode |= WIFI_MODE_AP; + } else { + ++fake_wifi::ap_writes; + if (fake_wifi::ap_fails) return -5; + fake_wifi::access_point = mask; + } + return ESP_OK; +} +inline int esp_wifi_get_protocol(wifi_interface_t interface, uint8_t* mask) { + *mask = interface == WIFI_IF_STA ? fake_wifi::station : fake_wifi::access_point; + return ESP_OK; +} +inline int esp_wifi_set_storage(int) { ++fake_wifi::storage_writes; return ESP_OK; } +inline int esp_wifi_get_channel(uint8_t* channel, wifi_second_chan_t* secondary) { + *channel = 1; *secondary = 0; return ESP_OK; +} +inline int esp_wifi_set_channel(uint8_t, wifi_second_chan_t) { return ESP_OK; } diff --git a/test/fixtures/wifi_compatibility/test.cpp b/test/fixtures/wifi_compatibility/test.cpp new file mode 100644 index 000000000..b4d9993b4 --- /dev/null +++ b/test/fixtures/wifi_compatibility/test.cpp @@ -0,0 +1,203 @@ +#include +#include +#include +#include + +using namespace mesh::wifi; + +static void load() { + assert(loadStationProtocolForBoot()); + assert(loadCompanionModeForBoot()); +} +static void reboot() { + compatibility_detail::protocolPreference() = {}; + compatibility_detail::modePreference() = {}; + load(); +} + +int main(int argc, char** argv) { + assert(argc == 2); + const std::string test = argv[1]; + if (test == "defaults") { + assert(activeStationProtocol() == StationProtocol::Bgn); + assert(configuredStationProtocol() == StationProtocol::Bgn); + assert(activeCompanionMode() == CompanionMode::Normal); + assert(!stationProtocolRestartPending() && !companionModeRestartPending()); + assert(fake_nvs::opens == 0); + load(); + assert(fake_nvs::opens == 2 && fake_nvs::closes == 2); + assert(fake_nvs::writes == 0 && fake_nvs::bytes.empty()); + const int opens = fake_nvs::opens, reads = fake_nvs::reads; + for (int i = 0; i < 1000; ++i) { + load(); + assert(activeStationProtocol() == StationProtocol::Bgn); + assert(activeCompanionMode() == CompanionMode::Normal); + assert(applyProtocolMask(WIFI_IF_STA) == ESP_OK); + } + assert(fake_nvs::opens == opens && fake_nvs::reads == reads); + } else if (test == "parse") { + StationProtocol protocol = StationProtocol::Bg; + CompanionMode mode = CompanionMode::WiFiOnly; + assert(parseStationProtocol("bgn", &protocol) && protocol == StationProtocol::Bgn); + assert(parseStationProtocol("bg", &protocol) && protocol == StationProtocol::Bg); + assert(!parseStationProtocol("n", &protocol) && protocol == StationProtocol::Bg); + assert(!parseStationProtocol("bg extra", &protocol)); + assert(!parseStationProtocol("BG", &protocol)); + assert(!parseStationProtocol(nullptr, &protocol)); + assert(!parseStationProtocol("bg", nullptr)); + assert(parseCompanionMode("normal", &mode) && mode == CompanionMode::Normal); + assert(parseCompanionMode("wifi-only", &mode) && mode == CompanionMode::WiFiOnly); + assert(!parseCompanionMode("wifi", &mode) && mode == CompanionMode::WiFiOnly); + assert(!parseCompanionMode("wifi-only extra", &mode)); + assert(!parseCompanionMode(nullptr, &mode)); + assert(!parseCompanionMode("normal", nullptr)); + assert(std::strcmp(stationProtocolName(StationProtocol::Bg), "bg") == 0); + assert(std::strcmp(stationProtocolName(StationProtocol::Bgn), "bgn") == 0); + assert(std::strcmp(companionModeName(CompanionMode::WiFiOnly), "wifi-only") == 0); + assert(std::strcmp(companionModeName(CompanionMode::Normal), "normal") == 0); + assert(fake_nvs::opens == 0); + } else if (test == "protocol_reboot") { + load(); + assert(saveStationProtocol(StationProtocol::Bg) == CompatibilitySaveResult::Saved); + assert(fake_nvs::bytes.at("sta_protocol") == 1); + assert(configuredStationProtocol() == StationProtocol::Bg); + assert(activeStationProtocol() == StationProtocol::Bgn && stationProtocolRestartPending()); + assert(fake_wifi::station_writes == 0); + assert(applyProtocolMask(WIFI_IF_STA) == ESP_OK && fake_wifi::station == kProtocolMask); + load(); + assert(activeStationProtocol() == StationProtocol::Bgn); + reboot(); + assert(activeStationProtocol() == StationProtocol::Bg && !stationProtocolRestartPending()); + const int storage_writes = fake_wifi::storage_writes; + assert(applyProtocolMask(WIFI_IF_STA) == ESP_OK && fake_wifi::station == 3); + assert(fake_wifi::storage_writes == storage_writes); + assert(saveStationProtocol(StationProtocol::Bgn) == CompatibilitySaveResult::Saved); + assert(activeStationProtocol() == StationProtocol::Bg && stationProtocolRestartPending()); + assert(applyProtocolMask(WIFI_IF_STA) == ESP_OK && fake_wifi::station == 3); + reboot(); + assert(activeStationProtocol() == StationProtocol::Bgn && !stationProtocolRestartPending()); + assert(applyProtocolMask(WIFI_IF_STA) == ESP_OK && fake_wifi::station == kProtocolMask); + } else if (test == "mode_reboot") { + load(); + assert(saveCompanionMode(CompanionMode::WiFiOnly) == CompatibilitySaveResult::Saved); + assert(fake_nvs::bytes.at("wifi_only") == 1); + assert(configuredCompanionMode() == CompanionMode::WiFiOnly); + assert(activeCompanionMode() == CompanionMode::Normal && companionModeRestartPending()); + assert(fake_wifi::station_writes == 0); + load(); assert(activeCompanionMode() == CompanionMode::Normal); + reboot(); + assert(activeCompanionMode() == CompanionMode::WiFiOnly && !companionModeRestartPending()); + assert(saveCompanionMode(CompanionMode::Normal) == CompatibilitySaveResult::Saved); + assert(activeCompanionMode() == CompanionMode::WiFiOnly && companionModeRestartPending()); + reboot(); + assert(activeCompanionMode() == CompanionMode::Normal && !companionModeRestartPending()); + assert(!stationProtocolRestartPending()); + } else if (test == "corrupt") { + fake_nvs::bytes["sta_protocol"] = 254; + fake_nvs::bytes["wifi_only"] = 2; + load(); + assert(activeStationProtocol() == StationProtocol::Bgn); + assert(activeCompanionMode() == CompanionMode::Normal); + assert(fake_nvs::writes == 0); + assert(!stationProtocolRestartPending() && !companionModeRestartPending()); + } else if (test == "invalid") { + load(); const int opens = fake_nvs::opens; + assert(saveStationProtocol(static_cast(2)) == CompatibilitySaveResult::InvalidValue); + assert(saveCompanionMode(static_cast(255)) == CompatibilitySaveResult::InvalidValue); + assert(fake_nvs::opens == opens && fake_nvs::writes == 0); + assert(!stationProtocolRestartPending() && !companionModeRestartPending()); + } else if (test == "unloaded") { + assert(saveStationProtocol(StationProtocol::Bgn) == CompatibilitySaveResult::StorageError); + assert(saveCompanionMode(CompanionMode::WiFiOnly) == CompatibilitySaveResult::StorageError); + assert(fake_nvs::opens == 0); + } else if (test == "open_failure") { + fake_nvs::open_fails = true; + assert(!loadStationProtocolForBoot() && !loadCompanionModeForBoot()); + assert(activeStationProtocol() == StationProtocol::Bgn); + assert(activeCompanionMode() == CompanionMode::Normal); + assert(!loadStationProtocolForBoot() && !loadCompanionModeForBoot()); + assert(fake_nvs::opens == 2 && fake_nvs::closes == 0); + assert(saveCompanionMode(CompanionMode::WiFiOnly) == CompatibilitySaveResult::StorageError); + assert(saveStationProtocol(StationProtocol::Bgn) == CompatibilitySaveResult::StorageError); + assert(!stationProtocolRestartPending() && !companionModeRestartPending()); + fake_nvs::open_fails = false; + assert(saveCompanionMode(CompanionMode::WiFiOnly) == CompatibilitySaveResult::Saved); + assert(activeCompanionMode() == CompanionMode::Normal && companionModeRestartPending()); + } else if (test == "write_failure" || test == "verify_failure") { + load(); + fake_nvs::write_fails = test == "write_failure"; + fake_nvs::verify_fails = test == "verify_failure"; + assert(saveCompanionMode(CompanionMode::WiFiOnly) == CompatibilitySaveResult::StorageError); + assert(saveStationProtocol(StationProtocol::Bgn) == CompatibilitySaveResult::StorageError); + assert(activeCompanionMode() == CompanionMode::Normal); + assert(configuredCompanionMode() == CompanionMode::Normal); + if (test == "verify_failure") assert(fake_nvs::bytes.at("wifi_only") == 0); + assert(!stationProtocolRestartPending() && !companionModeRestartPending()); + assert(fake_nvs::opens == fake_nvs::closes); + } else if (test == "independent") { + fake_nvs::bytes["sta_protocol"] = 1; + fake_nvs::bytes["wifi_only"] = 1; + fake_nvs::bytes["powersave"] = 2; + load(); + assert(activeCompanionMode() == CompanionMode::WiFiOnly); + assert(activeStationProtocol() == StationProtocol::Bg); + assert(saveCompanionMode(CompanionMode::Normal) == CompatibilitySaveResult::Saved); + assert(fake_nvs::bytes.at("sta_protocol") == 1 && fake_nvs::bytes.at("powersave") == 2); + assert(saveStationProtocol(StationProtocol::Bgn) == CompatibilitySaveResult::Saved); + assert(fake_nvs::bytes.at("wifi_only") == 0 && fake_nvs::bytes.at("powersave") == 2); + } else if (test == "bg_ap") { + fake_nvs::bytes["sta_protocol"] = 1; load(); + fake_wifi::mode = WIFI_MODE_STA | WIFI_MODE_AP; + assert(applyProtocolMask(WIFI_IF_STA) == ESP_OK); + assert(fake_wifi::station == 3 && fake_wifi::access_point == 7); + assert(applyAccessPointProtocolMask() == ESP_OK && fake_wifi::access_point == 7); + assert(checkLongRangeRadioStart() == ESP_ERR_INVALID_STATE); + } else if (test == "bg_ap_race") { + fake_nvs::bytes["sta_protocol"] = 1; load(); + fake_wifi::ap_appears = true; + assert(applyProtocolMask(WIFI_IF_STA) == ESP_OK); + assert(fake_wifi::station == 3 && fake_wifi::access_point == 7); + } else if (test == "bg_lr_start") { + fake_nvs::bytes["sta_protocol"] = 1; load(); + assert(checkLongRangeRadioStart() == ESP_ERR_INVALID_STATE); + assert(applyStationProtocolMask(15, true) == ESP_ERR_INVALID_STATE); + assert(applyStationProtocolMask(8, true) == ESP_ERR_INVALID_STATE); + assert(fake_wifi::station_writes == 0 && fake_wifi::storage_writes == 0); + setLongRangeOwner(kLongRangeBridgeOwner, true); + assert(applyProtocolMask(WIFI_IF_STA) == ESP_ERR_INVALID_STATE); + setLongRangeOwner(kLongRangeBridgeOwner, false); + assert(applyProtocolMask(WIFI_IF_STA) == ESP_OK && fake_wifi::station == 3); + } else if (test == "owner_conflict") { + load(); const int opens = fake_nvs::opens; + setLongRangeOwner(kLongRangeBridgeOwner, true); + assert(saveStationProtocol(StationProtocol::Bg) == CompatibilitySaveResult::RadioConflict); + assert(fake_nvs::opens == opens && fake_nvs::writes == 0); + setLongRangeOwner(kLongRangeBridgeOwner, false); + assert(saveStationProtocol(StationProtocol::Bg) == CompatibilitySaveResult::Saved); + assert(activeStationProtocol() == StationProtocol::Bgn); + } else if (test == "primary_conflict") { + fake_nvs::bytes["sta_protocol"] = 1; load(); + assert(kPrimaryEspNowRadio); + assert(activeStationProtocol() == StationProtocol::Bgn && !stationProtocolRestartPending()); + const int opens = fake_nvs::opens; + assert(saveStationProtocol(StationProtocol::Bg) == CompatibilitySaveResult::RadioConflict); + assert(fake_nvs::opens == opens && fake_nvs::writes == 0); + assert(applyProtocolMask(WIFI_IF_STA) == ESP_OK && fake_wifi::station == kProtocolMask); + assert(saveStationProtocol(StationProtocol::Bgn) == CompatibilitySaveResult::Saved); + } else if (test == "driver_failure") { + fake_nvs::bytes["sta_protocol"] = 1; load(); + fake_wifi::mode_fails = true; + assert(applyProtocolMask(WIFI_IF_STA) != ESP_OK && fake_wifi::station_writes == 0); + fake_wifi::mode_fails = false; fake_wifi::station_fails = true; + assert(applyProtocolMask(WIFI_IF_STA) != ESP_OK); + fake_wifi::station_fails = false; fake_wifi::ap_fails = true; + fake_wifi::mode |= WIFI_MODE_AP; + assert(applyProtocolMask(WIFI_IF_STA) != ESP_OK); + assert(fake_wifi::station == 3); + fake_wifi::ap_fails = false; + fake_wifi::mode_reads = 0; + fake_wifi::mode_read_failure = 2; + assert(applyProtocolMask(WIFI_IF_STA) != ESP_OK); + assert(fake_wifi::station == 3 && fake_wifi::access_point == 7); + } else return 2; +} diff --git a/test/test_companion_wifi_compatibility.py b/test/test_companion_wifi_compatibility.py new file mode 100644 index 000000000..6265c3d30 --- /dev/null +++ b/test/test_companion_wifi_compatibility.py @@ -0,0 +1,360 @@ +"""Execute the actual Companion BLE boot guard and WiFi power-save resolver.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include "examples/companion_radio/BluetoothName.h" +#include +#include +#include +#include "examples/companion_radio/CompanionWiFi.h" +#if defined(MESH_TEST_NON_ESP32) +#undef ESP32 +#endif + +uint32_t now_ms = 100; +uint32_t millis() { return now_ms; } +unsigned identity_calls=0, address_calls=0, begin_calls=0, enable_calls=0; +unsigned register_calls=0, sdk_ps_calls=0; +bool identity_ok=true, register_ok=true, begin_ok=true; +enum class InterfaceType { Bluetooth }; +struct CompanionNodePrefs { + char bluetooth_name[32]={}, node_name[32]="test node"; + uint8_t bluetooth_stealth_mode=0, bluetooth_stealth_peer_type=0; + uint8_t bluetooth_stealth_peer[6]={}; + uint8_t powersaving_enabled=0, wifi_enabled=0; +}; +struct Mesh { + CompanionNodePrefs prefs; + bool preference=true; + CompanionNodePrefs* getNodePrefs() { return &prefs; } + uint32_t getBLEPin() { return 123456; } + bool isBluetoothEnabledPreference() { return preference; } +} the_mesh; +struct { + bool addInterface(InterfaceType, void*) { ++register_calls; return register_ok; } + bool removeInterface(void*) { return true; } + bool isEnabled() const { return true; } +} interface_manager; +struct { + template bool begin(T...) { ++begin_calls; return begin_ok; } + void enable() { ++enable_calls; } + const char* beginFailure() const { return "test failure"; } +} bluetooth_interface; +namespace mesh { +struct Logger { + template void printf(T...) {} + void println(const char*) {} +} logger; +Logger& usbLoggingPort() { return logger; } +} +bool prepareCompanionBluetoothIdentity(CompanionNodePrefs*) { + ++identity_calls; return identity_ok; +} +const uint8_t* companionBluetoothAddress(const CompanionNodePrefs*, bool& clear) { + ++address_calls; clear=false; return nullptr; +} +bool companion_bluetooth_initialized=false; +uint32_t companion_bluetooth_start_at=0; +constexpr uint32_t COMPANION_BLUETOOTH_RETRY_MS=5000; +char companion_bluetooth_start_failure[96]="Bluetooth has not started"; +uint8_t companion_bluetooth_session_stealth_mode=0; +#define BLE_NAME_PREFIX "MeshCore " +#define COMPANION_BT_MODEM_SLEEP_AVAILABLE 0 + +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) +CompanionTransportMode companion_transport_boot_mode=CompanionTransportMode::Bluetooth; +bool companion_transport_boot_mode_loaded=true; +CompanionTransportMode getCompanionTransportMode() { + return companion_transport_boot_mode; +} +bool companionTransportWiFiActiveAtBoot() { + return companion_transport_boot_mode==CompanionTransportMode::WiFi; +} +#endif + +enum wifi_ps_type_t { WIFI_PS_NONE, WIFI_PS_MIN_MODEM, WIFI_PS_MAX_MODEM }; +constexpr int ESP_OK=0, WIFI_OFF=0, WIFI_IF_STA=0, WIFI_AP=2; +enum wl_status_t { WL_CONNECTED=3, WL_NO_SSID_AVAIL=1, WL_CONNECT_FAILED=4, + WL_CONNECTION_LOST=5, WL_DISCONNECTED=6 }; +wifi_ps_type_t last_ps=WIFI_PS_MIN_MODEM; +struct TestIP { + uint32_t value=0; + operator uint32_t() const { return value; } + std::string toString() const { return value ? "192.168.11.102" : "0.0.0.0"; } +}; +struct { + int mode=1; + wl_status_t state=WL_CONNECTED; + int getMode() const { return mode; } + wl_status_t status() const { return state; } + std::string SSID() const { return "test WLAN"; } + std::string softAPSSID() const { return "MeshCore-Setup"; } + TestIP localIP() const { return {1}; } + TestIP softAPIP() const { return {uint32_t(mode & WIFI_AP)}; } + int RSSI() const { return -74; } +} WiFi; +char configured_wifi_ssid[32]="test WLAN"; +bool companion_wifi_requested=true, companion_wifi_active=true; +bool companion_wifi_has_credentials=true, companion_wifi_credential_reload_pending=false; +int esp_wifi_set_ps(wifi_ps_type_t value) { + ++sdk_ps_calls; last_ps=value; return ESP_OK; +} +int esp_wifi_get_ps(wifi_ps_type_t* value) { *value=last_ps; return ESP_OK; } +int esp_wifi_get_protocol(int, uint8_t* value) { *value=7; return ESP_OK; } +bool companion_wifi_power_save_loaded=false; +uint8_t companion_wifi_power_save=mesh::wifi::kDefaultPowerSave; +namespace mesh { namespace wifi { +constexpr bool kPrimaryEspNowRadio=false; +struct StatusPower { + bool suspended=false, foreground=false; + bool backgroundSuspended() const { return suspended; } + bool foregroundActive() const { return foreground; } +}; +StatusPower& networkPower() { static StatusPower value; return value; } +// Protocol persistence has its own real SDK-boundary suite; only the endpoint +// is mocked here so the complete production CLI handler can be executed. +CompatibilitySaveResult saveStationProtocol(StationProtocol value) { + return compatibility_detail::save("phy", value, compatibility_detail::protocolPreference()); +} +} } + +@METHODS@ + +void rebootCompatibility() { + mesh::wifi::compatibility_detail::modePreference()={}; + assert(mesh::wifi::loadCompanionModeForBoot()); +} +void resetBootCounters() { + identity_calls=address_calls=begin_calls=enable_calls=register_calls=0; + companion_bluetooth_initialized=false; + companion_bluetooth_start_at=0; +} +int main() { + using namespace mesh::wifi; + reset_nvs(); rebootCompatibility(); + assert(activeCompanionMode()==CompanionMode::Normal); +#if defined(ESP32) && defined(WIFI_SSID) + char reply[192]={}; + for (const char* text : {"get wifi.modex", "set wifi.modex wifi-only", "get unknown", + "get wifi.compat", "get wifi.status detail", "get wifi.status detailx"}) + assert(!handleCompanionWiFiCompatibilityCommand(text,reply,sizeof(reply))); + assert(!handleCompanionWiFiCompatibilityCommand(nullptr,reply,sizeof(reply))); + assert(!handleCompanionWiFiCompatibilityCommand("get wifi.status",nullptr,192)); + assert(!handleCompanionWiFiCompatibilityCommand("get wifi.status",reply,0)); +#if !defined(COMPANION_EXCLUSIVE_WIFI_BLE) + for (const char* text : {"set wifi.mode", "set wifi.mode none", "set wifi.mode wifi-only extra"}) { + assert(handleCompanionWiFiCompatibilityCommand(text,reply,sizeof(reply))); + assert(strstr(reply,"Error:") && configuredCompanionMode()==CompanionMode::Normal); + } + assert(handleCompanionWiFiCompatibilityCommand("set wifi.mode wifi-only",reply,sizeof(reply))); + assert(strstr(reply,"wifi.enabled 1") && configuredCompanionMode()==CompanionMode::Normal); + the_mesh.prefs.wifi_enabled=1; + assert(handleCompanionWiFiCompatibilityCommand("set wifi.mode\twifi-only",reply,sizeof(reply))); + assert(strstr(reply,"reboot required") && configuredCompanionMode()==CompanionMode::WiFiOnly); + assert(handleCompanionWiFiCompatibilityCommand("get wifi.mode",reply,sizeof(reply))); + assert(strstr(reply,"wifi-only") && strstr(reply,"boot=normal") && strstr(reply,"reboot required")); + assert(!identity_calls && !begin_calls && !register_calls); + assert(handleCompanionWiFiCompatibilityCommand("set wifi.mode normal",reply,sizeof(reply))); + nvs_available=false; + assert(handleCompanionWiFiCompatibilityCommand("set wifi.mode wifi-only",reply,sizeof(reply))); + assert(strstr(reply,"could not be saved") && configuredCompanionMode()==CompanionMode::Normal); + nvs_available=true; +#else + assert(!handleCompanionWiFiCompatibilityCommand("get wifi.mode",reply,sizeof(reply))); + assert(!handleCompanionWiFiCompatibilityCommand("set wifi.mode wifi-only",reply,sizeof(reply))); +#endif + assert(handleCompanionWiFiCompatibilityCommand("get wifi.status",reply,sizeof(reply))); + assert(strstr(reply,"coex=yes") && strstr(reply,"protocol=") + && strstr(reply,"mode=") && strstr(reply,"ps=") + && strstr(reply,"connected") && strstr(reply,"test WLAN") + && strstr(reply,"192.168.11.102") && strstr(reply,"rssi=-74")); + assert(strlen(reply)<160); + // The expanded status remains bounded for short framed/terminal replies. + for (size_t capacity : {1U, 2U, 16U, 80U, 160U}) { + char guarded[162]; memset(guarded,'!',sizeof(guarded)); + assert(handleCompanionWiFiCompatibilityCommand("get wifi.status",guarded+1,capacity)); + assert(guarded[0]=='!' && guarded[capacity+1]=='!'); + assert(memchr(guarded+1,0,capacity)!=nullptr); + } + // The runtime collector distinguishes disabled, suspended, retry and setup + // states rather than reporting every stopped WebConfig session as WiFi off. + WiFi.state=WL_DISCONNECTED; companion_wifi_requested=false; + formatCompanionWiFiStatus(reply,sizeof(reply)); assert(strstr(reply,"> off ")); + companion_wifi_requested=true; companion_wifi_active=false; + formatCompanionWiFiStatus(reply,sizeof(reply)); assert(strstr(reply,"inactive")); + companion_wifi_active=true; companion_wifi_credential_reload_pending=true; + formatCompanionWiFiStatus(reply,sizeof(reply)); assert(strstr(reply,"reconnect-pending")); + companion_wifi_credential_reload_pending=false; + networkPower().suspended=true; + formatCompanionWiFiStatus(reply,sizeof(reply)); assert(strstr(reply,"suspended(30m)")); + networkPower().suspended=false; companion_wifi_has_credentials=false; + formatCompanionWiFiStatus(reply,sizeof(reply)); assert(strstr(reply,"unconfigured")); + companion_wifi_has_credentials=true; + for (auto state : {WL_NO_SSID_AVAIL,WL_CONNECT_FAILED,WL_CONNECTION_LOST,WL_DISCONNECTED}) { + WiFi.state=state; formatCompanionWiFiStatus(reply,sizeof(reply)); + assert(strstr(reply,"protocol=") && !strstr(reply," ip=") && !strstr(reply," rssi=")); + } + WiFi.mode=3; + formatCompanionWiFiStatus(reply,sizeof(reply)); + assert(strstr(reply,"setup-ap") && strstr(reply,"MeshCore-Setup") && strstr(reply," ip=")); + WiFi.mode=1; WiFi.state=WL_CONNECTED; +#endif + assert(saveCompanionMode(CompanionMode::WiFiOnly)==CompatibilitySaveResult::Saved); + assert(activeCompanionMode()==CompanionMode::Normal); // Next boot only. + rebootCompatibility(); + assert(activeCompanionMode()==CompanionMode::WiFiOnly); + +#if defined(ESP32) && defined(WIFI_SSID) && !defined(COMPANION_EXCLUSIVE_WIFI_BLE) + // The guard must precede identity writes, registration, SDK init and retry. + startCompanionBluetooth(); + assert(!companion_bluetooth_initialized && !companion_bluetooth_start_at); + assert(!identity_calls && !address_calls && !register_calls && !begin_calls && !enable_calls); + assert(companion_bluetooth_start_failure[0]); + // Even an old retry deadline cannot resurrect Bluetooth in this boot. + companion_bluetooth_start_at=now_ms; + serviceDeferredCompanionBluetooth(); + assert(!companion_bluetooth_start_at && !begin_calls && !identity_calls); + + // Saved normal mode is not permission to init BLE until an actual reboot. + assert(saveCompanionMode(CompanionMode::Normal)==CompatibilitySaveResult::Saved); + startCompanionBluetooth(); + assert(!companion_bluetooth_initialized && !begin_calls && !register_calls); + assert(!companionWiFiBluetoothActive()); + nvs_values["powersave"]={PT_U8, {}, kPowerSaveNone}; + reloadCompanionWiFiPowerSave(); + assert(getCompanionWiFiPowerSave()==kPowerSaveNone && last_ps==WIFI_PS_NONE); + assert(setCompanionWiFiPowerSave(kPowerSaveNone)==CompanionWiFiPowerSaveResult::Applied); + assert(last_ps==WIFI_PS_NONE); + rebootCompatibility(); + assert(activeCompanionMode()==CompanionMode::Normal); +#endif + + // Ordinary startup, existing Indicator exclusive mode, and non-ESP32 BLE + // startup must retain their previous behavior even if the generic NVS flag + // is present. This function is only invoked after the Indicator's boot gate. + resetBootCounters(); + startCompanionBluetooth(); + assert(companion_bluetooth_initialized); + assert(identity_calls==1 && register_calls==1 && address_calls==1 && begin_calls==1 && enable_calls==1); + startCompanionBluetooth(); serviceDeferredCompanionBluetooth(); + assert(begin_calls==1); // Already initialized remains idempotent. + +#if defined(ESP32) && defined(WIFI_SSID) + assert(companionWiFiBluetoothActive()); + reloadCompanionWiFiPowerSave(); + assert(getCompanionWiFiPowerSave()==kPowerSaveMin && last_ps==WIFI_PS_MIN_MODEM); + unsigned before=sdk_ps_calls; + assert(setCompanionWiFiPowerSave(kPowerSaveNone)==CompanionWiFiPowerSaveResult::BluetoothConflict); + assert(sdk_ps_calls==before && last_ps==WIFI_PS_MIN_MODEM); + // A live save for next boot must not unlock PS_NONE under a running BLE stack. + assert(saveCompanionMode(CompanionMode::WiFiOnly)==CompatibilitySaveResult::Saved); + assert(companionWiFiBluetoothActive()); +#if !defined(COMPANION_EXCLUSIVE_WIFI_BLE) + before=sdk_ps_calls; + assert(setCompanionWiFiPowerSave(kPowerSaveNone)==CompanionWiFiPowerSaveResult::SavedForNextBoot); + assert(sdk_ps_calls==before && last_ps==WIFI_PS_MIN_MODEM); + assert(getCompanionWiFiPowerSave()==kPowerSaveMin); + assert(nvs_values.at("powersave").byte==kPowerSaveNone); +#else + assert(setCompanionWiFiPowerSave(kPowerSaveNone)==CompanionWiFiPowerSaveResult::BluetoothConflict); +#endif +#endif + + // Failed initialization still retries on normal BLE-enabled boots. + resetBootCounters(); identity_ok=false; + startCompanionBluetooth(); + assert(!companion_bluetooth_initialized && companion_bluetooth_start_at==now_ms+5000); + now_ms+=4999; serviceDeferredCompanionBluetooth(); assert(identity_calls==1); + ++now_ms; identity_ok=true; serviceDeferredCompanionBluetooth(); + assert(companion_bluetooth_initialized && identity_calls==2 && begin_calls==1); +} +''' + + +class CompanionWiFiCompatibilityTest(unittest.TestCase): + def compile(self, *defines): + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + signatures = [ + "static void scheduleCompanionBluetoothRetry(", + "static void startCompanionBluetooth(", + "static void serviceDeferredCompanionBluetooth(", + ] + if any(item.startswith("-DWIFI_SSID=") for item in defines): + signatures += [ + "static bool companionWiFiBluetoothActive(", + "const char* companionWiFiPowerSaveName(", + "static wifi_ps_type_t companionWiFiPowerSaveType(", + "void reloadCompanionWiFiPowerSave(", + "uint8_t getCompanionWiFiPowerSave(", + "const char* getCompanionWiFiPowerSaveName(", + "bool applyCompanionWiFiPowerSave(", + "CompanionWiFiPowerSaveResult setCompanionWiFiPowerSave(", + "void formatCompanionWiFiStatus(", + "bool handleCompanionWiFiCompatibilityCommand(", + ] + source = HARNESS.replace("@METHODS@", "\n".join(method(main, item) for item in signatures)) + compiler = os.environ.get("CXX") or shutil.which("g++") + self.assertIsNotNone(compiler, "A host C++ compiler is required") + with tempfile.TemporaryDirectory(prefix="companion-wifi-boot-") as directory: + folder = Path(directory) + (folder / "Preferences.h").write_text( + (ROOT / "test/fixtures/wifi_credentials_preferences.h").read_text() + ) + (folder / "test.cpp").write_text(source) + binary = folder / ("test.exe" if os.name == "nt" else "test") + flags = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie" + ] + built = subprocess.run([ + compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", *flags, + "-DBLE_PIN_CODE=123456", *defines, + "-I", str(folder), "-I", str(ROOT / "src"), "-I", str(ROOT), + str(folder / "test.cpp"), "-o", str(binary), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=15) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_generic_wifi_only_boot_and_deferred_retry_stay_ble_free(self): + self.compile("-DESP32=1", '-DWIFI_SSID="test"') + + def test_indicator_exclusive_mode_keeps_its_existing_selector(self): + self.compile("-DESP32=1", '-DWIFI_SSID="test"', "-DCOMPANION_EXCLUSIVE_WIFI_BLE=1") + + def test_non_esp32_bluetooth_boot_is_unchanged(self): + # ESP32 compatibility storage is made available only for the NVS test + # setup; undefining ESP32 before the actual methods exercises the old + # non-ESP32 startup branch without an infrastructure-WiFi interface. + self.compile("-DESP32=1", "-DMESH_TEST_NON_ESP32=1") + + def test_compatibility_is_latched_before_mesh_reads_the_power_policy(self): + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + setup = method(main, "void setup()") + self.assertLess(setup.index("loadCompanionModeForBoot();"), setup.index("the_mesh.begin(")) + self.assertLess(setup.index("loadCompanionModeForBoot();"), setup.index("startCompanionBluetooth();")) + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + begin = method(mesh, "void MyMesh::begin(") + self.assertIn("getCompanionWiFiPowerSave();", begin) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_wifi_status.py b/test/test_companion_wifi_status.py index 6ef78a3a3..ca7655d25 100644 --- a/test/test_companion_wifi_status.py +++ b/test/test_companion_wifi_status.py @@ -11,6 +11,18 @@ ROOT = Path(__file__).resolve().parents[1] class CompanionWiFiStatusTest(unittest.TestCase): + def test_status_includes_connection_and_compatibility_in_one_command(self): + main = (ROOT / "examples/companion_radio/main.cpp").read_text() + mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text() + self.assertIn('strcmp(command, "get wifi.status") == 0', main) + self.assertNotIn('"get wifi.status detail"', main) + self.assertNotIn('"get wifi.compat"', main) + self.assertIn('get wifi.status (connection, saved/boot/live settings)', mesh) + self.assertIn('formatCompanionWiFiStatusSnapshot(reply, reply_size, snapshot);', main) + local = mesh[mesh.index("bool MyMesh::handleLocalControlCommand("):] + self.assertLess(local.index("handleCompanionWiFiCompatibilityCommand("), + local.index("case mesh::cli::StandaloneWiFiKey::Status:")) + def test_runtime_fallback_classification(self): source = r''' #include diff --git a/test/test_companion_wifi_status_format.py b/test/test_companion_wifi_status_format.py new file mode 100644 index 000000000..d7cfee67d --- /dev/null +++ b/test/test_companion_wifi_status_format.py @@ -0,0 +1,149 @@ +"""Exercise the bounded combined Companion WiFi status with real C++ code.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +HARNESS = r''' +#include +#include +#include +#include +#include +using mesh::wifi::CompanionWiFiStatusSnapshot; +using mesh::wifi::formatCompanionWiFiStatusSnapshot; + +CompanionWiFiStatusSnapshot snapshot() { + return {"connected", "test", "192.168.11.102", true, -74, + "bgn", "bgn", "0x07", "normal", "normal", "min", + true, false, false, 5001}; +} + +int main() { + char output[512]={}; + auto value=snapshot(); + assert(formatCompanionWiFiStatusSnapshot(output,sizeof(output),value)); + assert(std::string(output)=="> connected ssid=test ip=192.168.11.102 rssi=-74\n" + "protocol=bgn/bgn/0x07 mode=normal ps=min coex=yes ota=off"); + value.protocol_saved="bg"; value.mode_saved="wifi-only"; + value.ota_listening=true; + assert(formatCompanionWiFiStatusSnapshot(output,sizeof(output),value)); + assert(std::string(output).find("protocol=bg/bgn/0x07 mode=wifi-only/normal")!=std::string::npos); + assert(std::string(output).find("ota=5001:listen")!=std::string::npos); + value.ota_attached=true; + assert(formatCompanionWiFiStatusSnapshot(output,sizeof(output),value)); + assert(std::string(output).find("ota=5001:client")!=std::string::npos); + + // A maximum-length SSID, IPv4 address, RX RSSI and port must fit all + // saved/boot/live protocol and mode combinations in the 157-byte routed + // reply budget, without dropping any applicable field. + const char* protocols[]={"bg","bgn"}; + const char* modes[]={"normal","wifi-only"}; + const char* physical[]={"0x03","0x07","n/a"}; + const char* saves[]={"none","min","max","n/a"}; + for (size_t ssid_size:{31u,32u}) { + const std::string ssid(ssid_size,'S'); + for (const char* saved:protocols) for (const char* boot:protocols) + for (const char* phy:physical) for (const char* mode_saved:modes) + for (const char* mode_boot:modes) for (const char* ps:saves) + for (bool coexist:{false,true}) for (bool listening:{false,true}) + for (bool attached:{false,true}) { + value={"connected",ssid.c_str(),"255.255.255.255",true,-128, + saved,boot,phy,mode_saved,mode_boot,ps,coexist,listening,attached,65535}; + char routed[157]; + assert(formatCompanionWiFiStatusSnapshot(routed,sizeof(routed),value)); + assert(strlen(routed) n/a ssid=n/a\nprotocol=n/a/n/a/n/a mode=n/a ps=n/a coex=no ota=off"); + value=snapshot();value.ip="";value.has_rssi=true;value.rssi=INT_MIN; + assert(formatCompanionWiFiStatusSnapshot(output,sizeof(output),value)); + assert(!strstr(output," ip=") && strstr(output," rssi=-2147483648")); + + // Exercise every capacity, preserving guards before and after the exact + // destination. A small buffer gets the exact prefix, a truthful false + // result and NUL termination, never a selectively abbreviated report. + value=snapshot();value.ssid=ssid.c_str();value.ip="255.255.255.255"; + value.rssi=-128;value.mode_saved="wifi-only";value.ota_listening=true; + value.ota_attached=true;value.ota_port=65535; + assert(formatCompanionWiFiStatusSnapshot(output,sizeof(output),value)); + const std::string complete(output); + for (size_t capacity=1;capacity<=160;++capacity) { + unsigned char guarded[164]; memset(guarded,0x5a,sizeof(guarded)); + char* destination=reinterpret_cast(guarded+2); + const bool fits=formatCompanionWiFiStatusSnapshot(destination,capacity,value); + assert(fits==(capacity>complete.size())); + assert(guarded[0]==0x5a && guarded[1]==0x5a); + for (size_t i=2+capacity;i none" : expected_pref[saved]==2 ? "> max" : "> min")==0); // A later portal/CLI save or changed observer pref cannot change an active // canonical worker; reconnect uses its begin-scoped snapshot without NVS. stored.power_save = (saved + 2) % 3; assert(mesh::wifi::writeCredentials(stored)); obs.wifi_power_save = (saved + 1) % 3; + assert(mesh::wifi::saveCompanionMode(@WIFI_ONLY@ + ? mesh::wifi::CompanionMode::Normal : mesh::wifi::CompanionMode::WiFiOnly) + ==mesh::wifi::CompatibilitySaveResult::Saved); const int opens = nvs_opens; worker.applyConnectedPowerSave(); assert(applied_ps == expected[saved]); assert(nvs_opens == opens); @@ -255,20 +272,25 @@ int main() { assert(saved_none._wifi_power_save == (@NONE_ALLOWED@ ? 1 : 0)); assert(applied_ps == expected[@NONE_ALLOWED@ ? 1 : 0]); } -""".replace("@MODES@", ", ".join(modes)).replace("@NONE_ALLOWED@", str(not bluetooth).lower()) +""".replace("@MODES@", ", ".join(modes)).replace( + "@PREFS@", f"0, {0 if bluetooth else 1}, {0 if primary_espnow else 2}" + ).replace("@NONE_ALLOWED@", str(not bluetooth).lower()).replace( + "@WIFI_ONLY@", "1" if wifi_only else "0") def test_connected_worker_snapshot_and_transport_coexistence(self): profiles = ( - ("ordinary", False, False, ()), - ("simultaneous_ble", True, False, ("-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), - ("exclusive_wifi", False, False, ("-DCOMPANION_EXCLUSIVE_WIFI_BLE=1", "-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), - ("primary_espnow", False, True, ()), - ("espnow_with_ble", True, True, ("-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), - ("espnow_exclusive_wifi", False, True, ("-DCOMPANION_EXCLUSIVE_WIFI_BLE=1", "-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), + ("ordinary", False, False, False, ()), + ("simultaneous_ble", True, False, False, ("-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), + ("wifi_only_ble_compiled", False, False, True, ("-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), + ("exclusive_wifi", False, False, False, ("-DCOMPANION_EXCLUSIVE_WIFI_BLE=1", "-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), + ("primary_espnow", False, True, False, ()), + ("espnow_with_ble", True, True, False, ("-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), + ("espnow_wifi_only_ble_compiled", False, True, True, ("-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), + ("espnow_exclusive_wifi", False, True, False, ("-DCOMPANION_EXCLUSIVE_WIFI_BLE=1", "-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"')), ) - for name, bluetooth, primary, flags in profiles: + for name, bluetooth, primary, wifi_only, flags in profiles: with self.subTest(profile=name): - self.compile(self.policy_fixture(bluetooth, primary), *flags) + self.compile(self.policy_fixture(bluetooth, primary, wifi_only), *flags) def test_worker_cannot_revert_to_mutable_legacy_power_save(self): fixture = self.policy_fixture(False, False) @@ -279,6 +301,21 @@ int main() { with self.assertRaises(canonical_wifi.FixtureFailure): self.compile(stale) + def test_wifi_only_rejects_old_compiled_ble_clamps_in_both_consumers(self): + fixture = self.policy_fixture(False, False, wifi_only=True) + for signature in ("static bool bluetoothWiFiCoexistenceRequired(", + "void applyConnectedPowerSave("): + with self.subTest(consumer=signature): + current = canonical_wifi.extract_braced(fixture, signature) + old = re.sub( + r"mesh::wifi::activeCompanionMode\(\)\s*" + r"!= mesh::wifi::CompanionMode::WiFiOnly", + "true", current) + self.assertNotEqual(current, old) + stale = fixture.replace(current, old, 1) + with self.assertRaises(canonical_wifi.FixtureFailure): + self.compile(stale, "-DBLE_PIN_CODE=1", '-DWIFI_SSID="compiled"') + if __name__ == "__main__": unittest.main() diff --git a/test/test_mqtt_canonical_wifi.py b/test/test_mqtt_canonical_wifi.py index 4e0fb13c8..a5cc89744 100644 --- a/test/test_mqtt_canonical_wifi.py +++ b/test/test_mqtt_canonical_wifi.py @@ -21,6 +21,10 @@ class MQTTCanonicalWiFiTest(unittest.TestCase): web = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text() mqtt = (ROOT / "src/helpers/bridges/MQTTBridge.cpp").read_text() fixture = (ROOT / "test/fixtures/mqtt_canonical_wifi.cpp").read_text() + web_policy = "\n".join(extract_braced(web, signature) for signature in ( + "static bool bluetoothWiFiCoexistenceRequired(", + "static uint8_t effectiveWiFiPowerSave(", + )) web_methods = "\n".join(extract_braced(web, signature) for signature in ( "mesh::wifi::CredentialState WebConfigServer::resolveWiFi(", "bool WebConfigServer::hasConfiguredWiFi(", @@ -31,6 +35,7 @@ class MQTTCanonicalWiFiTest(unittest.TestCase): "bool WebConfigServer::setStandaloneWiFiPassword(", "bool WebConfigServer::setStandaloneWiFiPowerSave(", "bool WebConfigServer::formatWiFiPassword(", + "bool WebConfigServer::formatWiFiPowerSave(", "bool WebConfigServer::reloadStandaloneWiFi(", )) batch_start = web.index(' if (_standalone_wifi && strcmp(e.key, "wifi.ssid")') @@ -63,7 +68,8 @@ class MQTTCanonicalWiFiTest(unittest.TestCase): reconnect_start = mqtt.index("#ifdef ESP_PLATFORM", reconnect_start) reconnect_end = mqtt.index("#endif", reconnect_start) + len("#endif") reconnect = mqtt[reconnect_start:reconnect_end] - return (fixture.replace("@WEB_METHODS@", web_methods) + return (fixture.replace("@WEB_POLICY@", web_policy) + .replace("@WEB_METHODS@", web_methods) .replace("@MQTT_METHODS@", mqtt_methods) .replace("@BEGIN_GATE@", gate).replace("@RECONNECT@", reconnect) .replace("@CLI_SET@", cli_set).replace("@CLI_GET@", cli_get) @@ -80,7 +86,7 @@ class MQTTCanonicalWiFiTest(unittest.TestCase): (path / "test.cpp").write_text(source, encoding="ascii") result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", - "-DESP_PLATFORM=1", "-DWITH_MQTT_BRIDGE=1", *flags, + "-DESP_PLATFORM=1", "-DESP32=1", "-DWITH_MQTT_BRIDGE=1", *flags, "-I", str(path), "-I", str(ROOT / "src"), str(path / "test.cpp"), "-o", str(path / "test")], capture_output=True, text=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) diff --git a/test/test_wifi_compatibility.py b/test/test_wifi_compatibility.py new file mode 100644 index 000000000..8228ba336 --- /dev/null +++ b/test/test_wifi_compatibility.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Execute production ESP32 compatibility settings with NVS and SDK faults.""" +from pathlib import Path +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/wifi_compatibility" +SANITIZERS = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) +ORDINARY = ("defaults", "parse", "protocol_reboot", "mode_reboot", "corrupt", + "invalid", "unloaded", "open_failure", "write_failure", "verify_failure", + "independent", "bg_ap", "bg_ap_race", "bg_lr_start", "owner_conflict", + "driver_failure") + + +class WiFiCompatibilityTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + raise unittest.SkipTest("a host C++17 compiler is required") + cls.directory = tempfile.TemporaryDirectory(prefix="meshcore-wifi-compat-") + cls.addClassCleanup(cls.directory.cleanup) + cls.binaries = {} + for kind, flags in ( + ("ordinary", []), + ("bridge", ["-DWITH_ESPNOW_BRIDGE=1"]), + ("primary", ["-DMESH_PRIMARY_ESPNOW=1"]), + ("primary_full", ["-DMESH_PRIMARY_ESPNOW=1", "-DMESH_ESPNOW_RADIO=1"]), + ): + binary = Path(cls.directory.name) / (kind + ".exe") + compiled = subprocess.run( + [compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", *SANITIZERS, + "-DESP32=1", *flags, f"-I{FIXTURE}", f"-I{ROOT / 'src'}", + str(FIXTURE / "test.cpp"), "-o", str(binary)], + capture_output=True, text=True) + if compiled.returncode: + raise AssertionError(compiled.stdout + compiled.stderr) + cls.binaries[kind] = binary + + def run_case(self, name, kind="ordinary"): + result = subprocess.run([str(self.binaries[kind]), name], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_ordinary_settings(self): + for scenario in ORDINARY: + with self.subTest(scenario=scenario): + self.run_case(scenario) + + def test_bridge_build_preserves_normal_and_can_use_bg_when_not_running(self): + for scenario in ORDINARY: + with self.subTest(scenario=scenario): + self.run_case(scenario, "bridge") + + def test_primary_espnow_preserves_lr_and_rejects_bg_without_mutation(self): + for kind in ("primary", "primary_full"): + for scenario in ("defaults", "parse", "mode_reboot", "corrupt", "invalid", + "unloaded", "open_failure", "write_failure", "verify_failure", + "primary_conflict"): + with self.subTest(kind=kind, scenario=scenario): + self.run_case(scenario, kind) + + +if __name__ == "__main__": + unittest.main()