Fix official app WiFi commands and Full build qualification
This commit is contained in:
1 parent
6f6d1a2a7a
commit
d5853b9ca2
9 files changed
+161
-26
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user