From fc3298c84d756b322351b3113b86fe04f66f9813 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 7 Sep 2026 16:28:02 -0700 Subject: [PATCH] Fix G3 USB sleep and two-minute button wake --- .github/workflows/run-unit-tests.yml | 3 + docs/cli_commands.md | 10 + docs/releases/1.17.1.5.md | 51 ++++ docs/role_feature_switches.md | 9 + examples/companion_radio/main.cpp | 19 +- examples/companion_radio/ui-new/UITask.cpp | 3 + examples/simple_repeater/UITask.cpp | 3 + examples/simple_repeater/main.cpp | 12 +- examples/simple_room_server/UITask.cpp | 5 +- examples/simple_room_server/main.cpp | 5 + examples/simple_sensor/UITask.cpp | 3 + examples/simple_sensor/main.cpp | 5 + src/helpers/ESP32Board.h | 51 +++- src/helpers/ui/MomentaryButton.cpp | 11 + src/helpers/ui/MomentaryButton.h | 18 +- test/README.md | 1 + test/test_esp32_usb_sleep.py | 334 +++++++++++++++++++++ test/test_t096_button_wake_profile.py | 3 +- variants/station_g3_esp32/platformio.ini | 2 + 19 files changed, 527 insertions(+), 21 deletions(-) create mode 100644 test/test_esp32_usb_sleep.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 00e3dcc8..f01699e7 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -30,6 +30,9 @@ jobs: - name: Verify ESP32 static DRAM budget run: python3 -B test/test_esp32_dram.py + - name: Verify ESP32 USB sleep and G3 button wake + run: python3 -B test/test_esp32_usb_sleep.py + - name: Verify nRF52 UF2-reset CLI coverage run: python3 -B test/test_nrf52_uf2reset_cli.py diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 94758b39..0a1f715d 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1562,6 +1562,16 @@ enable command. USB power alone does not block a remote enable request. The separate `set powersaving on` form saves/applies the preference without those guards; it is also the form used by infrastructure WebConfig. +For the **1.17.1.5 G3 USB-disconnect report**, use `powersaving off` as the +workaround. The released ESP32 sleep code can lose native USB after two +minutes when the terminal is closed, even with a computer attached. The +source fix blocks ESP32 light sleep while a native USB host is attached or +live USB logging is enabled; ordinary CPU idle/yield remains available. +The G3 button also wakes the device for at least two minutes. See the +[G3 release note](releases/1.17.1.5.md#g3-usb-disappearance-with-power-saving-enabled) +for verification steps and the distinction between the fix and the published +binaries. + Companion firmware defaults this setting to `on`. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32, it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. USB and each active wireless transport remain available; SenseCAP Indicator Full keeps only its selected BLE or infrastructure-WiFi secondary transport active. `powersaving off` restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (`radio.rxps`) and WiFi modem power save (`wifi.powersave`). Infrastructure WebConfig uses the `set powersaving` form; enabling it can put the node to sleep and make WiFi temporarily unavailable. --- diff --git a/docs/releases/1.17.1.5.md b/docs/releases/1.17.1.5.md index 4a28f0ff..b010a531 100644 --- a/docs/releases/1.17.1.5.md +++ b/docs/releases/1.17.1.5.md @@ -17,6 +17,57 @@ The release build records the resolved frequency, bandwidth, spreading factor, and coding rate. The offline USA Cascadia fallback is 910.525 MHz, BW62.5, SF7, CR5. Saved node settings survive ordinary firmware updates. +## G3 USB disappearance with power saving enabled + +The published `26303793` firmware has an ESP32 native-USB sleep bug that +matches a Station G3 report: USB disappears from the connected Pi after a +few minutes, the display button stops responding, but the repeater still +answers over LoRa. Hardware confirmation of this report is pending. + +Cascade enables device power saving by default on fresh settings. After +two minutes, the repeater can enter light sleep when no serial terminal is +open, even though the Pi has enumerated the USB device. Light sleep stops +USB service; the radio GPIO can still wake the CPU, while the G3 display +button is only polled. Closing a terminal must not be treated as unplugging +the USB host. Espressif documents the underlying +[native USB sleep limitation](https://docs.espressif.com/projects/esp-idf/en/v4.4.7/esp32s3/api-guides/usb-otg-console.html#limitations). + +On 1.17.1.5, use the repeater's remote admin CLI, or its USB terminal before +it disappears: + +```text +powersaving off +powersaving +``` + +The second command should return `off`. This saves the setting and takes +effect immediately; it does not erase the node or change LoRa RX power +saving (`radio.rxps`). If USB does not reappear after disabling sleep +remotely, power-cycle once and check that the port and display button remain +usable beyond the two-minute mark. Ordinary reflashing can retain the +saved power-saving setting, so check it during comparisons with another +firmware build too. + +The source correction keeps native USB serviced while a host is enumerated, +including when its terminal is closed. Enabled live USB logging also blocks +ESP32 light sleep, even without a host. FreeRTOS can still idle/yield; this +does not disable the saved power-saving setting or Companion CPU scaling. +With logging off, USB power from a charger still permits sleep. + +The corrected G3 uses its user button as a light-sleep wake source alongside +LoRa. Button activity keeps the CPU and display awake for at least two minutes; +holding or pressing the button again extends the interval. The usual display +timeout can keep the screen on longer. An intentional long-press power-off +retains its existing action. After the wake interval, sleep is allowed when +power saving is enabled, logging is off, and no native USB host is attached. + +Host simulations cover both native USB modes, terminal close/reconnect, +logging toggles, OTA inhibition, simultaneous button/LoRa wake configuration, +and the two-minute interval across timer rollover. The published 1.17.1.5 +binaries have not been replaced by these corrections. + +## Firmware roles and update support + Use the exact board's Full Companion image when available. Every Full Companion must pass linked-firmware checks for MOTA sending, its text terminal, and the platform's source transports. ESP32 supplies USB and WiFi sources; diff --git a/docs/role_feature_switches.md b/docs/role_feature_switches.md index fdc608d0..b2f0982c 100644 --- a/docs/role_feature_switches.md +++ b/docs/role_feature_switches.md @@ -165,6 +165,15 @@ Infrastructure `set usb.logging` has **no `reboot` suffix**, including nRF52. `log start/stop` records to storage independently of live USB logging. Use `log erase` to delete that capture. +With the [G3 sleep correction](releases/1.17.1.5.md#g3-usb-disappearance-with-power-saving-enabled), +enabled live USB logging keeps ESP32 USB serviced and blocks light sleep, +including when a host closes the port or disconnects. CPU idle/yield remains +available. `set usb.logging off` removes that blocker; an attached native USB +host still prevents sleep. On Full infrastructure, `set logging.output usb` +and `both` enable the same USB blocker; `wifi` and `off` remove it. File capture +with `log start` is independent. The original 1.17.1.5 binaries require the +`powersaving off` workaround described in the release note. + ### Infrastructure MQTT and logging output **Search terms:** MQTT settings, MQTT on, MQTT off, logging output, USB and WiFi logging. diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index f3888331..a1bb18e8 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2461,10 +2461,14 @@ void loop() { serviceCompanionPowerSaving(); #endif - // USB power alone (for example, a wall charger) must not disable power - // saving. Stay awake only while an enumerated USB host is attached. + // USB power alone (for example, a wall charger) does not inhibit sleep. + // Host sessions, live logging, and button activity still need service. bool can_sleep = the_mesh.getNodePrefs()->powersaving_enabled && !the_mesh.hasPendingWork(); +#if defined(ESP32_PLATFORM) && MESH_USB_LOGGING_AVAILABLE + // The native-USB-only light-sleep path below bypasses ESP32Board::sleep. + can_sleep = can_sleep && !mesh::isUsbLoggingEnabled(); +#endif #if defined(NRF52_PLATFORM) \ || (defined(ESP32_PLATFORM) && defined(ENABLE_USB_INTERFACE)) can_sleep = can_sleep && !board.isUsbHostConnected(); @@ -2472,9 +2476,9 @@ void loop() { #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) - // GPIO edges wake the nRF52 poller, but multi-click and long-press results - // are due after a time interval rather than another edge. Stay awake only - // while that small gesture state machine has a pending deadline. + // GPIO wake starts the poller, but multi-click and long-press results + // are due after a time interval rather than another edge. The G3 also + // retains a two-minute wake interval after button activity. can_sleep = can_sleep && !user_btn.needsPolling(); #endif if (can_sleep) { @@ -2506,6 +2510,11 @@ void loop() { board.sleep(0); // event-driven idle; interrupts wake the main loop #endif } +#if defined(ESP32_PLATFORM) + else if (the_mesh.getNodePrefs()->powersaving_enabled) { + delay(1); // CPU idle remains available while USB/logging/button holds are active. + } +#endif #if defined(ESP32) && defined(WIFI_SSID) #ifdef WITH_WEBCONFIG diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 6ae2d0c4..6b5cd991 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1954,6 +1954,9 @@ void UITask::loop() { if (board.isExternalPowered()) { _auto_off = millis() + AUTO_OFF_MILLIS; } +#endif +#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN) + if (user_btn.isWakeHoldActive()) _auto_off = millis() + AUTO_OFF_MILLIS; #endif if (!isPairingScreenActive() && isDisplayAutoOffDue(_auto_off, AUTO_OFF_MILLIS)) { _display->turnOff(); diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index 25a39a9d..8fcdebc4 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -506,6 +506,9 @@ void UITask::loop() { // `_auto_off` is only armed on activity, so a timeout changed at runtime has // to restart the countdown here - otherwise 0 -> 60 blanks instantly off a // boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark. +#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN) + if (user_btn.isWakeHoldActive()) _auto_off = millis() + timeout; +#endif if (_powering_off_at == 0 && timeout > 0 && millisReached(millis(), _auto_off)) { _display->turnOff(); #ifdef DISPLAY_REDRAW_ON_CHANGE diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 15b93c70..bf8ed190 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -368,9 +368,9 @@ void loop() { #if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ && MOMENTARY_BUTTON_WAKE_FROM_SLEEP \ && defined(PIN_USER_BTN) && defined(DISPLAY_CLASS) - // Do not strand a stable-level debounce or multi-click deadline in the raw - // nRF52 event wait. The GPIO edge wakes the first poll; this gate lets the - // remaining 25/280 ms state-machine interval finish. + // GPIO wake starts the first poll. Keep debounce and multi-click deadlines + // awake too, so a release cannot strand the remaining 25/280 ms interval. + // The G3 also retains its two-minute button wake interval here. can_power_save = can_power_save && !user_btn.needsPolling(); #endif if (can_power_save) { @@ -389,6 +389,12 @@ void loop() { #endif } +#if defined(ESP32_PLATFORM) + if (!can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) { + delay(1); // Idle without suspending USB or the button wake interval. + } +#endif + if (the_mesh.getNodePrefs()->reboot_interval > 0 && the_mesh.millisHasNowPassed(the_mesh.getNodePrefs()->reboot_interval * 3600000)) { board.reboot(); diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 9fdbce21..77c3b98f 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -384,7 +384,7 @@ void UITask::loop() { && defined(DISPLAY_CLASS) \ && defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ && MOMENTARY_BUTTON_WAKE_FROM_SLEEP - // Event-driven nRF52 targets must use the shared debounced button state + // Sleep-capable targets must use the shared debounced button state // machine. Raw 200 ms polling can go back to sleep after the GPIO edge and // miss both a short press and its release. int ev = user_btn.check(); @@ -506,6 +506,9 @@ void UITask::loop() { // `_auto_off` is only armed on activity, so a timeout changed at runtime has // to restart the countdown here - otherwise 0 -> 60 blanks instantly off a // boot-time deadline, and 60 -> 3600 still blanks at the old 60 s mark. +#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN) + if (user_btn.isWakeHoldActive()) _auto_off = millis() + timeout; +#endif #ifdef DISPLAY_TOUCH_TOGGLE if (_powering_off_at == 0 && timeout > 0 && millisReached(millis(), _auto_off)) { #else diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index c28a2634..b8c14fe2 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -251,4 +251,9 @@ void loop() { } #endif } +#if defined(ESP32_PLATFORM) + if (!can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) { + delay(1); // Keep USB and the button wake interval serviced while idle. + } +#endif } diff --git a/examples/simple_sensor/UITask.cpp b/examples/simple_sensor/UITask.cpp index fc5eb997..803d3087 100644 --- a/examples/simple_sensor/UITask.cpp +++ b/examples/simple_sensor/UITask.cpp @@ -135,6 +135,9 @@ void UITask::loop() { _next_refresh = millis() + 1000; // refresh every second } +#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 && defined(PIN_USER_BTN) + if (user_btn.isWakeHoldActive()) _auto_off = millis() + AUTO_OFF_MILLIS; +#endif if (millis() > _auto_off) { _display->turnOff(); } diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 2bba49dd..518d5852 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -226,4 +226,9 @@ void loop() { } #endif } +#if defined(ESP32_PLATFORM) + if (!can_power_save && the_mesh.getNodePrefs()->powersaving_enabled) { + delay(1); // Keep USB and the button wake interval serviced while idle. + } +#endif } diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 252b782b..0b65a9a8 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -16,6 +16,7 @@ #include "esp_system.h" #include #include "ESP32TrueRandom.h" +#include "UsbLogging.h" #if defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT && \ (!defined(ARDUINO_USB_MODE) || !ARDUINO_USB_MODE) @@ -99,9 +100,17 @@ public: } void sleep(uint32_t secs) override { - // Skip if not allow to sleep - if (inhibit_sleep) { - delay(1); // Give MCU to OTA to run + // Native USB loses its connection in light sleep. An enumerated host + // still needs USB serviced when no terminal asserts CDC DTR (for example, + // after a Pi closes its serial port). Guard every caller here. + bool keep_awake = inhibit_sleep || isUsbHostConnected(); +#if MESH_USB_LOGGING_AVAILABLE + // A live logging stream must also remain available before a host opens + // it and across host disconnects. Compiled-out logging is not a blocker. + keep_awake = keep_awake || mesh::isUsbLoggingEnabled(); +#endif + if (keep_awake) { + delay(1); // Keep USB and OTA tasks running. return; } @@ -110,8 +119,15 @@ public: esp_sleep_enable_timer_wakeup(secs * 1000000ULL); // Wake up periodically to do scheduled jobs } - uint32_t irqGpio = getIRQGpio(); - if (irqGpio == static_cast(-1)) { + const uint32_t irqGpio = getIRQGpio(); + const bool radio_wakeup = irqGpio != static_cast(-1); +#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \ + && MOMENTARY_BUTTON_WAKE_FROM_SLEEP && defined(PIN_USER_BTN) + const int button_gpio = PIN_USER_BTN; +#else + const int button_gpio = -1; +#endif + if (!radio_wakeup && button_gpio < 0) { if (secs > 0) { esp_light_sleep_start(); } @@ -124,8 +140,10 @@ public: // Disable CPU interrupt servicing portENTER_CRITICAL(&sleepMux); - // Skip sleep if there is a LoRa packet - if (gpio_get_level(wakeupPin) == HIGH) { + // Do not sleep over a pending packet or a press that needs polling. + if ((radio_wakeup && gpio_get_level(wakeupPin) == HIGH) + || (button_gpio >= 0 + && gpio_get_level((gpio_num_t)button_gpio) == USER_BTN_PRESSED)) { portEXIT_CRITICAL(&sleepMux); delay(1); return; @@ -133,14 +151,27 @@ public: // Configure GPIO wakeup esp_sleep_enable_gpio_wakeup(); - gpio_wakeup_enable((gpio_num_t)wakeupPin, GPIO_INTR_HIGH_LEVEL); // Wake up when receiving a LoRa packet + if (radio_wakeup) { + gpio_wakeup_enable(wakeupPin, GPIO_INTR_HIGH_LEVEL); + } + if (button_gpio >= 0) { + gpio_wakeup_enable((gpio_num_t)button_gpio, + USER_BTN_PRESSED == LOW ? GPIO_INTR_LOW_LEVEL : GPIO_INTR_HIGH_LEVEL); + } // MCU enters light sleep esp_light_sleep_start(); // Avoid ISR flood during wakeup due to HIGH LEVEL interrupt - gpio_wakeup_disable(wakeupPin); - gpio_set_intr_type(wakeupPin, GPIO_INTR_POSEDGE); + if (radio_wakeup) { + gpio_wakeup_disable(wakeupPin); + gpio_set_intr_type(wakeupPin, GPIO_INTR_POSEDGE); + } + if (button_gpio >= 0) { + gpio_wakeup_disable((gpio_num_t)button_gpio); + // ESP32 MomentaryButton polls; only its sleep wake source is temporary. + gpio_set_intr_type((gpio_num_t)button_gpio, GPIO_INTR_DISABLE); + } // Enable CPU interrupt servicing portEXIT_CRITICAL(&sleepMux); diff --git a/src/helpers/ui/MomentaryButton.cpp b/src/helpers/ui/MomentaryButton.cpp index 128cd1ef..285583d7 100644 --- a/src/helpers/ui/MomentaryButton.cpp +++ b/src/helpers/ui/MomentaryButton.cpp @@ -103,6 +103,17 @@ int MomentaryButton::check(bool repeat_click) { const int raw_btn = _threshold > 0 ? (analogRead(_pin) < _threshold) : digitalRead(_pin); +#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 + // Keep the main loop awake after button activity, including when a press + // starts at millis zero or spans rollover. Holding/repressing extends it. + if (isPressed(raw_btn)) { + _last_pressed_at = now; + _wake_hold_active = true; + } else if (_wake_hold_active && !isWakeHoldActive()) { + _wake_hold_active = false; + } +#endif + // Treat prev as the stable electrical level. A raw transition must remain // unchanged for a short interval before it can alter gesture state. Without // this, one physical release can be counted two or three times and a short diff --git a/src/helpers/ui/MomentaryButton.h b/src/helpers/ui/MomentaryButton.h index 1d573e91..8ed2c77f 100644 --- a/src/helpers/ui/MomentaryButton.h +++ b/src/helpers/ui/MomentaryButton.h @@ -8,6 +8,10 @@ #define BUTTON_EVENT_DOUBLE_CLICK 3 #define BUTTON_EVENT_TRIPLE_CLICK 4 +#ifndef MOMENTARY_BUTTON_WAKE_HOLD_MS +#define MOMENTARY_BUTTON_WAKE_HOLD_MS 0 +#endif + class MomentaryButton { int8_t _pin; int8_t prev, cancel; @@ -23,6 +27,10 @@ class MomentaryButton { int8_t _candidate_level; uint32_t _candidate_since; bool _debouncing; +#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 + uint32_t _last_pressed_at = 0; + bool _wake_hold_active = false; +#endif bool isPressed(int level) const; @@ -34,7 +42,15 @@ public: void cancelClick(); // suppress next BUTTON_EVENT_CLICK (if already in DOWN state) uint8_t getPin() { return _pin; } bool isPressed() const; + bool isWakeHoldActive() const { +#if MOMENTARY_BUTTON_WAKE_HOLD_MS > 0 + return _wake_hold_active + && (uint32_t)(millis() - _last_pressed_at) < MOMENTARY_BUTTON_WAKE_HOLD_MS; +#else + return false; +#endif + } bool needsPolling() const { - return _debouncing || _press_active || _pending_click; + return _debouncing || _press_active || _pending_click || isWakeHoldActive(); } }; diff --git a/test/README.md b/test/README.md index 4b56ce67..e9ae6250 100644 --- a/test/README.md +++ b/test/README.md @@ -29,6 +29,7 @@ python3 test/test_regular_file_reads.py # SPIFFS phantom directories, li python3 test/test_esp32_full_partition.py # Full partition-preservation policy python3 test/test_esp32_dram.py # Classic ESP32 static limits and cached-image build gates python3 test/test_esp32_usb_serial_hygiene.py # Single-TTY diagnostics/NVS contract +python3 test/test_esp32_usb_sleep.py # USB/logging sleep guards, G3 button wake and two-minute hold/rollover python3 test/test_esp32_tinyusb_role_hygiene.py # G2/room USB write coverage and bounded-list contracts python3 test/test_esp32_tinyusb_cooperative_output.py # Real role dump/list pumps with host C++ stubs python3 test/test_esp32_tinyusb_nonblocking.py # Native CDC stalled-host/64-byte-FIFO simulation (C++17 compiler) diff --git a/test/test_esp32_usb_sleep.py b/test/test_esp32_usb_sleep.py new file mode 100644 index 00000000..58b9eca8 --- /dev/null +++ b/test/test_esp32_usb_sleep.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Run ESP32Board's real sleep/USB methods against host peripheral stubs.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include + +static void require(bool ok, const char* message) { + if (!ok) throw std::runtime_error(message); +} +struct SerialPort { + bool terminal_open = false; + bool host_attached = false; + explicit operator bool() const { return terminal_open; } + bool isPlugged() const { return host_attached; } +} Serial; +struct UsbDevice { + bool mounted = false; + explicit operator bool() const { return mounted; } +} USB; +static void attachHost(bool attached) { + Serial.host_attached = USB.mounted = attached; +} +namespace mesh { +static bool logging_enabled = false; +bool isUsbLoggingEnabled() { return logging_enabled; } +} + +using gpio_num_t = int; +constexpr int HIGH = 1; +constexpr int LOW = 0; +constexpr int USER_BTN_PRESSED = LOW; +constexpr int GPIO_INTR_HIGH_LEVEL = 5; +constexpr int GPIO_INTR_LOW_LEVEL = 4; +constexpr int GPIO_INTR_POSEDGE = 1; +constexpr int GPIO_INTR_DISABLE = 0; +static unsigned sleep_calls = 0; +static uint64_t timer_us = 0; +static bool radio_irq_high = false; +static int critical_depth = 0; +static int wake_pin = -1; +static int restored_pin = -1; +static std::array wake_levels{}; +static std::array restored_interrupts{}; +static bool button_pressed = false; +static bool press_during_sleep = false; +static unsigned yield_ms = 0; +static void delay(unsigned ms) { yield_ms += ms; } +static void esp_sleep_enable_timer_wakeup(uint64_t us) { timer_us = us; } +static void esp_light_sleep_start() { + ++sleep_calls; + if (press_during_sleep) { + require(wake_levels[38] == GPIO_INTR_LOW_LEVEL, + "G3 button cannot wake the CPU from sleep"); + require(wake_levels[48] == GPIO_INTR_HIGH_LEVEL, + "button wake replaced the LoRa wake source"); + button_pressed = true; + press_during_sleep = false; + } +} +static void esp_sleep_enable_gpio_wakeup() {} +static int gpio_get_level(gpio_num_t pin) { + return pin == 38 ? (button_pressed ? LOW : HIGH) : (radio_irq_high ? HIGH : LOW); +} +static void gpio_wakeup_enable(gpio_num_t pin, int level) { + wake_pin = pin; + wake_levels.at(pin) = level; +} +static void gpio_wakeup_disable(gpio_num_t pin) { + restored_pin = pin; + wake_levels.at(pin) = 0; +} +static void gpio_set_intr_type(gpio_num_t pin, int type) { + restored_interrupts.at(pin) = type; +} +#define portENTER_CRITICAL(mux) (++critical_depth) +#define portEXIT_CRITICAL(mux) (--critical_depth) + +struct MainBoard { + virtual void sleep(uint32_t) = 0; + virtual bool isUsbDataConnected() = 0; + virtual bool isUsbHostConnected() = 0; +}; +struct ESP32Board : MainBoard { + bool inhibit_sleep = false; + uint32_t irq = 48; // Station G3 LoRa DIO1 + uint32_t getIRQGpio() { return irq; } +@METHODS@ +}; + +int main() { + try { + ESP32Board board; +#if ARDUINO_USB_CDC_ON_BOOT + // The Pi has enumerated the G3, but no terminal asserts CDC DTR. + attachHost(true); + require(board.isUsbHostConnected(), "enumerated host was missed"); + require(!board.isUsbDataConnected(), "closed terminal reported as open"); + board.sleep(30); + require(sleep_calls == 0, "closed terminal allowed sleep with USB attached"); + require(timer_us == 0 && wake_pin == -1 && critical_depth == 0, + "USB-inhibited sleep touched wake peripherals"); + require(yield_ms > 0, "USB-inhibited sleep did not yield to other tasks"); + + Serial.terminal_open = true; + board.sleep(30); + require(sleep_calls == 0, "open terminal allowed USB sleep"); + Serial.terminal_open = false; + + // The timer-only path must also preserve USB, with no LoRa wake pin. + board.irq = static_cast(-1); + board.sleep(30); + 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. + attachHost(true); + Serial.terminal_open = true; + require(!board.isUsbHostConnected(), "UART build invented a USB host"); +#endif + + // USB power without an enumerated host must still permit battery saving. + attachHost(false); + Serial.terminal_open = false; + board.sleep(30); + require(sleep_calls == 1 && timer_us == 30000000ULL, + "disconnected board did not retain its scheduled sleep"); + require(restored_interrupts[48] == GPIO_INTR_POSEDGE + && wake_levels[48] == 0 && critical_depth == 0, + "LoRa wake/interrupt restoration changed"); + +#if ARDUINO_USB_CDC_ON_BOOT + // Reattaching the Pi must inhibit the next sleep without opening a TTY. + attachHost(true); + board.sleep(30); + require(sleep_calls == 1, "reattached closed terminal failed to inhibit sleep"); + attachHost(false); +#endif + + board.inhibit_sleep = true; + board.sleep(30); + require(sleep_calls == 1, "OTA sleep inhibition regressed"); + board.inhibit_sleep = false; + radio_irq_high = true; + board.sleep(30); + require(sleep_calls == 1 && critical_depth == 0, + "pending LoRa packet was stranded in sleep"); + radio_irq_high = false; +#if MOMENTARY_BUTTON_WAKE_FROM_SLEEP + // A fresh press during sleep wakes GPIO 38 alongside the radio GPIO. + press_during_sleep = true; + board.sleep(30); + require(sleep_calls == 2 && button_pressed, + "button did not wake a sleeping G3"); + require(wake_levels[38] == 0 + && restored_interrupts[38] == GPIO_INTR_DISABLE, + "button wake interrupt was not cleaned up"); + board.sleep(30); + require(sleep_calls == 2 && critical_depth == 0, + "held button was stranded before the UI could poll it"); + button_pressed = false; + board.sleep(30); + require(sleep_calls == 3, "released button permanently disabled sleep"); +#else + board.irq = static_cast(-1); + board.sleep(0); + require(sleep_calls == 1, "slept without a wake source"); + board.sleep(2); + require(sleep_calls == 2 && timer_us == 2000000ULL, + "disconnected timer-only sleep regressed"); +#endif + + // Logging is an explicit sleep blocker even with the cable disconnected. + const unsigned before_logging = sleep_calls; + mesh::logging_enabled = true; + board.sleep(30); +#if MESH_USB_LOGGING_AVAILABLE + require(sleep_calls == before_logging, "enabled logging allowed USB sleep"); +#else + require(sleep_calls == before_logging + 1, + "compiled-out logging disabled power saving"); +#endif + mesh::logging_enabled = false; + const unsigned after_logging = sleep_calls; + board.sleep(30); + require(sleep_calls == after_logging + 1, + "turning logging off did not restore sleep eligibility"); + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} +''' + + +def board_method(signature: str) -> str: + source = (ROOT / "src/helpers/ESP32Board.h").read_text() + start = source.index(signature) + opening = source.index("{", start) + depth = 1 + end = opening + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[start:end] + + +class Esp32UsbSleepTest(unittest.TestCase): + 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") + methods = "\n".join(board_method(signature) for signature in ( + "void sleep(uint32_t secs) override", + "bool isUsbDataConnected() override", + "bool isUsbHostConnected() override", + )) + with tempfile.TemporaryDirectory(prefix="meshcore-usb-sleep-") as temp: + cpp = Path(temp) / "test.cpp" + cpp.write_text(HARNESS.replace("@METHODS@", methods)) + for mode, cdc, button, logging in ( + (0, 1, 1, 1), (0, 1, 0, 1), (1, 1, 0, 1), (0, 0, 0, 1), + (0, 1, 1, 0), + ): + with self.subTest(usb_mode=mode, cdc=cdc, button=button, logging=logging): + binary = Path(temp) / f"test-{mode}-{cdc}-{button}-{logging}" + compiled = subprocess.run([ + compiler, "-std=c++17", "-Wall", "-Wextra", + f"-DARDUINO_USB_MODE={mode}", + f"-DARDUINO_USB_CDC_ON_BOOT={cdc}", + f"-DMOMENTARY_BUTTON_WAKE_FROM_SLEEP={button}", + f"-DMESH_USB_LOGGING_AVAILABLE={logging}", "-DPIN_USER_BTN=38", + "-DCONFIG_TINYUSB_ENABLED=1", str(cpp), "-o", str(binary), + ], capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_g3_enables_gesture_polling_after_gpio_wake(self): + profile = (ROOT / "variants/station_g3_esp32/platformio.ini").read_text() + base = profile.split("[Station_G3_ESP32]", 1)[1].split("\n[", 1)[0] + self.assertIn("-D MOMENTARY_BUTTON_WAKE_FROM_SLEEP=1", base) + self.assertIn("-D MOMENTARY_BUTTON_WAKE_HOLD_MS=120000UL", base) + for role in ("simple_repeater", "simple_room_server", "companion_radio"): + main = (ROOT / f"examples/{role}/main.cpp").read_text() + guard = main.split("#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP)", 1)[1] + guard = guard.split("#endif", 1)[0] + self.assertIn("!user_btn.needsPolling()", guard) + + def test_button_holds_awake_for_two_minutes_and_renews_across_rollover(self): + harness = r''' +#include +#include +#include +#include +static void require(bool ok, const char* why) { + if (!ok) throw std::runtime_error(why); +} +static uint32_t click(MomentaryButton& button, uint32_t at) { + g_mock_millis = at; + g_mock_pin_levels[38] = LOW; + button.check(); + require(button.isWakeHoldActive(), "raw press did not start wake interval"); + g_mock_millis += 25; + button.check(); + const uint32_t last_pressed = g_mock_millis; + g_mock_millis += 50; + g_mock_pin_levels[38] = HIGH; + button.check(); + g_mock_millis += 25; + button.check(); + g_mock_millis += 280; + require(button.check() == BUTTON_EVENT_CLICK, "wake hold swallowed the click"); + require(button.needsPolling(), "main loop could sleep after button release"); + return last_pressed; +} +int main() { + try { + for (uint32_t start : {0U, 120001U, UINT32_MAX - 60000U}) { + resetArduinoMock(); + g_mock_pin_levels[38] = HIGH; + MomentaryButton button(38, 1000, true); + button.begin(); + button.check(); + require(!button.isWakeHoldActive(), "boot without a press started a hold"); + uint32_t last = click(button, start); + g_mock_millis = last + 119999U; + button.check(); + require(button.isWakeHoldActive() && button.needsPolling(), + "button wake ended before two minutes"); + last = click(button, g_mock_millis); + g_mock_millis = last + 119999U; + button.check(); + require(button.needsPolling(), "another press did not renew two minutes"); + ++g_mock_millis; + button.check(); + require(!button.isWakeHoldActive() && !button.needsPolling(), + "expired wake interval never allowed sleep again"); + } + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} +''' + with tempfile.TemporaryDirectory(prefix="meshcore-g3-button-") as temp: + cpp = Path(temp) / "button.cpp" + binary = Path(temp) / "button" + cpp.write_text(harness) + compiled = subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", + "-DMOMENTARY_BUTTON_WAKE_HOLD_MS=120000UL", + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(cpp), str(ROOT / "src/helpers/ui/MomentaryButton.cpp"), + "-o", str(binary), + ], capture_output=True, text=True) + self.assertEqual(compiled.returncode, 0, compiled.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_t096_button_wake_profile.py b/test/test_t096_button_wake_profile.py index 1f559a35..39866d09 100644 --- a/test/test_t096_button_wake_profile.py +++ b/test/test_t096_button_wake_profile.py @@ -59,7 +59,8 @@ class T096ButtonWakeProfileTest(unittest.TestCase): sensor_main = SENSOR_MAIN.read_text(encoding="utf-8") self.assertIn( - "return _debouncing || _press_active || _pending_click;", header + "return _debouncing || _press_active || _pending_click || isWakeHoldActive();", + header, ) self.assertIn("defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP)", main) self.assertIn("!user_btn.needsPolling()", main) diff --git a/variants/station_g3_esp32/platformio.ini b/variants/station_g3_esp32/platformio.ini index 425917df..04505737 100644 --- a/variants/station_g3_esp32/platformio.ini +++ b/variants/station_g3_esp32/platformio.ini @@ -28,6 +28,8 @@ build_flags = -D PIN_BOARD_SDA=5 -D PIN_BOARD_SCL=6 -D PIN_USER_BTN=38 + -D MOMENTARY_BUTTON_WAKE_FROM_SLEEP=1 + -D MOMENTARY_BUTTON_WAKE_HOLD_MS=120000UL -D PIN_GPS_RX=15 -D PIN_GPS_TX=7 -D SX126X_DIO2_AS_RF_SWITCH=true