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
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")