mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-22 10:22:34 +00:00
Preserve raw UART console input while power saving is enabled
This commit is contained in:
1 parent
8fe83ebc29
commit
757f15fcb6
8 files changed
+154
-27
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user