Restore Companion boot display and default WiFi to opt-in

This commit is contained in:
mikecarper committed 2026-10-01 23:19:48 -07:00
1 parent 5ef97727c7
commit 8505361ebe
11 files changed
+276 -11

No files matched your search

+17
View File
@@ -206,6 +206,23 @@ image over USB once when moving a device from the legacy partition layout.
## WiFi companion setup
Full and Bluetooth/wired-capable Companions default to infrastructure WiFi off.
Bluetooth starts where the build supports it, and saved WiFi enablement is
preserved across upgrades. Before using the setup AP or station, enable WiFi
through the USB or Bluetooth CLI:
```text
get wifi.enabled
set wifi.enabled 1
```
ESP32 simultaneous-transport builds apply the change without a reboot. SenseCAP
Indicator exclusive-transport builds and Pico W require a reboot. WiFi-only
compatibility builds without any other CLI transport retain WiFi on so they are
not left unreachable. Saving an SSID/password alone does not enable WiFi on an
opt-in build. Use `set wifi.enabled 0` to save it off again. This controls
infrastructure WiFi, not a primary ESP-NOW mesh radio.
A `*_companion_radio_wifi` build replaces the BLE or USB companion link with
the MeshCore companion protocol over TCP port 5000. The phone or computer must
be able to reach the device on the same LAN.
+16
View File
@@ -363,6 +363,15 @@ new address; USB password input is masked. Binary Companion clients can use
USB, BLE, or TCP port 5000 without the terminal-start token: send command
`0x42` (`CMD_RUN_CLI_COMMAND`) followed by the same CLI text, such as
`get wifi.powersave`, `get wifi.pwd`, `stats-core`, or `set wifi.powersave min`.
Full and Bluetooth/wired-capable Companions default to infrastructure WiFi off
and Bluetooth on where supported. Legacy WiFi-only builds without another CLI
transport retain WiFi on so they are not left unreachable.
Use `get wifi.enabled` and `set wifi.enabled 1` to inspect or persistently enable
WiFi; `set wifi.enabled 0` disables it. Saved enablement survives upgrades, and
credentials alone do not turn it on. ESP32 simultaneous-transport builds apply
the change from the main loop; exclusive-transport builds and Pico W apply it
after reboot. This does not disable a primary ESP-NOW mesh radio or add Bluetooth
to a WiFi-only build.
See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands)
for the complete access rules. WiFi-only Companions accept
all three modes. A Full Companion that runs BLE and infrastructure WiFi
@@ -900,6 +909,13 @@ saved settings. The WebConfig **Display** card exposes the same controls:
Modes are `off`, `on`, `button`, `pairing`, `button-pairing`, and `automatic`.
Pairing modes are available only on builds supporting BLE pairing.
All display-equipped Companion builds show the screen at boot in the default
`button` or `button-pairing` mode, on battery or USB power. It stays on for the
selected profile's timeout (15 seconds by default); button input restarts that
timer. This also applies to previously saved button modes. Saved `off` and
pairing-only modes still suppress the boot screen. Repeaters, room servers,
and sensors do not gain this Companion-only boot wake.
- `off`: stays dark, including boot and pairing.
- `on`: stays on while the device is awake.
- `button`: a physical button, keyboard/navigation input, or supported touch
+7
View File
@@ -18,6 +18,13 @@ updating other nodes. Most targets can run those transports simultaneously;
the SenseCAP Indicator exception selects one secondary wireless transport per
boot as described below.
Fresh Full Companion installs default to Bluetooth on and infrastructure WiFi off.
WiFi remains compiled in: use `set wifi.enabled 1` through the USB or Bluetooth
CLI to enable it, or `set wifi.enabled 0` to disable it persistently. Existing
saved WiFi enablement is honored after an upgrade. The SenseCAP Indicator
requires a reboot to apply its exclusive transport selection; primary ESP-NOW
mesh operation is unaffected by the infrastructure-WiFi default.
The full Companion is deliberately not a LoRa OTA destination: it has no
firmware staging store, refuses `ota install`, and never advertises its own
firmware as an mOTA image.
+10 -2
View File
@@ -2300,7 +2300,14 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
_prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US;
_prefs.powersaving_enabled = 1;
_prefs.powersaving_policy_version = 0;
// Keep WiFi-only compatibility builds reachable when they have no other
// CLI transport. Full and Bluetooth/wired-capable Companions are opt-in.
#if defined(WIFI_SSID) && !defined(BLE_PIN_CODE) && !defined(ENABLE_USB_INTERFACE) \
&& !defined(SERIAL_RX) && !defined(ETHERNET_ENABLED)
_prefs.wifi_enabled = 1;
#else
_prefs.wifi_enabled = 0;
#endif
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
_prefs.display_rotation_degrees = 0;
_prefs.cad_enabled = DEFAULT_CAD_ENABLED ? 1 : 0;
@@ -2398,10 +2405,11 @@ void MyMesh::begin(bool has_display, bool radio_available) {
mesh::ui::loadDisplayPowerSettings(_store->getPrimaryFS(),
#ifdef BLE_PIN_CODE
true
true,
#else
false
false,
#endif
true // All Companion transports show startup unless the saved mode forbids it.
);
#if COMPANION_FEATURE_NOTIFICATIONS
+3 -3
View File
@@ -1273,7 +1273,7 @@ void halt() {
static char configured_wifi_ssid[32];
static char configured_wifi_password[65];
static bool companion_wifi_has_credentials = false;
static bool companion_wifi_requested = true;
static bool companion_wifi_requested = false;
static bool companion_wifi_active = false;
static bool companion_wifi_disable_in_progress = false;
static bool companion_wifi_services_stopped = false;
@@ -1291,7 +1291,7 @@ void halt() {
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
static CompanionTransportMode companion_transport_boot_mode =
CompanionTransportMode::WiFi;
CompanionTransportMode::Bluetooth;
static bool companion_transport_boot_mode_loaded = false;
CompanionTransportMode getCompanionTransportMode() {
@@ -3074,7 +3074,7 @@ void setup() {
} else {
stopCompanionInfrastructureWiFi();
board.setInhibitSleep(false);
WIFI_DEBUG_PRINTLN("WiFi remains off from the saved BOOT/GPIO 0 setting");
WIFI_DEBUG_PRINTLN("WiFi remains off; use set wifi.enabled 1 to enable it");
}
#if defined(COMPANION_EXCLUSIVE_WIFI_BLE)
}
+12 -2
View File
@@ -25,6 +25,8 @@ struct DisplayPowerPrefs {
DisplayPowerProfile battery;
DisplayPowerProfile usb;
DisplayInboxMode inbox = DisplayInboxMode::History;
// Runtime role policy, not serialized with the user's display settings.
bool wake_on_boot = false;
};
inline DisplayPowerPrefs& displayPowerPrefs() {
@@ -70,7 +72,8 @@ public:
void update(const DisplayPowerPrefs& prefs, bool usb, bool connected,
bool pairing, uint32_t now) {
const auto& selected = usb ? prefs.usb : prefs.battery;
const bool reset = !_initialized || _usb != usb || _profile.mode != selected.mode;
const bool startup = !_initialized;
const bool reset = startup || _usb != usb || _profile.mode != selected.mode;
const bool timeout_changed = _profile.seconds != selected.seconds;
const bool just_connected = connected && !_connected;
_profile = selected;
@@ -80,7 +83,14 @@ public:
if (reset) {
_timed = false;
_initialized = true;
wake(DisplayWake::Boot, now);
// Companions show startup for the normal button timeout. Only the first
// initialization counts; changing profiles must not synthesize input.
if (startup && prefs.wake_on_boot && (_profile.mode == DisplayMode::Button
|| _profile.mode == DisplayMode::ButtonPairing)) {
wake(DisplayWake::Button, now);
} else {
wake(DisplayWake::Boot, now);
}
} else if (timeout_changed && _timed) {
_deadline = now + uint32_t(_profile.seconds) * 1000;
}
+2 -1
View File
@@ -80,10 +80,11 @@ bool savePrefs(const DisplayPowerPrefs& prefs) {
}
}
bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported) {
bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported, bool wake_on_boot) {
settings_fs = fs;
pairing_available = pairing_supported;
DisplayPowerPrefs prefs;
prefs.wake_on_boot = wake_on_boot;
if (pairing_supported) prefs.battery.mode = prefs.usb.mode = DisplayMode::ButtonPairing;
displayPowerPrefs() = prefs;
if (!fs) return false;
+3 -1
View File
@@ -7,7 +7,9 @@ namespace mesh { namespace ui {
// Separate, versioned file: existing node and MQTT preference layouts stay
// readable by older firmware. Returns true when a saved profile was loaded.
bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported);
// Companion startup enables a timed boot screen without changing saved modes.
bool loadDisplayPowerSettings(FILESYSTEM* fs, bool pairing_supported,
bool wake_on_boot = false);
bool handleDisplayPowerCommand(const char* command, char* reply, size_t reply_size,
bool inbox_supported = false);
bool displayPairingSupported();
+19 -1
View File
@@ -128,6 +128,7 @@ int main(int argc,char** argv){
}
DataStore fresh;CompanionNodePrefs defaults;double lat=0,lon=0;
assert(fresh.loadPrefs(defaults,lat,lon));
assert(defaults.wifi_enabled==0); // Fresh installs never enable WiFi implicitly.
assert(fresh.savePrefs(defaults,lat,lon));
} else if(scenario==2){
#if defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
@@ -198,6 +199,23 @@ int main(int argc,char** argv){
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.flood_retry_advert_enabled==0);
assert(loaded.one_key_dm_enabled==0);
} else if(scenario==8){
for(uint8_t enabled : {0,1}){
CompanionNodePrefs saved=original;saved.wifi_enabled=enabled;
assert(store.savePrefs(saved,47.1,-122.2));
CompanionNodePrefs loaded;double lat=0,lon=0;
loaded.wifi_enabled=1-enabled;
assert(store.loadPrefs(loaded,lat,lon));
assert(loaded.wifi_enabled==enabled); // Saved choices override the default.
assert(store.fs.files["/new_prefs"][156]==enabled); // Established byte offset.
}
for(unsigned size : {84,144,155,156}){
DataStore legacy;legacy.fs.files["/new_prefs"]=store.fs.files["/new_prefs"];
legacy.fs.files["/new_prefs"].resize(size);
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.wifi_enabled==0); // Old files with no WiFi field use opt-in.
}
} else if(scenario==4){
DataStore legacy;legacy.fs.files["/node_prefs"]=disk;
legacy.fs.fail_write_after=17;
@@ -285,7 +303,7 @@ inline char* utoa(unsigned int value,char* output,int base){
str(ROOT / 'src/helpers/TxtDataHelpers.cpp'),
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
for scenario in range(8):
for scenario in range(9):
with self.subTest(scenario=scenario):
run = subprocess.run([str(binary), str(scenario)], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
+37
View File
@@ -170,6 +170,43 @@ int main() {
class CompanionUsbDefaultTest(unittest.TestCase):
def test_wifi_defaults_preserve_a_reachable_cli(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
start = mesh.index(" // Keep WiFi-only compatibility builds reachable")
end = mesh.index(" memset(_prefs.bluetooth_name", start)
defaults = mesh[start:end]
profiles = {
"esp32-full": (["ESP32=1", "WIFI_SSID=1", "BLE_PIN_CODE=123456",
"ENABLE_USB_INTERFACE=1", "COMPANION_RADIO_FULL=1"], 0),
"esp32-ble-wifi": (["ESP32=1", "WIFI_SSID=1", "BLE_PIN_CODE=123456"], 0),
"esp32-usb-wifi": (["ESP32=1", "WIFI_SSID=1", "ENABLE_USB_INTERFACE=1"], 0),
"esp32-wifi-only": (["ESP32=1", "WIFI_SSID=1"], 1),
"pico-usb-ble-wifi": (["RP2040_PLATFORM=1", "WIFI_SSID=1",
"ENABLE_USB_INTERFACE=1", "BLE_PIN_CODE=123456"], 0),
"pico-wifi-only": (["RP2040_PLATFORM=1", "WIFI_SSID=1"], 1),
"ethernet-wifi": (["ESP32=1", "WIFI_SSID=1", "ETHERNET_ENABLED=1"], 0),
"hardware-serial-wifi": (["ESP32=1", "WIFI_SSID=1", "SERIAL_RX=1"], 0),
"indicator-exclusive": (["ESP32=1", "WIFI_SSID=1", "BLE_PIN_CODE=123456",
"ENABLE_USB_INTERFACE=1", "COMPANION_EXCLUSIVE_WIFI_BLE=1"], 0),
"nrf52-ble": (["NRF52_PLATFORM=1", "BLE_PIN_CODE=123456"], 0),
"usb-only": (["ENABLE_USB_INTERFACE=1"], 0),
}
with tempfile.TemporaryDirectory() as directory:
binary = Path(directory) / "wireless-defaults"
for name, (flags, expected) in profiles.items():
with self.subTest(profile=name):
source = ("#include <cassert>\nstruct Prefs { unsigned wifi_enabled = 99; } _prefs;\n"
"int main() {\n" + defaults
+ f"assert(_prefs.wifi_enabled == {expected});\n}}\n")
build = subprocess.run(["g++", "-std=c++17", "-x", "c++",
*["-D" + flag for flag in flags],
"-", "-o", str(binary)], input=source,
text=True, capture_output=True)
self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
subprocess.run([str(binary)], check=True)
self.assertIn("static bool companion_wifi_requested = false;", MAIN)
self.assertIn("companion_transport_boot_mode =\n CompanionTransportMode::Bluetooth;", MAIN)
def test_startup_and_reconnection_for_each_usb_transport(self):
functions = "\n".join(function("static void " + name + "(") for name in (
"resetUsbTerminalHostSession", "serviceUsbTerminalHostSessionReset",
+150 -1
View File
@@ -1,5 +1,6 @@
"""Exercise the shared display policy and real settings/CLI code on the host."""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
@@ -109,6 +110,135 @@ void policyTests() {
p.update(prefs, false, false, true, 1001);
assert(!p.on()); // Off wins over a pending button wake and pairing.
}
void companionBootTests() {
// The startup exception is role-wide, independent of transport, power
// source, or whether an app has already connected by the first UI frame.
for (bool usb : {false, true}) for (bool connected : {false, true}) {
for (unsigned mode = 0; mode <= 5; ++mode) {
DisplayPowerPrefs prefs;
prefs.wake_on_boot = true;
prefs.battery.mode = prefs.usb.mode = DisplayMode::Off;
auto& profile = usb ? prefs.usb : prefs.battery;
profile.mode = DisplayMode(mode);
DisplayPowerPolicy p;
p.update(prefs, usb, connected, false, 100);
const bool button_mode = mode == 2 || mode == 4;
assert(p.on() == (mode == 1 || button_mode || (mode == 5 && !connected)));
p.update(prefs, usb, connected, false, 4100);
assert(p.on() == (mode == 1 || button_mode)); // Normal 15s, not 4s.
p.update(prefs, usb, connected, false, 15099);
assert(p.on() == (mode == 1 || button_mode));
p.update(prefs, usb, connected, false, 15100);
assert(p.on() == (mode == 1));
p.update(prefs, usb, connected, false, 30000);
assert(p.on() == (mode == 1)); // Refresh cannot retrigger startup.
}
}
for (DisplayMode mode : {DisplayMode::Button, DisplayMode::ButtonPairing}) {
DisplayPowerPrefs prefs;
prefs.wake_on_boot = true;
prefs.battery = prefs.usb = {mode, 27};
DisplayPowerPolicy p;
p.update(prefs, false, false, false, 100);
p.update(prefs, false, true, false, 101);
assert(p.on()); // Connecting does not dismiss a button-mode boot screen.
assert(!p.wake(DisplayWake::Message, 200));
p.update(prefs, false, true, false, 27099);
assert(p.on());
assert(p.wake(DisplayWake::Button, 27099));
p.update(prefs, false, true, false, 27100);
assert(p.on()); // Real input extends the boot timeout.
p.update(prefs, false, true, false, 54099);
assert(!p.on());
p.update(prefs, false, true, true, 54100);
assert(p.on() == (mode == DisplayMode::ButtonPairing));
p.update(prefs, false, true, true, 100000);
assert(p.on() == (mode == DisplayMode::ButtonPairing));
p.update(prefs, false, true, false, 100001);
assert(!p.on());
DisplayPowerPolicy switched;
switched.update(prefs, false, false, false, 0);
assert(switched.on());
switched.update(prefs, true, false, false, 1);
assert(!switched.on()); // No synthetic boot when the power source changes.
switched.update(prefs, false, false, false, 2);
assert(!switched.on());
prefs.battery.mode = DisplayMode::Off;
switched.update(prefs, false, false, false, 3);
prefs.battery.mode = mode;
switched.update(prefs, false, false, false, 4);
assert(!switched.on()); // No synthetic boot when the mode changes.
DisplayPowerPolicy dismissed;
dismissed.update(prefs, false, false, false, 0);
dismissed.dismiss();
dismissed.update(prefs, false, false, false, 1);
assert(!dismissed.on());
DisplayPowerPolicy rollover;
rollover.update(prefs, false, false, false, UINT32_MAX - 500);
rollover.update(prefs, false, false, false, 26498);
assert(rollover.on());
rollover.update(prefs, false, false, false, 26499);
assert(!rollover.on());
}
}
void companionSettingsTests() {
for (bool ble : {false, true}) {
FakeFS fs;
assert(!loadDisplayPowerSettings(&fs, ble, true));
assert(displayPowerPrefs().wake_on_boot);
const DisplayMode mode = ble ? DisplayMode::ButtonPairing : DisplayMode::Button;
assert(displayPowerPrefs().battery.mode == mode);
assert(displayPowerPrefs().usb.mode == mode);
char reply[160];
for (const char* command : {"set display.timeout 27", "set display.usb.timeout 123"}) {
assert(handleDisplayPowerCommand(command, reply, sizeof(reply)));
assert(std::string(reply) == "OK");
}
assert(fs.files["/display_prefs"].size() == 12); // No on-device format change.
assert(fs.files["/display_prefs"][10] == 0);
assert(loadDisplayPowerSettings(&fs, ble, true));
assert(displayPowerPrefs().wake_on_boot);
for (bool usb : {false, true}) {
DisplayPowerPolicy p;
p.update(displayPowerPrefs(), usb, false, false, 0);
assert(p.on()); // Previously saved button modes also show startup.
const uint32_t timeout = usb ? 123000 : 27000;
p.update(displayPowerPrefs(), usb, false, false, timeout - 1);
assert(p.on());
p.update(displayPowerPrefs(), usb, false, false, timeout);
assert(!p.on());
}
for (const char* command : {"set display.mode off", "set display.usb.mode off"}) {
assert(handleDisplayPowerCommand(command, reply, sizeof(reply)));
assert(std::string(reply) == "OK");
}
for (unsigned version : {1, 2}) {
auto& saved = fs.files["/display_prefs"];
saved[2] = version; saved[11] = 0;
for (unsigned i = 0; i < 11; ++i) saved[11] ^= saved[i];
assert(loadDisplayPowerSettings(&fs, ble, true));
assert(displayPowerPrefs().wake_on_boot);
for (bool usb : {false, true}) {
DisplayPowerPolicy p;
p.update(displayPowerPrefs(), usb, false, true, 0);
assert(!p.on()); // Saved off wins over startup and pairing.
assert(!p.wake(DisplayWake::Button, 1));
}
}
assert(loadDisplayPowerSettings(&fs, ble));
assert(!displayPowerPrefs().wake_on_boot); // Infrastructure does not opt in.
assert(!loadDisplayPowerSettings(nullptr, ble, true));
assert(displayPowerPrefs().wake_on_boot); // Defaults still work without flash.
DisplayPowerPolicy p;
p.update(displayPowerPrefs(), false, false, false, 0);
assert(p.on());
assert(!loadDisplayPowerSettings(nullptr, ble));
assert(!displayPowerPrefs().wake_on_boot);
}
}
void commandTests() {
FakeFS fs;
assert(!loadDisplayPowerSettings(&fs, true));
@@ -190,11 +320,30 @@ void commandTests() {
run("get display.timeout", "> 42");
run("get display.usb.timeout", "> 42");
}
int main() { policyTests(); commandTests(); }
int main() {
policyTests(); companionBootTests(); companionSettingsTests(); commandTests();
}
'''
class DisplayPowerTest(unittest.TestCase):
def test_companion_boot_wake_is_transport_and_layout_independent(self):
mesh = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
self.assertRegex(mesh, re.compile(
r"loadDisplayPowerSettings\(_store->getPrimaryFS\(\),\s*"
r"#ifdef BLE_PIN_CODE\s*true,\s*#else\s*false,\s*#endif\s*true\b"))
main = (ROOT / "examples/companion_radio/main.cpp").read_text()
setup = main[main.index("void setup()"):main.index("\nvoid loop()")]
self.assertLess(setup.index("the_mesh.begin("), setup.index("ui_task.begin("))
for layout in ("ui-new", "ui-orig", "ui-tiny"):
with self.subTest(layout=layout):
ui = (ROOT / "examples/companion_radio" / layout / "UITask.cpp").read_text()
start = ui.index("void UITask::begin(")
end = ui.index("\n}", start)
self.assertIn("_display->servicePower(", ui[start:end])
infrastructure = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
self.assertIn("loadDisplayPowerSettings(fs, false);", infrastructure)
def test_policy_and_persistent_commands(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "host C++ compiler required")