Unify device controls and restore the WiFi console

This commit is contained in:
mikecarper committed 2026-09-08 18:36:23 -07:00
1 parent 3746f9d5f4
commit 4c8aad5b16
44 files changed
+3573 -2948

No files matched your search

@@ -114,6 +114,38 @@ TEST(CLICommandUtils, NormalizesCapitalizedSetVerb) {
EXPECT_STREQ("set altpath 600000,0d2784,F8DADA", command);
}
TEST(CLICommandUtils, LoggingToggleHasOneGrammarForEveryRole) {
bool enabled = false, reboot = false;
ASSERT_TRUE(mesh::cli::parseLoggingToggle("on", enabled, reboot));
EXPECT_TRUE(enabled);
EXPECT_FALSE(reboot);
ASSERT_TRUE(mesh::cli::parseLoggingToggle("off reboot", enabled, reboot));
EXPECT_FALSE(enabled);
EXPECT_TRUE(reboot);
ASSERT_TRUE(mesh::cli::parseLoggingToggle("on reboot", enabled, reboot));
EXPECT_TRUE(enabled);
EXPECT_TRUE(reboot);
ASSERT_TRUE(mesh::cli::parseLoggingToggle("off", enabled, reboot));
EXPECT_FALSE(enabled);
EXPECT_FALSE(reboot);
for (const char* value : {"", "onward", "1", "off reboot now", "on\nreboot"}) {
EXPECT_FALSE(mesh::cli::parseLoggingToggle(value, enabled, reboot)) << value;
}
EXPECT_FALSE(mesh::cli::parseLoggingToggle(nullptr, enabled, reboot));
}
TEST(CLICommandUtils, LoggingOutputsControlUsbAndWifiIndependently) {
bool usb = true, wifi = true;
for (const char* mode : {"off", "usb", "wifi", "both"}) {
ASSERT_TRUE(mesh::cli::parseLoggingOutput(mode, usb, wifi));
EXPECT_STREQ(mode, mesh::cli::loggingOutputName(usb, wifi));
EXPECT_EQ(usb, strcmp(mode, "usb") == 0 || strcmp(mode, "both") == 0);
EXPECT_EQ(wifi, strcmp(mode, "wifi") == 0 || strcmp(mode, "both") == 0);
}
EXPECT_FALSE(mesh::cli::parseLoggingOutput("bluetooth", usb, wifi));
EXPECT_FALSE(mesh::cli::parseLoggingOutput(nullptr, usb, wifi));
}
TEST(CLICommandUtils, NormalizesAnyCommandVerbCase) {
char get_command[] = "GET outpath";
char clear_command[] = "cLeAr recent.repeater";
+2 -2
View File
@@ -19,7 +19,7 @@ struct CompanionMqttSetupPortal {
struct MyMesh {
void onConfigBatchEnd();
void syncWiFiPowerSaving();
bool _wc_mqtt_dirty, _mqtt_started, _mqtt_configured;
bool _wc_mqtt_dirty, _wc_batch_active, _mqtt_started, _mqtt_configured;
MQTTBridge* _mqtt_bridge;
MQTTPrefs _mqtt_prefs;
};
@@ -29,7 +29,7 @@ struct MyMesh {
def callback_source():
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
start = source.index(CALLBACK)
return source[start:source.index("void MyMesh::execCommand", start)]
return source[start:source.index("void MyMesh::execAdminCommand", start)]
class CompanionMqttStackTests(unittest.TestCase):
+18 -13
View File
@@ -865,8 +865,7 @@ assert.strictEqual(
console.log("generalized firmware picker tests passed");
// User-selected output paths must produce role-correct commands. In
// particular, a Full Companion with MQTT must never get CommonCLI setters.
// Shared features must produce the same commands for every role.
const controls = require('../docs/_data/firmware_controls.json');
const liveFamily = controls.familyTag;
const controlledReleases = [release(liveFamily, '2026-09-01T00:00:00Z', [
@@ -887,44 +886,50 @@ const observer = findControlled('heltec_v4_repeater_observer_mqtt-full-usb-wifi'
for (const mode of ['none', 'usb', 'wifi', 'both']) {
const directions = picker.runtimeDirections(observer, {logging: mode});
assert.deepStrictEqual(directions[0].actions[0].commands,
(mode === 'usb' || mode === 'both' ? ['powersaving off'] : []).concat(
(mode === 'usb' || mode === 'both' ? ['set powersaving off'] : []).concat(
['set logging.output ' + (mode === 'none' ? 'off' : mode), 'get logging.output']));
}
const mqttCompanion = findControlled('heltec_v4_2_v4_3_companion_radio_full_femon');
assert.deepStrictEqual(mqttCompanion.loggingModes, ['none', 'usb', 'wifi', 'both']);
for (const mode of ['usb', 'both']) {
assert.deepStrictEqual(picker.runtimeDirections(mqttCompanion, {logging: mode})[0].actions[0].commands,
['powersaving off', 'set usb.logging on']);
['set powersaving off', 'set logging.output ' + mode, 'get logging.output']);
}
assert(picker.installSteps(mqttCompanion, 'bin').some(step => step.includes('powersaving off')));
assert(picker.installSteps(mqttCompanion, 'bin').some(step => step.includes('set powersaving off')));
// The workaround belongs to the affected release, not every future ESP32 build.
for (const releaseFamily of ['v1.17.1.4-old', 'v1.17.1.5-halo-keymind-cascade-dev-newfix',
'v1.17.1.6-next', 'v1.17.1.50-next', '']) {
const otherRelease = {...mqttCompanion, releaseFamily};
assert.deepStrictEqual(picker.runtimeDirections(otherRelease, {logging: 'usb'})[0].actions[0].commands,
['set usb.logging on']);
assert(!picker.installSteps(otherRelease, 'bin').some(step => step.includes('powersaving off')));
['set logging.output usb', 'get logging.output']);
assert(!picker.installSteps(otherRelease, 'bin').some(step => step.includes('set powersaving off')));
}
const companionWifi = picker.runtimeDirections(mqttCompanion, {logging: 'wifi'});
assert.deepStrictEqual(companionWifi[0].actions[0].commands, ['set usb.logging off']);
assert.deepStrictEqual(companionWifi[0].actions[0].commands, ['set logging.output wifi', 'get logging.output']);
assert(companionWifi[0].actions[0].text.includes('enable the desired MQTT'));
assert(commands(companionWifi).every(c => !/logging.output|bridge.enabled|set mqtt\./.test(c)));
assert(companionWifi.some(s => s.title === 'GPS' && s.actions[0].text.includes('gps=1')));
assert(commands(companionWifi).includes('set mqtt.enabled on'));
assert(commands(companionWifi).includes('get wifi.cli'));
assert(commands(companionWifi).includes('set usb.logging on') === false);
assert(commands(companionWifi).includes('set gps on'));
const companionOff = picker.runtimeDirections(mqttCompanion, {logging: 'none'});
assert(companionOff[0].actions[0].text.includes('none for every MQTT'));
assert(companionOff[0].actions[0].text.includes('saved broker settings'));
for (const title of ['Device power saving', 'GPS', 'MQTT broker connections', 'WebConfig command terminal']) {
assert.deepStrictEqual(companionWifi.find(s => s.title === title).actions,
picker.runtimeDirections(observer, {}).find(s => s.title === title).actions);
}
const nrf = findControlled('RAK_4631_companion_radio_full');
assert.deepStrictEqual(picker.runtimeDirections(nrf, {logging: 'usb'})[0].actions[0].commands,
['set usb.logging on reboot']);
const rakRepeater = findControlled('RAK_4631_repeater');
const g3Repeater = findControlled('Station_G3_ESP32_repeater');
assert.deepStrictEqual(picker.runtimeDirections(g3Repeater, {logging: 'usb'})[0].actions[0].commands,
['powersaving off', 'set usb.logging on']);
['set powersaving off', 'set usb.logging on']);
assert.deepStrictEqual(picker.runtimeDirections(g3Repeater, {logging: 'none'})[0].actions[0].commands,
['set usb.logging off']);
const rakCommands = picker.runtimeDirections(rakRepeater, {logging: 'usb', mode: 'standard'});
assert.deepStrictEqual(rakCommands[0].actions[0].commands, ['set usb.logging on']);
assert.strictEqual(rakCommands.find(s => s.title.startsWith('RS232')).actions[0].label, 'Off');
assert(commands(rakCommands).includes('gps on'));
assert(commands(rakCommands).includes('set gps on'));
assert(!rakCommands.some(s => s.title === 'MQTT broker connections'));
const indicator = picker.runtimeDirections(findControlled('SenseCapIndicator-LoRa_companion_radio_full'), {});
assert(commands(indicator).includes('set companion.transport ble'));
@@ -347,6 +347,16 @@ TEST(WebConfigBatch, OnlyWritesGateTheDeferredReboot) {
EXPECT_FALSE(Batch::cliWriteSucceeded(""));
}
TEST(WebConfigBatch, PasswordEchoIsMaskedOnlyAfterARealChange) {
EXPECT_TRUE(Batch::cliPasswordChanged("password now: new-password"));
EXPECT_TRUE(Batch::cliPasswordChanged("OK"));
EXPECT_FALSE(Batch::cliPasswordChanged("Error: unsupported command"));
EXPECT_FALSE(Batch::cliPasswordChanged("Error: failed to save"));
EXPECT_FALSE(Batch::cliPasswordChanged("(no reply)"));
EXPECT_FALSE(Batch::cliPasswordChanged(""));
EXPECT_FALSE(Batch::cliPasswordChanged(NULL));
}
TEST(WebConfigBatch, CliRebootIsWithheldWhenAnyCommandInTheSequenceFailed) {
EXPECT_TRUE(Batch::cliRebootAllowed(/*has_reboot=*/true, /*all_ok=*/true));
// Same rule a config save follows: do not reboot into a half-applied config
+99
View File
@@ -114,6 +114,105 @@ class WebConfigUiRuntimeTest(unittest.TestCase):
self.assertNotIn("data-test-error=", dom)
return dom
def test_companion_console_runs_commands_and_uses_role_suggestions(self):
status, config = self.setup_values()
status.update(mode="lan", cli=True, mqtt=True, active_slots=1,
password_supported=False)
prelude = """
<script>
(function(){
var status=%s,config=%s,submitted=null;
function response(value){
return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}});
}
window.fetch=function(path,options){
if(path==="/api/status")return response(status);
if(path==="/api/config")return response(config);
if(path==="/api/presets")return response({presets:[]});
if(path==="/api/cli"){
submitted=JSON.parse(options.body);
return response({state:"running",reqid:submitted.reqid,total:1});
}
if(path.indexOf("/api/cli/result?")===0)return response({
state:"done",reqid:submitted.reqid,results:[{ok:true,reply:"companion-console-test-version"}]
});
return response({});
};
window.addEventListener("load",function(){
setTimeout(function(){
var button=document.querySelector('#tabs button[data-t="cli"]');
document.body.setAttribute("data-test-cli-tab",button?"yes":"no");
if(!button)return;
button.click();
document.getElementById("term-in").value="ver";
cliSubmit();
},75);
setTimeout(function(){
var table=cliTable().map(function(row){return row[0]});
document.body.setAttribute("data-test-suggestions",JSON.stringify(table));
document.body.setAttribute("data-test-submitted",submitted?submitted.cmds.join(","):"none");
document.body.setAttribute("data-test-reply",document.getElementById("term-out").textContent);
document.body.setAttribute("data-test-enum",cliEnum("wifi.cli").join(","));
document.body.setAttribute("data-test-busy",String(cli.busy));
var master=document.querySelector('[data-k="mqtt.enabled"]');
document.body.setAttribute("data-test-mqtt-enabled",master?String(master.checked):"missing");
document.body.setAttribute("data-test-mqtt-enum",cliEnum("mqtt.enabled").join(","));
cliHelp();
document.body.setAttribute("data-test-companion-help",document.getElementById("term-out").textContent);
// Switching role must invalidate the cached table even with one slot.
st.role="repeater";
document.body.setAttribute("data-test-repeater-erase",String(cliTable().some(function(row){return row[0]==="erase"})));
},500);
});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-cli-tab="yes"', dom)
self.assertIn('data-test-submitted="ver"', dom)
self.assertIn("companion-console-test-version", dom)
self.assertIn('data-test-busy="false"', dom)
self.assertIn('data-test-enum="on,off"', dom)
self.assertIn('data-test-repeater-erase="true"', dom)
self.assertIn('data-test-mqtt-enabled="false"', dom)
self.assertIn('data-test-mqtt-enum="on,off"', dom)
import html
help_text = html.unescape(re.search(
r'data-test-companion-help="([^"]*)"', dom).group(1))
self.assertIn("set mqtt.enabled on|off", help_text)
self.assertNotIn("neighbors", help_text)
self.assertNotIn("stats-core", help_text)
suggestions = json.loads(html.unescape(re.search(
r'data-test-suggestions="([^"]*)"', dom).group(1)))
for command in ("set powersaving off", "get usb.logging", "set wifi.cli ",
"set mqtt1.preset ", "get name", "get radio"):
self.assertIn(command, suggestions)
for command in ("erase", "password ", "setperm ", "get prv.key",
"set bridge.enabled ", "get mqtt1.password"):
self.assertNotIn(command, suggestions)
def test_console_stays_hidden_when_disabled_or_on_setup_ap(self):
for mode in ("lan", "setup"):
with self.subTest(mode=mode):
status, config = self.setup_values()
status.update(mode=mode, cli=False)
prelude = """
<script>
(function(){
var status=%s,config=%s;
window.fetch=function(path){
var value=path==="/api/status"?status:path==="/api/config"?config:{state:"done",networks:[]};
return Promise.resolve({ok:true,status:200,json:function(){return Promise.resolve(value)}});
};
window.addEventListener("load",function(){setTimeout(function(){
document.body.setAttribute("data-test-cli-hidden",String(!document.querySelector('#tabs button[data-t="cli"]')));
},500)});
})();
</script>
""" % (json.dumps(status), json.dumps(config))
dom = self.run_page(prelude)
self.assertIn('data-test-cli-hidden="true"', dom)
def run_early_selection_case(self, explicit_password=None):
status, config = self.setup_values()