diff --git a/build_legacy.sh b/build_legacy.sh index 4751cadd..b3789d4d 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3354,6 +3354,17 @@ declare_build_capability_contract() { record_build_expectation "companion.wifi_ota_seeder" \ "OTA seeder listening on :" record_build_expectation "web.webconfig" "start webconfig" + # WiFi/WebConfig alone does not prove the official app can configure it. + # Require every read/save operation in the uploadable application, so a + # Full build fails qualification if its app command handler is omitted. + record_application_expectation "companion.app_wifi.get_enabled" "get wifi.enabled" + record_application_expectation "companion.app_wifi.set_enabled" "set wifi.enabled " + record_application_expectation "companion.app_wifi.get_ssid" "> (not set)" + record_application_expectation "companion.app_wifi.set_ssid" "OK - WiFi SSID saved" + record_application_expectation "companion.app_wifi.get_password" "get wifi.pwd" + record_application_expectation "companion.app_wifi.set_password" "OK - WiFi password saved" + record_application_expectation "companion.app_wifi.get_status" "wifi.status" + record_application_expectation "companion.app_wifi.get_ip" "get wifi.ip" # This branch is constant-folded away when the expanded-profile marker # is missing. Make artifact qualification prove that canonical Full # Companion images really contain the bounded first-boot WiFi policy. diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index 2c293cf2..c3a8f1b5 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -287,8 +287,9 @@ refusal, manual acceptance, held-message delivery, and matching ACK. `--invalid-signature-first` also checks that a decryptable introduction with an invalid Ed25519 signature does not add a contact. -`CMD_RUN_CLI_COMMAND` is followed by the local CLI text without a terminating -NUL. The device returns `RESP_CODE_CLI_REPLY` (`0x1D`) followed by the reply +`CMD_RUN_CLI_COMMAND` is followed by the local CLI text with an optional final +NUL, as sent by the official app. Clients that omit it remain supported. +The device returns `RESP_CODE_CLI_REPLY` (`0x1D`) followed by the reply text. This is separate from sending a remote on-air CLI command with `CMD_SEND_TXT_MSG` and `TXT_TYPE_CLI_COMMAND`. The body must contain at least one byte and must not contain an embedded NUL. An unknown command is returned @@ -303,6 +304,12 @@ WiFi/MQTT builds accept `get wifi.pwd`, `get mqttN.password`, and `get mqttN.token`. `get password` reports that Companion has no admin password; infrastructure roles return their own admin password on a local CLI connection. These privileges do not apply to CLI commands relayed over LoRa. +ESP32 WiFi Companion builds expose the official app's `get/set wifi.enabled`, +`get/set wifi.ssid`, and `get/set wifi.pwd` controls. Credentials use the shared +WiFi store, and enablement is saved in node preferences. WiFi/BLE builds apply +enablement from the main loop; exclusive WiFi/BLE builds apply it after reboot. +`get wifi.status` reports the connection state and `get wifi.ip` reports the +station address, the setup AP address, or `(not connected)`. `set freq ` works through command `0x42` and authorized LoRa CLI, like `set radio`; it saves the frequency and requires reboot to apply. See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands) diff --git a/examples/companion_radio/CompanionWiFi.h b/examples/companion_radio/CompanionWiFi.h index e10b0d31..7c52dbc0 100644 --- a/examples/companion_radio/CompanionWiFi.h +++ b/examples/companion_radio/CompanionWiFi.h @@ -44,6 +44,7 @@ enum class CompanionWiFiPowerSaveResult : uint8_t { // The requested state is persisted immediately. Network services transition // from the main loop so a button callback never tears down an active server. bool toggleCompanionWiFi(); +bool setCompanionWiFiEnabled(bool enabled); bool isCompanionWiFiEnabled(); bool hasCompanionWiFiCredentials(); // Display-facing station state. Arduino's cached WL status can briefly lag the diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4180522c..71857417 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3631,6 +3631,38 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, #endif #if defined(ESP32) && defined(WIFI_SSID) + if (strcmp(command, "get wifi.ip") == 0) { + char ip[16] = {}; +#ifdef WITH_WEBCONFIG + if (WebConfigServer::getSetupInfo(nullptr, 0, ip, sizeof(ip))) { + snprintf(reply, reply_size, "> %s", ip); + return true; + } +#endif + snprintf(reply, reply_size, "> %s", isCompanionWiFiConnected() + ? WiFi.localIP().toString().c_str() : "(not connected)"); + return true; + } + if (strcmp(command, "get wifi.enabled") == 0) { + snprintf(reply, reply_size, "> %u", (unsigned)_prefs.wifi_enabled); + return true; + } + if (strncmp(command, "set wifi.enabled ", 17) == 0) { + int32_t enabled; + if (!mesh::cli::parseIntegerStrict(command + 17, enabled) + || enabled < 0 || enabled > 1) { + snprintf(reply, reply_size, "Error: WiFi enabled must be 0 or 1"); + } else if (!setCompanionWiFiEnabled(enabled != 0)) { + snprintf(reply, reply_size, "Error: WiFi setting could not be saved"); + } else { +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + snprintf(reply, reply_size, "OK - reboot to apply WiFi settings"); +#else + snprintf(reply, reply_size, "OK - WiFi setting saved"); +#endif + } + return true; + } if (strcmp(command, "get display.wifi") == 0) { formatCompanionWiFiDisplayStatus(reply, reply_size); return true; @@ -3747,6 +3779,10 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, switch (wifi_key) { case mesh::cli::StandaloneWiFiKey::SSID: WebConfigServer::formatWiFiSSID(reply, reply_size); + // The official app recognizes this sentinel as an empty SSID. + if (strcmp(reply, "> (not configured)") == 0) { + snprintf(reply, reply_size, "> (not set)"); + } return true; case mesh::cli::StandaloneWiFiKey::Status: formatCompanionWiFiStatus(reply, reply_size); @@ -5048,8 +5084,9 @@ void MyMesh::handleCmdFrame(size_t len) { } else if (mesh::companion::isRunCliFrame(cmd_frame[0], len)) { // V14+ int i = 1; char *text = (char *)&cmd_frame[i]; - int tlen = len - i; - if (memchr(text, 0, tlen) != NULL) { + const size_t tlen = mesh::companion::cliCommandTextLength( + &cmd_frame[i], len - i); + if (tlen == 0) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); } else { text[tlen] = 0; // ensure null diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 3d60d666..686285e8 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1676,36 +1676,36 @@ void halt() { companion_wifi_setup_stop_requested = true; } - bool toggleCompanionWiFi() { - if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return false; + bool setCompanionWiFiEnabled(bool enabled) { + if (enabled && mesh::wireless::control().blocked(mesh::wireless::WiFi)) return false; #if defined(COMPANION_EXCLUSIVE_WIFI_BLE) - const CompanionTransportMode current = getCompanionTransportMode(); - const CompanionTransportMode selected = - current == CompanionTransportMode::WiFi - ? CompanionTransportMode::Bluetooth - : CompanionTransportMode::WiFi; - if (!selectCompanionTransportMode(selected)) { - WIFI_DEBUG_PRINTLN( - "Companion transport selection could not be saved"); - return current == CompanionTransportMode::WiFi; - } - WIFI_DEBUG_PRINTLN( - "Companion transport %s saved; reboot required", - selected == CompanionTransportMode::WiFi ? "WiFi" : "Bluetooth"); - return selected == CompanionTransportMode::WiFi; + return selectCompanionTransportMode(enabled ? CompanionTransportMode::WiFi + : CompanionTransportMode::Bluetooth); #else - companion_wifi_requested = !companion_wifi_requested; - the_mesh.getNodePrefs()->wifi_enabled = companion_wifi_requested ? 1 : 0; - the_mesh.savePrefs(); + CompanionNodePrefs* prefs = the_mesh.getNodePrefs(); + const uint8_t previous = prefs->wifi_enabled; + prefs->wifi_enabled = enabled ? 1 : 0; + if (!the_mesh.savePrefs()) { + prefs->wifi_enabled = previous; + return false; + } + companion_wifi_requested = enabled; if (!companion_wifi_requested && companion_wifi_active) { companion_wifi_disable_in_progress = true; } - WIFI_DEBUG_PRINTLN("BOOT/GPIO 0 click requested WiFi %s", - companion_wifi_requested ? "on" : "off"); - return companion_wifi_requested; + return true; #endif } + bool toggleCompanionWiFi() { +#if defined(COMPANION_EXCLUSIVE_WIFI_BLE) + const bool current = getCompanionTransportMode() == CompanionTransportMode::WiFi; +#else + const bool current = companion_wifi_requested; +#endif + return setCompanionWiFiEnabled(!current) ? !current : current; + } + #if defined(WITH_WEBCONFIG) && defined(DISPLAY_CLASS) \ && UI_WIFI_SETUP_HOME_PAGE != 1 static DisplayDriver* companion_setup_display = nullptr; diff --git a/src/helpers/CompanionHardwareCommandCompat.h b/src/helpers/CompanionHardwareCommandCompat.h index f063a0cf..5c22fcda 100644 --- a/src/helpers/CompanionHardwareCommandCompat.h +++ b/src/helpers/CompanionHardwareCommandCompat.h @@ -2,6 +2,7 @@ #include #include +#include namespace mesh { namespace companion { @@ -32,6 +33,15 @@ inline bool isRunCliFrame(uint8_t command, size_t frame_len) { return command == kRunCliCommand && frame_len >= 2; } +// The official app appends a NUL; other clients send just the text. Accept +// either representation, but never execute a truncated or empty command. +inline size_t cliCommandTextLength(const uint8_t* body, size_t length) { + if (body == nullptr || length == 0) return 0; + if (body[length - 1] == 0) --length; + if (length == 0 || memchr(body, 0, length) != nullptr) return 0; + return length; +} + inline bool isFemRxGainGet(uint8_t command) { return command == kOriginalGetFemRxGain || command == kDeprecatedGetFemRxGain; } diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 5558a943..46416462 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -1366,6 +1366,19 @@ expectations=" ${BUILD_EXPECTATIONS[*]} " [[ "$expectations" != *"Full Companion terminal listening"* ]] \ || fail "ESP32 Full contract still depends on optional debug logging" +# Every ESP32 Full Companion must ship the official app's WiFi controls. +# These checks apply to the packaged binary, not debug strings in the ELF. +for wifi_target in Station_G2_companion_radio_full \ + heltec_v4_2_v4_3_companion_radio_full_femon \ + SenseCapIndicator-LoRa_companion_radio_full; do + declare_build_capability_contract "$wifi_target" ESP32_PLATFORM + app_expectations=" ${BUILD_APPLICATION_EXPECTATIONS[*]} " + for operation in get_enabled set_enabled get_ssid set_ssid get_password set_password get_status get_ip; do + [[ "$app_expectations" == *"companion.app_wifi.${operation}="* ]] \ + || fail "$wifi_target omitted app WiFi $operation from packaged qualification" + done +done + BUILD_CAPABILITIES=() BUILD_REDUCTIONS=() BUILD_EXPECTATIONS=() @@ -1402,6 +1415,8 @@ expectations=" ${BUILD_EXPECTATIONS[*]} " || fail "nRF52 Full contract omitted USB folder seeding" [[ "$expectations" != *"companion.network_terminal="* ]] \ || fail "nRF52 Full contract promised an ESP32-only network terminal" +[[ " ${BUILD_APPLICATION_EXPECTATIONS[*]} " != *"companion.app_wifi."* ]] \ + || fail "nRF52 Full contract promised ESP32 app WiFi settings" BUILD_PROFILE_FOR_TARGET=standard for rak_target in \ diff --git a/test/test_companion_frame_queue/test_companion_frame_queue.cpp b/test/test_companion_frame_queue/test_companion_frame_queue.cpp index c9ce8d20..f84a552e 100644 --- a/test/test_companion_frame_queue/test_companion_frame_queue.cpp +++ b/test/test_companion_frame_queue/test_companion_frame_queue.cpp @@ -171,6 +171,35 @@ TEST(CompanionHardwareCommandCompat, BothShippedAliasBlocksRemainInbound) { EXPECT_FALSE(mesh::companion::isWiFiPowerSaveSet(0x7F)); } +TEST(CompanionHardwareCommandCompat, OfficialAppCliTerminatorIsOptional) { + const uint8_t command[] = "A7|get wifi.enabled"; + const size_t text_length = sizeof(command) - 1; + EXPECT_EQ(text_length, mesh::companion::cliCommandTextLength(command, sizeof(command))); + EXPECT_EQ(text_length, mesh::companion::cliCommandTextLength(command, text_length)); + const uint8_t single[] = {'x', 0}; + EXPECT_EQ(1U, mesh::companion::cliCommandTextLength(single, 1)); + EXPECT_EQ(1U, mesh::companion::cliCommandTextLength(single, 2)); +} + +TEST(CompanionHardwareCommandCompat, EmptyAndEmbeddedNulCliBodiesAreRejected) { + const uint8_t empty[] = {0}; + const uint8_t embedded[] = {'v', 'e', 'r', 0, 'x'}; + const uint8_t doubled[] = {'v', 'e', 'r', 0, 0}; + EXPECT_EQ(0U, mesh::companion::cliCommandTextLength(nullptr, 1)); + EXPECT_EQ(0U, mesh::companion::cliCommandTextLength(empty, 0)); + EXPECT_EQ(0U, mesh::companion::cliCommandTextLength(empty, sizeof(empty))); + EXPECT_EQ(0U, mesh::companion::cliCommandTextLength(embedded, sizeof(embedded))); + EXPECT_EQ(0U, mesh::companion::cliCommandTextLength(doubled, sizeof(doubled))); +} + +TEST(CompanionHardwareCommandCompat, FullSizeCliBodyDoesNotRequireExtraByte) { + uint8_t body[171]; + memset(body, 'x', sizeof(body)); + EXPECT_EQ(sizeof(body), mesh::companion::cliCommandTextLength(body, sizeof(body))); + body[sizeof(body) - 1] = 0; + EXPECT_EQ(sizeof(body) - 1, mesh::companion::cliCommandTextLength(body, sizeof(body))); +} + } // namespace int main(int argc, char** argv) { diff --git a/test/test_firmware_capabilities.py b/test/test_firmware_capabilities.py index d53b7711..75d6dcde 100644 --- a/test/test_firmware_capabilities.py +++ b/test/test_firmware_capabilities.py @@ -110,6 +110,31 @@ class FirmwareCapabilityCheckerTest(unittest.TestCase): self.assertIn("logging.usb.packets", manifest["capabilities"]) self.assertEqual(manifest["verification"][0]["source"], "packaged application") + def test_full_app_wifi_gate_rejects_missing_read_or_save_operation(self): + operations = { + "get_enabled": "get wifi.enabled", + "set_enabled": "set wifi.enabled ", + "get_ssid": "> (not set)", + "set_ssid": "OK - WiFi SSID saved", + "get_password": "get wifi.pwd", + "set_password": "OK - WiFi password saved", + "get_status": "wifi.status", + "get_ip": "get wifi.ip", + } + arguments = [] + for operation, marker in operations.items(): + arguments += ["--expect-application", f"companion.app_wifi.{operation}={marker}"] + # A complete ELF must not hide a missing operation in the shipped BIN. + linked = "\0".join(operations.values()).encode() + for missing in [None, *operations]: + application = "\0".join(marker for operation, marker in operations.items() + if operation != missing).encode() + with self.subTest(missing=missing): + result, manifest = self.run_checker( + linked, *arguments, artifacts={"firmware-bin": application}) + self.assertEqual(result.returncode, 0 if missing is None else 1, result.stderr) + self.assertEqual(manifest["verified"], missing is None) + def test_full_logging_requires_packet_code_as_well_as_the_setting(self): expectations = ["--expect-application", "logging.usb.control=usb.logging", "--expect-application", "logging.usb.packets=packet logger"]