Preserve raw UART console input while power saving is enabled

This commit is contained in:
mikecarper committed 2026-10-05 20:11:10 -07:00
1 parent 8fe83ebc29
commit 757f15fcb6
8 files changed
+154 -27

No files matched your search

+7 -4
View File
@@ -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,
+7
View File
@@ -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<bool>(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
+7
View File
@@ -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++){
+11 -4
View File
@@ -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();
+4 -1
View File
@@ -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();
}
+61 -2
View File
@@ -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);
+31 -9
View File
@@ -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)
+26 -7
View File
@@ -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<bool>(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")