Fix official app WiFi commands and Full build qualification

This commit is contained in:
mikecarper committed 2026-09-30 20:27:05 -07:00
1 parent 6f6d1a2a7a
commit d5853b9ca2
9 files changed
+161 -26

No files matched your search

+11
View File
@@ -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.
+9 -2
View File
@@ -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 <MHz>` 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)
+1
View File
@@ -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
+39 -2
View File
@@ -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
+22 -22
View File
@@ -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;
@@ -2,6 +2,7 @@
#include <stddef.h>
#include <stdint.h>
#include <string.h>
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;
}
+15
View File
@@ -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 \
@@ -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) {
+25
View File
@@ -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"]