diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 483ea921e..3b1ba343c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -2337,10 +2337,13 @@ keep the hardware awake. Actual sleep depends on the board. On ESP32 infrastructure boards using an external USB-to-UART chip, such as the Heltec V3's CP2102, the native USB host guard cannot detect an attached computer. -With all bridges and USB logging stopped, light sleep can lose a console -command. Use `set powersaving off` for reliable USB configuration in that state. -If the console is already unresponsive, reset the board and send that command -within the first 120 seconds after boot, before infrastructure sleep is allowed. +Current source blocks MCU light sleep while the raw UART console driver is +enabled, preserving the first web-console command after idle even with all +bridges and USB logging stopped. This guard has no idle timeout and also covers +a computer's first connection after boot. These builds enable that console at +startup, so MCU light sleep is unavailable while it remains enabled, including +on battery. `set powersaving on` still enables sensor/GPS power-saving policies; +the CPU continues normal idle yielding while keeping the console available. A running MQTT, RS232 or ESP-NOW bridge keeps infrastructure awake even when `get powersaving` reports `on`; that readback alone does not prove MCU sleep. Adding UART wake alone would still discard the characters that trigger wake, diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index bd40afe9f..a79c59565 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -119,6 +119,13 @@ public: // not leave the last positive native USB host signal stale. const bool usb_host_connected = isUsbHostConnected(); bool keep_awake = inhibit_sleep || usb_host_connected || isRadioTestActive(); +#if !defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT + // External USB-to-UART chips cannot report host attachment here. Light + // sleep loses their first input bytes; UART wake loses the wake character + // too. HardwareSerial's bool reports an installed driver, not a USB host. + // Keep an enabled console available even before its first command. + keep_awake = keep_awake || static_cast(Serial); +#endif #if MESH_ESP32_USB_CONSOLE_COOPERATIVE // HWCDC loses its SOF host signal after about 5 ms. A warm Pi reboot, // especially on a USB 1.1 bus, must not put us in a 30-second light sleep diff --git a/test/fixtures/official_config_gui/contract.mjs b/test/fixtures/official_config_gui/contract.mjs index deb63979b..90c7774af 100644 --- a/test/fixtures/official_config_gui/contract.mjs +++ b/test/fixtures/official_config_gui/contract.mjs @@ -10,6 +10,7 @@ import {setImmediate as turn} from 'node:timers/promises'; const firmware=spawn(process.argv[4],[],{stdio:['pipe','pipe','inherit']}); const readyMs=Number(process.argv[5]); +const preconnectIdleMs=Number(process.argv[6]||0); let output, pending; createInterface({input:firmware.stdout}).on('line',line=>{ if(line.startsWith('DATA ')){ @@ -65,6 +66,12 @@ const bootstrap=Function('cli','app','showMessage','getData','getPresets','alert readFileSync(process.argv[3],'utf8')+'\nreturn {connect,disconnect};'); const gui=bootstrap(cli,app,()=>{},async()=>{dataReads++;},async()=>{},message=>alerts.push(message)); let finished=false; +if(preconnectIdleMs>0){ + // No host byte or SerialCLI operation precedes this idle interval. The + // first complete command remains the untouched GUI's single time request. + await device('TIME '+preconnectIdleMs); + assert.equal(writes,0,'Preconnect idle gained artificial UART traffic'); +} const connection=gui.connect().finally(()=>{finished=true;}); try{ for(let step=0;step<20&&!finished;step++){ diff --git a/test/fixtures/official_web_console/contract.mjs b/test/fixtures/official_web_console/contract.mjs index 702948440..b2c9e94ef 100644 --- a/test/fixtures/official_web_console/contract.mjs +++ b/test/fixtures/official_web_console/contract.mjs @@ -9,6 +9,7 @@ import {setTimeout as delay} from 'node:timers/promises'; const {SerialConsole} = await import('data:text/javascript;base64,' + readFileSync(process.argv[2]).toString('base64')); const firmware = spawn(process.argv[3], [], {stdio:['pipe','pipe','inherit']}); +const rawUart = process.argv[4] === '1'; let stream, pending, state; let writes = 0; createInterface({input:firmware.stdout}).on('line', line => { @@ -53,7 +54,7 @@ try { await connect(); await query('set powersaving on',' -> OK - powersaving on\n','power-saving setter'); await query('get powersaving',' -> > on\n','power-saving getter'); - for(const elapsed of [121000,180000,360000,1800000,10800000,0xfffffffe]) { + for(const elapsed of [121000,180001,360000,1800000,10800000,0xfffffffe,0,180001]) { const before=writes; await command('TIME '+elapsed); assert.equal(writes,before,'An idle web console gained an artificial keepalive'); @@ -63,12 +64,18 @@ try { await client.disconnect();await connect(); await query('get powersaving',' -> > on\n','reopen query'); await query('set powersaving off',' -> OK - powersaving off\n','disable power saving'); - await command('HOST_OFF');await command('TIME 121000'); + await command('HOST_OFF');await command('TIME 180002'); assert.equal(state[1],0,'Disabled power saving slept'); await query('set powersaving on',' -> OK - powersaving on\n','enable battery power saving'); - await command('TIME 242000'); + // Closing Web Serial or disconnecting the USB-to-UART bridge cannot prove + // that a raw UART driver is no longer needed. Only driver disposal releases + // its sleep veto; native CDC continues using actual host availability. + if(rawUart) await command('DRIVER_END'); + // Expire native CDC's existing 120-second host-loss grace, measured from + // the last positive host observation at 180001 after the clock wrapped. + await command('TIME 300001'); assert.equal(state[1],1,'USB protection permanently disabled battery power saving'); - console.log('Official console: CRLF, no-DTR idle, power saving, reopen and battery sleep passed'); + console.log('Official console: CRLF, no-DTR idle, power saving, reopen and released-driver sleep passed'); } finally { if(client?.connected) await client.disconnect(); firmware.stdin.end(); diff --git a/test/fixtures/official_web_console/firmware.cpp b/test/fixtures/official_web_console/firmware.cpp index 4483e4d8f..705637818 100644 --- a/test/fixtures/official_web_console/firmware.cpp +++ b/test/fixtures/official_web_console/firmware.cpp @@ -82,7 +82,8 @@ int main() { std::string line; while (std::getline(std::cin, line)) { if (line.rfind("WRITE ", 0) == 0) { - // A USB peripheral suspended in light sleep cannot deliver new bytes. + // A sleeping console cannot deliver the first complete command during + // the stock reply window; no UART wake source was configured. if (sleep_calls == 0) { for (size_t n = 6; n < line.size(); n += 2) { unsigned byte; assert(n + 1 < line.size()); @@ -97,6 +98,8 @@ int main() { console.input.clear(); console.output.clear(); command[0] = 0; } else if (line == "HOST_OFF") { attachHost(false); + } else if (line == "DRIVER_END") { + Serial.end(); } else { assert(false && "Unknown peripheral operation"); } emit(); } diff --git a/test/test_esp32_usb_sleep.py b/test/test_esp32_usb_sleep.py index 94e0d96d4..da4dadbca 100644 --- a/test/test_esp32_usb_sleep.py +++ b/test/test_esp32_usb_sleep.py @@ -30,8 +30,16 @@ static void require(bool ok, const char* message) { struct SerialPort { bool terminal_open = false; bool host_attached = false; - explicit operator bool() const { return terminal_open; } + bool driver_initialized = true; + explicit operator bool() const { +#if ARDUINO_USB_CDC_ON_BOOT + return terminal_open; +#else + return driver_initialized; +#endif + } bool isPlugged() const { return host_attached; } + void end() { driver_initialized = false; } } Serial; struct UsbDevice { bool mounted = false; @@ -137,10 +145,14 @@ int main() { require(sleep_calls == 0, "timer-only sleep lost USB"); board.irq = 48; #else - // A UART build has no native host detection. Keep its existing behavior. + // A UART driver has no native host detection. Its initialized console + // must preserve the first incoming command even before any host traffic. attachHost(true); Serial.terminal_open = true; require(!board.isUsbHostConnected(), "UART build invented a USB host"); + board.sleep(30); + require(sleep_calls == 0, "initialized UART console allowed light sleep"); + Serial.end(); #endif // After the bounded host-reboot grace, disconnected USB power must still @@ -237,6 +249,50 @@ def board_method(signature: str) -> str: class Esp32UsbSleepTest(unittest.TestCase): + def test_raw_uart_driver_preserves_fresh_console_indefinitely_until_end(self): + methods = "\n".join(board_method(signature) for signature in ( + "void sleep(uint32_t secs) override", "bool isUsbDataConnected() override", + "bool isUsbHostConnected() override")) + harness = HARNESS.split("int main()", 1)[0] + r''' +int main() { + try { + ESP32Board board; + attachHost(false); + Serial.terminal_open = false; + mesh::logging_enabled = false; + for (uint32_t now : {180001U, 10800000U, UINT32_MAX, 0U, 180001U}) { + mock_millis = now; + require(!board.isUsbDataConnected() && !board.isUsbHostConnected(), + "UART driver was falsely reported as a USB host"); + board.sleep(30); + require(sleep_calls == 0, "fresh initialized UART console slept after idle"); + require(timer_us == 0 && critical_depth == 0, + "UART-inhibited sleep configured timer or interrupted IRQs"); + } + require(yield_ms > 0, "UART-inhibited sleep did not yield"); + Serial.end(); + board.sleep(30); + require(sleep_calls == 1 && timer_us == 30000000ULL, + "ending the raw UART driver did not restore sleep eligibility"); + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; return 1; + } +} +''' + compiler = os.environ.get("CXX", "c++") + self.assertIsNotNone(shutil.which(compiler), "a C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-raw-uart-sleep-") as directory: + source = Path(directory) / "uart.cpp" + binary = Path(directory) / "uart" + source.write_text(harness.replace("@METHODS@", methods), encoding="ascii") + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", + "-DARDUINO_USB_CDC_ON_BOOT=0", "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=0", + "-DMESH_USB_LOGGING_AVAILABLE=0", "-I", str(ROOT / "src"), + str(source), "-o", str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + def test_native_host_survives_terminal_close_and_reconnect(self): compiler = os.environ.get("CXX", "c++") self.assertIsNotNone(shutil.which(compiler), "a C++17 compiler is required") @@ -277,6 +333,9 @@ class Esp32UsbSleepTest(unittest.TestCase): harness = HARNESS.split("int main()", 1)[0] + r''' int main() { try { +#if !ARDUINO_USB_CDC_ON_BOOT + Serial.end(); // This case exercises a UART board without a console driver. +#endif // Charger-only/battery startup must not create a host-reboot grace. ESP32Board cold; attachHost(false); diff --git a/test/test_official_config_gui.py b/test/test_official_config_gui.py index 0c9ae6c72..85e578104 100644 --- a/test/test_official_config_gui.py +++ b/test/test_official_config_gui.py @@ -176,9 +176,11 @@ def nrf52_headers(work): return fixture -def cli_source(*, lf_reply=False, reject_cr=False, platform="ESP32_PLATFORM"): +def cli_source(*, lf_reply=False, reject_cr=False, platform="ESP32_PLATFORM", + lose_raw_uart_guard=False): source = (nrf52_cli_source() if platform == "NRF52_PLATFORM" - else console.firmware_harness(lose_crlf=lf_reply)) + else console.firmware_harness(lose_crlf=lf_reply, + lose_raw_uart_guard=lose_raw_uart_guard)) if platform == "NRF52_PLATFORM" and lf_reply: source = source.replace('console.printf(" -> %s\\r\\n", reply);', 'console.printf(" -> %s\\n", reply);') @@ -218,7 +220,8 @@ class OfficialConfigGuiTests(unittest.TestCase): cls.sources = official_sources() def run_contract(self, *, blocking=False, lf_reply=False, reject_cr=False, - extra_boot_ms=0, platform="ESP32_PLATFORM"): + extra_boot_ms=0, platform="ESP32_PLATFORM", raw_uart=False, + preconnect_idle_ms=0, lose_raw_uart_guard=False): with tempfile.TemporaryDirectory(prefix="official-config-gui-") as directory: work = Path(directory) gui = self.sources["src/gui.js"].read_text() @@ -246,8 +249,13 @@ class OfficialConfigGuiTests(unittest.TestCase): self.assertGreaterEqual(extra_boot_ms, 0) boot_ms = int(measured.stdout.strip()) + extra_boot_ms cli_cpp, cli = work / "cli.cpp", work / "cli" - cli_cpp.write_text(cli_source(lf_reply=lf_reply, reject_cr=reject_cr, - platform=platform), encoding="ascii") + cli_text = cli_source(lf_reply=lf_reply, reject_cr=reject_cr, platform=platform, + lose_raw_uart_guard=lose_raw_uart_guard) + if raw_uart: + self.assertEqual(platform, "ESP32_PLATFORM") + self.assertIn(" attachHost(true);", cli_text) + cli_text = cli_text.replace(" attachHost(true);", " prefs.powersaving_enabled = 1;\n attachHost(true);") + cli_cpp.write_text(cli_text, encoding="ascii") if platform == "NRF52_PLATFORM": peripheral = nrf52_headers(work) cli_flags = ["-DARDUINO", "-DNRF52_PLATFORM", "-DUSE_TINYUSB", @@ -255,9 +263,10 @@ class OfficialConfigGuiTests(unittest.TestCase): cli_sources = [str(ROOT / "src/helpers" / relative) for relative in ( "UsbLogging.cpp", "UsbLoggingClientActivity.cpp", "UsbLoggingLineStateOverride.cpp")] else: - cli_flags = ["-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DESP32_PLATFORM=1", "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", - "-DMESH_USB_CONSOLE_COOPERATIVE=1", "-DMESH_USB_LOGGING_AVAILABLE=1", + cdc = int(not raw_uart) + cli_flags = ["-DARDUINO_USB_MODE=1", f"-DARDUINO_USB_CDC_ON_BOOT={cdc}", + "-DESP32_PLATFORM=1", f"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE={cdc}", + f"-DMESH_USB_CONSOLE_COOPERATIVE={cdc}", "-DMESH_USB_LOGGING_AVAILABLE=1", "-DCONFIG_TINYUSB_ENABLED=0"] cli_sources = [] command = [self.compiler, "-std=c++17", "-Wall", "-Wextra", @@ -267,9 +276,22 @@ class OfficialConfigGuiTests(unittest.TestCase): self.assertEqual(built.returncode, 0, built.stdout + built.stderr) result = subprocess.run([self.node, str(FIXTURE / "contract.mjs"), str(self.sources["lib/serial-cli.js"]), str(work / "bootstrap.js"), - str(cli), str(boot_ms)], text=True, capture_output=True, timeout=20) + str(cli), str(boot_ms), str(preconnect_idle_ms)], + text=True, capture_output=True, timeout=20) return result + def test_raw_uart_first_stock_gui_command_survives_preconnect_idle(self): + for idle_ms in (180001, 10800000, 0xFFFFFFFE): + with self.subTest(idle_ms=idle_ms): + result = self.run_contract(raw_uart=True, preconnect_idle_ms=idle_ms) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_missing_raw_uart_sleep_guard_breaks_first_stock_gui_command(self): + result = self.run_contract(raw_uart=True, preconnect_idle_ms=180001, + lose_raw_uart_guard=True) + self.assertNotEqual(result.returncode, 0, "Negative control did not fail") + self.assertIn("initial time missed the stock 5000 ms deadline", result.stderr) + def test_stock_gui_time_handshake_with_cooperative_gps_and_cr_only(self): result = self.run_contract() self.assertEqual(result.returncode, 0, result.stdout + result.stderr) diff --git a/test/test_official_web_console.py b/test/test_official_web_console.py index e960894b6..3d6237bcb 100644 --- a/test/test_official_web_console.py +++ b/test/test_official_web_console.py @@ -43,7 +43,8 @@ def official_console(): return path.resolve() -def firmware_harness(*, lose_sleep_guard=False, lose_crlf=False): +def firmware_harness(*, lose_sleep_guard=False, lose_crlf=False, + lose_raw_uart_guard=False): # Retain the existing host peripheral boundary; use the production methods # and a virtual clock to exercise minutes/hours without slowing CI. source = HARNESS.split("int main()", 1)[0] @@ -58,6 +59,10 @@ def firmware_harness(*, lose_sleep_guard=False, lose_crlf=False): methods = methods.replace("usb_host_sleep_policy.shouldKeepAwake(\n" " millis(), MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS)", "false") + if lose_raw_uart_guard: + expression = "keep_awake = keep_awake || static_cast(Serial);" + assert expression in methods, "Missing raw UART negative-control boundary" + methods = methods.replace(expression, "keep_awake = keep_awake || false;") source = source.replace("@METHODS@", methods) source = source.replace(" uint32_t irq = 48;", " void loop() {}\n uint32_t irq = 48;") cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text() @@ -85,32 +90,46 @@ class OfficialWebConsoleTests(unittest.TestCase): raise unittest.SkipTest("Node 18+ and a host C++17 compiler are required") cls.console = official_console() - def run_contract(self, *, lose_sleep_guard=False, lose_crlf=False): + def run_contract(self, *, lose_sleep_guard=False, lose_crlf=False, + raw_uart=False, lose_raw_uart_guard=False): with tempfile.TemporaryDirectory(prefix="official-web-console-") as tmp: tmp = Path(tmp) source = tmp / "firmware.cpp" source.write_text(firmware_harness(lose_sleep_guard=lose_sleep_guard, - lose_crlf=lose_crlf), encoding="ascii") + lose_crlf=lose_crlf, + lose_raw_uart_guard=lose_raw_uart_guard), encoding="ascii") binary = tmp / "firmware" sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) result = subprocess.run([self.compiler, "-std=c++17", "-Wall", "-Wextra", - "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=1", - "-DESP32_PLATFORM=1", "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=1", - "-DMESH_USB_CONSOLE_COOPERATIVE=1", "-DMESH_USB_LOGGING_AVAILABLE=1", + "-DARDUINO_USB_MODE=1", "-DARDUINO_USB_CDC_ON_BOOT=" + str(int(not raw_uart)), + "-DESP32_PLATFORM=1", + "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=" + str(int(not raw_uart)), + "-DMESH_USB_CONSOLE_COOPERATIVE=" + str(int(not raw_uart)), + "-DMESH_USB_LOGGING_AVAILABLE=1", "-DCONFIG_TINYUSB_ENABLED=0", "-I", str(ROOT / "src"), *sanitizers, str(source), "-o", str(binary)], text=True, capture_output=True, timeout=60) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) result = subprocess.run([self.node, str(ROOT / "test/fixtures/official_web_console/contract.mjs"), - str(self.console), str(binary)], text=True, capture_output=True, timeout=20) + str(self.console), str(binary), str(int(raw_uart))], + text=True, capture_output=True, timeout=20) return result def test_stock_console_crlf_idle_power_saving_and_reopen(self): result = self.run_contract() self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + def test_raw_uart_stock_console_idle_power_saving_and_reopen(self): + result = self.run_contract(raw_uart=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_missing_raw_uart_sleep_guard_breaks_stock_console(self): + result = self.run_contract(raw_uart=True, lose_raw_uart_guard=True) + self.assertNotEqual(result.returncode, 0, "Negative control did not lose the console") + self.assertIn("idle query", result.stderr) + def test_missing_usb_sleep_guard_breaks_stock_console(self): result = self.run_contract(lose_sleep_guard=True) self.assertNotEqual(result.returncode, 0, "Negative control did not lose the console")