Wake Heltec V4 buttons from power-saving sleep

This commit is contained in:
mikecarper committed 2026-10-05 23:51:26 -07:00
1 parent c88885a315
commit ebf2f0823e
7 files changed
+718 -6

No files matched your search

+15 -1
View File
@@ -11,6 +11,9 @@ layout before installing an image.
- ESP32 native USB recipes now use hardware CDC where the chip supports it.
Classic ESP32 and explicit UART transports keep their UART path. nRF52,
RP2040 and STM32 USB backends are unchanged by this migration.
- Heltec V4 and V4 R8 user buttons wake CPU light sleep when power saving is
enabled. Button debounce and click deadlines remain awake so the display can
process the press. Explicit display-off mode still suppresses screen wake.
- ESP32 USB output is bounded so a stopped reader cannot indefinitely hold
the radio loop. The G2 preserves complete serial packet-log lines under
congestion. This supports unmodified Cisien meshcoretomqtt; congested
@@ -126,7 +129,18 @@ endpoint, reported the WebConfig handoff, and served its page and exact G2
identity over HTTP. OTA stopped cleanly and the original WebConfig, settings,
Admin ACLs, Pi network and host services were restored. The earlier radio CAD
warning remained unchanged; the earlier LAN upload failure's cause is still
unproven. The current GitHub unit workflow remains pending for this held release.
unproven. The GitHub unit workflow passed for source `c88885a3`; this release
remains held and unpublished.
The normal Full Heltec V4 Repeater from `c88885a3` passed two fresh-flash
connections through the unchanged native Chromium setup GUI. With power saving
on, its quiet console checks passed for 600 seconds open and closed with logging
off, and 600 seconds open with USB logging. See the [V4 native console
qualification](../test-results/hardware_validation_heltec_v4_native_console_2026-10-06.md)
for deadlines, host preparation and retained failed attempts. USB remained
attached, so these checks do not measure CPU sleep or button wake. The subsequent
V4 button-wake correction and its software validation are separate; physical
button wake was not tested here.
Build qualification does not establish physical compatibility on every board.
Hardware CDC has finite buffers, and closing a Linux TTY does not necessarily
@@ -92,8 +92,11 @@ its recovery ACLs, and its running MQTT services were not changed by this run.
## Remaining limits
The user's permanent/intermittent web-console hang has not been reproduced or
proven fixed. Current tests do not qualify every host OS, physical USB adapter,
cold power cycle, ESP32 board, or firmware profile. The final V4 build has not
been flashed through Chromium's remote physical Web Serial interface. Keep
those limits separate from the successful browser OTA and console idle checks.
The subsequent [current Full V4 native browser qualification](hardware_validation_heltec_v4_native_console_2026-10-06.md)
adds actual Pi Chromium Web Serial, the unchanged setup GUI's five-second
deadline, and measured fresh-flash and power-saving console idle results. The
older source and boundaries above remain historical evidence. Neither report
qualifies every host OS, physical USB adapter, cold power cycle, ESP32 board,
firmware profile, or radio traffic load. USB-attached console checks do not
establish CPU sleep, measured current or button wake; the subsequent V4
button-wake correction needs separate qualification.
@@ -0,0 +1,153 @@
# Heltec V4 Full native browser console - 2026-10-06
The normal Full Heltec V4 Repeater from source
`c88885a315208908e050c57fc0ec0a71ff10bd6f` passed fresh-flash connection,
600 seconds with the console open, and 600 seconds with it closed through the
unchanged `config.meshcore.io` setup GUI. Power saving was on and logging output
was off during those idle intervals. A separate fresh-flash and 600-second
open-console trial also passed with USB logging on. Both trials restored the
original settings and closed their streams. Final operator readback and owned
browser/host-service cleanup also passed. Release 1.17.1.9 remains held and
unpublished.
These results extend the [earlier startup investigation](hardware_validation_esp32_web_console_startup_2026-10-05.md)
to the current normal Full image and native hardware CDC. They are physical
console stability checks, not a measurement of sleep current, radio traffic
capacity, or every ESP32/host combination.
## Image and build gates
The requested target was `heltec_v4_repeater`, Full profile. Its resolved
`heltec_v4_repeater_observer_mqtt` feature base retains the normal repeater
update identity. Runtime readback had to match the complete version
`v1.17.1.9-halo-keymind-cascade-dev-c88885a3 (Build: 06-Oct-2026)`.
The source includes the OTA upload correction in `d2983762` and WiFi ownership
correction in `95913c53`. The GitHub unit workflow for the current source passed;
this does not replace the physical checks below or the final release matrix.
| Artifact | Bytes | SHA-256 |
| --- | ---: | --- |
| Full application | 2,097,528 | `1f10f44149b3f56edbe0ecb2a2bab9b51da651a6d047e2c2c1555691468d2b4c` |
| Merged image | 2,163,064 | `3264d7a1a5713755b5dd10ad1c81bb0588e4a072eeaa126b86307a42be77bb5b` |
| Matching ELF | - | `0545eef46da30a86f86531199a91e887167d463c5981f193c0455cd75d633538` |
The package hash, linked capability, and RAM gates passed, with 20,880 bytes
of margin above the required heap budget. The linked application includes
HWCDC, its retained-PHY handoff and startup owner hook; no TinyUSB USBCDC class
is linked. Full ESP-NOW, WiFi/LoRa OTA, and USB logging capability checks passed.
These are build checks; this run did not exercise those radio/OTA features.
Both application slots are `0x640000` bytes, at `0x10000` and `0x650000`.
The padded partition table SHA-256 is
`9af3af2b74e944337ba85f2b0027ee80df160579a1ab746ba0f95853f618cd60`.
The merged image contains that table and the exact audited application at
`0x10000`. The application fits either slot with 4,456,072 bytes of margin.
The merged artifact was audited, but the physical tests wrote the application
only; they do not qualify a factory erase or merged-image installation.
## Native browser boundary
Chromium `154.0.8037.92` ran on the Pi with native OS Web Serial ports and
streams. The actual Vue GUI and stock SerialCLI were loaded before flashing.
The stock DOM Connect flow after the stock flasher returned sent the normal
plain `time <epoch>` request. Its unchanged 5,000 ms timeout covers both native
write completion and the framed response. No additional reset, cable action,
post-flash settle, command retry, or keepalive was inserted to turn that
connection into a pass.
| Website source | SHA-256 |
| --- | --- |
| GUI `index.html` | `efd64d1b56d57988fc4108f3f84efc943ed4c42ade4e4dbf8139c46fb3c0476d` |
| GUI `src/gui.js` | `919034806e903fbc41e29a367a7d854837f6520f47aa34e5d0618f5dd3d29800` |
| GUI `lib/serial-cli.js` | `fbfbdcfa8bf1a372a0396d342e018ba8ee83d33b7b98c3a546fd37cd7e8ce870` |
| Flasher `js/flash.js` | `e9762a29f6a91f8e33736e02ff41a81cd49e7b96fe253a5d71cba7daefd46d5b` |
| Flasher `lib/esp32.js` | `2a896d5e520ea9b6ea9900223c2460df797c3b2287f441daa4e50168a50b17e6` |
Automation selected only the single preauthorized native device. Before each
write, actual ROM reads checked the partition table and valid OTA sequence/CRC,
then directed the application write to the verified active slot (`app1` at
`0x650000`). The stock loader transfer, reset and disconnect remained in place;
full erase was disabled. Phase instrumentation recorded existing calls without
adding hardware operations. This qualifies that stock flash backend and native
GUI connection, not the complete firmware-picker selection/erase flow.
Original public identity, the ACL response, and 15 checked saved preferences
were compared with the original private reference after flashing and cleanup.
Only the numeric channel-busy diagnostic counter was normalized in preference
comparison. No raw identities, credentials or console replies are published.
## Physical results
| Check | Result |
| --- | --- |
| Fresh flash, then first GUI connection with logging off | PASS. Plain time clock-set acknowledgement in 1,614.4 ms; complete GUI bootstrap and exact version/reference checks passed. |
| Power saving on, logging off, console open | PASS. 600.093 seconds with zero native command writes or keepalives. Uptime advanced 18 -> 619 seconds; the first post-idle core reply took 35.9 ms. A full DOM Disconnect/Connect reread acknowledged time in 23.6 ms. |
| Power saving on, logging off, console closed | PASS. 600.100 seconds with streams closed and zero writes. Uptime advanced 627 -> 1232 seconds. The first DOM reconnect time response took 33.2 ms; it was the recognized framed clock-cannot-go-backwards rejection, not a clock-set acknowledgement. Full GUI bootstrap and version checks still passed. |
| Separate fresh flash, power saving on, USB logging | PASS. Fresh plain time acknowledgement in 696.1 ms, then 600.101 seconds open with zero writes or keepalives. Uptime advanced 18 -> 618 seconds; first post-idle core reply took 40.4 ms. Full DOM reconnect time acknowledgement took 24.8 ms. |
| Both trial cleanups | PASS. Original power saving off, logging output USB, all 15 checked preferences, public identity and ACL response were restored. The GUI port, reader and writer closed. |
Uptime gates account for the surrounding query/GUI bootstrap time. None of the
three quiet intervals contains a native write event. Both trials recorded zero
native command errors and no GUI modem-signal calls.
Radio observations stayed in active RX with zero physical RX errors. The core
error bitmask changed from `0` to `8`, with zero received packets and one sent
packet. This is the configured receive-silence watchdog warning, consistent
with the quiet-channel recovery policy; it is not eight errors or a CPU reset.
Neither trial establishes error-free RF operation or a loaded radio soak.
## Retained setup failures and host qualification
Two preceding browser attempts remain failed and unqualified. Attempt 2 timed
out before metadata completion or any GUI command was recorded; its exact
flasher substage was not instrumented. Attempt 4 timed out in the stock loader
connection/reset loop after 674 USB JTAG reset entries. It did not reach loader
main return, application `writeFlash`, or a GUI command. Neither failure
exercised the GUI's five-second command timer, so they are not counted as
firmware console timeout failures or silently converted into passing trials.
Before the passing logging-off attempt, the host TTY was normalized from
`VMIN=0, VTIME=0` to `VMIN=1, VTIME=0`, with zero explicit modem-signal calls
in that preparation. No firmware or website code changed. That host setup is
part of the qualified configuration; the evidence does not isolate the original
ROM synchronization failure's cause. The second logging trial started without
another external CLI preflight or TTY change.
The pinned stock flasher also calls `connect(resetMode, romBaudrate)` from
`detectChip`, although the second `connect` argument is a retry count with a
default of seven. The supplied value is 115,200. This explains the excessive
retry budget after failed synchronization, not why synchronization first failed.
It is an external flasher issue; no website patch was required for the passing
GUI tests and no website deployment is claimed.
## Evidence and limits
Final read-only operator verification again matched the exact version, original
15 checked preferences, public identity and ACL response; uptime was 686 seconds
and flags remained `8`. The owned Chromium profile and tunnel were closed, the
serial port had no owner, and original host service states were restored:
ModemManager and mctomqtt active, mesh-logger inactive. No Pi network
configuration changed. Restored service state does not establish MQTT delivery.
The compact [safe audit](hardware_validation_heltec_v4_native_console_2026-10-06.safe.json)
contains artifact/helper/report hashes and selected outcome fields. Completed
sanitized reports and phase observations are retained privately; raw reference
settings remain private. Failed attempts are retained separately from the
qualified results.
The USB cable remained attached throughout these trials. The native-host sleep
guard keeps CPU command service available while the USB host is connected,
including when its serial port is closed. Power saving on is therefore not
proof of CPU light-sleep entry, measured current, or physical button wake.
At this source, V4 lacks the momentary-button wake profile flag. The later
profile correction is outside this image and needs separate software/build
and console validation. No physical button access is available in this setup,
so physical button wake remains unqualified.
These checks qualify this historical `c88885a3` Full application, native Pi Chromium setup,
protected active-slot application flash, and measured intervals. They do not
qualify every host OS, board/role, adapter, cold power cycle, factory erase,
heavy logging volume, measured power consumption, or RF traffic load. GPIO/reset
behavior outside the unchanged stock flasher's reset sequence was not separately
qualified. The release remains unpublished; the final same-source all-board
build and release packaging are separate gates.
@@ -0,0 +1,240 @@
{
"schema_version": 1,
"scope": "Heltec V4 normal Full native Chromium stock config GUI; active-slot app-only flash and quiet USB stability",
"source_commit": "c88885a315208908e050c57fc0ec0a71ff10bd6f",
"firmware_version": "v1.17.1.9-halo-keymind-cascade-dev-c88885a3 (Build: 06-Oct-2026)",
"status": "qualified_historical_c888_USB_stability_operator_restored",
"build": {
"target": "heltec_v4_repeater",
"profile": "full",
"platformio_feature_base": "heltec_v4_repeater_observer_mqtt",
"normal_update_identity_retained": true,
"app_filename": "heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.9-halo-keymind-cascade-dev-c88885a3.bin",
"app_sha256": "1f10f44149b3f56edbe0ecb2a2bab9b51da651a6d047e2c2c1555691468d2b4c",
"app_bytes": 2097528,
"merged_filename": "heltec_v4_repeater-full-usb-wifi-ota-v1.17.1.9-halo-keymind-cascade-dev-c88885a3-merged.bin",
"merged_sha256": "3264d7a1a5713755b5dd10ad1c81bb0588e4a072eeaa126b86307a42be77bb5b",
"merged_bytes": 2163064,
"elf_sha256": "0545eef46da30a86f86531199a91e887167d463c5981f193c0455cd75d633538",
"partition_table_4096_sha256": "9af3af2b74e944337ba85f2b0027ee80df160579a1ab746ba0f95853f618cd60",
"slot_bytes": 6553600,
"usb_mode": 1,
"cdc_on_boot": 1,
"hwcdc_phy_handoff_compiled": true,
"ram_gate_passed": true,
"heap_margin_bytes": 20880,
"capability_gate_passed": true,
"verified_capabilities": [
"bridge.espnow",
"logging.usb.output",
"ota.update.lora",
"ota.update.wifi",
"profile.full"
],
"independent_build_audit_sha256": "34d1bed7549f9457063370c1a7703a2c6cd7d75b9f2a8cb3308d000bf33c56f3"
},
"helpers": {
"original_driver_sha256": "460b217824c897daefc377247562330290fb61e67facdf9ed3d9283160c5ca78",
"phase_driver_sha256": "20797d40df1b0e2f30abd5779b3c1006173974cf71404b0d6e1932c721492d84",
"phase_derivative_adds_no_hardware_operations": true
},
"host_preparation": {
"tty_before": {
"VMIN": 0,
"VTIME": 0
},
"tty_after": {
"VMIN": 1,
"VTIME": 0
},
"explicit_modem_signal_calls": 0,
"scope": "Host TTY VMIN/VTIME only; no firmware or browser JS changes"
},
"failed_attempts": [
{
"attempt": 2,
"report_sha256": "60640f0bdd6b2f91d278bc3ffdc3a372ec799f8f057674437432ab091b7d302c",
"qualified": false,
"error_name": "TimeoutError",
"duration_seconds": 303.30445313453674,
"website_command_sent": false,
"website_five_second_deadline_exercised": false,
"application_write_not_reached_proven": false,
"stock_usb_jtag_reset_entries": null,
"initial_sync_failure_cause": "not established"
},
{
"attempt": 4,
"report_sha256": "9ab397494c011d1828d46aefbc2b927b36ad81160d92ec11a14dedb3f280f1ae",
"qualified": false,
"error_name": "TimeoutError",
"duration_seconds": 277.81140637397766,
"website_command_sent": false,
"website_five_second_deadline_exercised": false,
"application_write_not_reached_proven": true,
"stock_usb_jtag_reset_entries": 674,
"initial_sync_failure_cause": "not established"
}
],
"external_flasher_library": {
"sha256": "2a896d5e520ea9b6ea9900223c2460df797c3b2287f441daa4e50168a50b17e6",
"default_connection_retry_count": 7,
"stock_detectChip_supplies_rom_baudrate_as_retry_count": 115200,
"scope": "Excessive failed-reset budget in stock flasher; not a MeshCore firmware fix or root-cause proof for initial sync failure."
},
"pending_physical_trials": [],
"radio_scope": "USB stability qualification records CAD and receive-silence watchdog flags as warnings; requires physical RX errors zero and rejects queue-full/start-RX/unknown flags. No zero-error radio claim.",
"limitations": [
"Current-source normal Full HWCDC application; not released-stock migration proof.",
"Host TTY VMIN/VTIME normalization is part of qualified host setup.",
"Failed flasher preparations remain failed and did not exercise the GUI five-second command timer.",
"Merged artifact is audited; actual browser tests flashed only the protected active application slot.",
"No measured sleep current, factory erase/cold-cycle, heavy logging or RF traffic-soak claim.",
"USB-attached CPU sleep guard means no physical light-sleep/button-wake or current-draw qualification; V4 momentary-button wake profile correction needs new-source qualification.",
"Original host service states restored; MQTT packet delivery was not measured."
],
"qualified_trials": [
{
"attempt": 5,
"logging_output": "off",
"power_saving": "on",
"report_sha256": "e4d49f17971d980565ad351b9b49a2e19c5ca8a83ac91805b87bf85ada49223e",
"independent_review_sha256": "0e60290e3d63b393bf6c8647bb6c966f7a015bdd09067ab0cf7840290201c9ea",
"first_plain_time_ms": 1614.4000000003725,
"first_plain_time_clock_set_acknowledged": true,
"native_command_deadline_ms": 5000,
"quiet_intervals": [
{
"kind": "open",
"requested_seconds": 600,
"actual_seconds": 600.0930547369935,
"native_writes_during_idle": 0,
"uptime_before": 18,
"serial_open": true,
"uptime_after": 619,
"uptime_delta": 601,
"uptime_continuity_verified": true,
"ok": true
},
{
"kind": "closed",
"requested_seconds": 600,
"actual_seconds": 600.100213859987,
"native_writes_during_idle": 0,
"uptime_before": 627,
"serial_open": false,
"uptime_after": 1232,
"uptime_delta": 605,
"uptime_continuity_verified": true,
"ok": true
}
],
"first_post_open_idle_core_ms": 35.90000000037253,
"post_open_idle_DOM_clock_reply_ms": 23.59999999962747,
"post_closed_idle_DOM_clock_reply_ms": 33.19999999925494,
"post_closed_idle_clock_set_acknowledged": false,
"post_closed_idle_clock_response": "recognized framed clock-cannot-go-backwards rejection",
"full_GUI_rereads_passed": true,
"native_command_errors": 0,
"GUI_modem_signal_calls": 0,
"original_restoration_verified": true,
"checked_preferences_count": 15,
"identity_ACL_response_and_checked_preference_hashes_preserved": true,
"original_power_saving": "off",
"original_logging_output": "usb",
"streams_closed": true,
"radio": {
"initial_core_error_flags": 0,
"final_core_error_flags": 8,
"all_observations_active_RX": true,
"physical_rx_errors_max": 0,
"final_recv": 0,
"final_sent": 1,
"scope": "Receive-silence watchdog8 warning after quiet; not a CPU reset or zero-error/traffic radio qualification."
}
},
{
"attempt": 3,
"logging_output": "usb",
"power_saving": "on",
"report_sha256": "13202bf1d378128c2ac0d99404aac2a877b3ef3e9e14cfc0fc7a727d71aeb1ee",
"independent_review_sha256": "0ab2d0c2d24e025fb381fedeebe5ee267b805c57f7ad6ec6a173a98201cacc34",
"first_plain_time_ms": 696.0999999996275,
"first_plain_time_clock_set_acknowledged": true,
"native_command_deadline_ms": 5000,
"quiet_intervals": [
{
"kind": "open",
"requested_seconds": 600,
"actual_seconds": 600.1005607169936,
"native_writes_during_idle": 0,
"uptime_before": 18,
"serial_open": true,
"uptime_after": 618,
"uptime_delta": 600,
"uptime_continuity_verified": true,
"ok": true
}
],
"first_post_open_idle_core_ms": 40.40000000037253,
"post_open_idle_DOM_clock_reply_ms": 24.800000000745058,
"post_open_idle_DOM_clock_set_acknowledged": true,
"full_GUI_rereads_passed": true,
"native_command_errors": 0,
"GUI_modem_signal_calls": 0,
"original_restoration_verified": true,
"checked_preferences_count": 15,
"identity_ACL_response_and_checked_preference_hashes_preserved": true,
"original_power_saving": "off",
"original_logging_output": "usb",
"streams_closed": true,
"radio": {
"initial_core_error_flags": 0,
"final_core_error_flags": 8,
"all_observations_active_RX": true,
"physical_rx_errors_max": 0,
"final_recv": 0,
"final_sent": 1,
"scope": "Known CAD/receive-silence flags are warnings; no zero-error RF or traffic-soak claim."
}
}
],
"browser": {
"version": "154.0.8037.92",
"native_OS_Web_Serial": true,
"stock_GUI_command_timeout_ms": 5000,
"GUI_sources": {
"index.html": "efd64d1b56d57988fc4108f3f84efc943ed4c42ade4e4dbf8139c46fb3c0476d",
"src/gui.js": "919034806e903fbc41e29a367a7d854837f6520f47aa34e5d0618f5dd3d29800",
"lib/serial-cli.js": "fbfbdcfa8bf1a372a0396d342e018ba8ee83d33b7b98c3a546fd37cd7e8ce870"
}
},
"operator_cleanup": {
"ok": true,
"exact_runtime_version_verified": true,
"checked_preferences_identity_ACL_response_preserved": true,
"uptime_seconds": 686,
"core_error_flags": 8,
"serial_closed_and_no_owner": true,
"owned_browser_profile_closed": true,
"owned_tunnel_closed": "parent independently verified",
"original_services_restored": {
"ModemManager": "active",
"mctomqtt": "active",
"mesh-logger": "inactive"
},
"network_configuration_changed": false,
"operator_report_sha256": "d6d6e03ad5058e1257a6653af70203a3852dace47701220d9fd5c5020c56ae33",
"services_report_sha256": "f02ab0773cc35c11c2c9f400d8dffe0e7dd1807a09ebd942ee652ccc82c0f420",
"browser_cleanup_report_sha256": "d22a76bffa71dac46ab9514f066369047642e8c0bdeda36c380e133b724ca299"
},
"sleep_scope": {
"USB_cable_remained_attached": true,
"native_host_sleep_guard_present": true,
"CPU_light_sleep_entry_measured": false,
"current_draw_measured": false,
"physical_button_wake_tested": false,
"c888_V4_momentary_button_wake_profile_flag_present": false,
"new_button_fix_qualification": "pending"
}
}
+300
View File
@@ -2,7 +2,9 @@
"""Run ESP32Board's real sleep/USB methods against host peripheral stubs."""
from pathlib import Path
import configparser
import os
import re
import shutil
import subprocess
import tempfile
@@ -248,7 +250,305 @@ def board_method(signature: str) -> str:
return source[start:end]
def v4_profile_config():
config = configparser.ConfigParser(
interpolation=None, inline_comment_prefixes=("#", ";"))
config.read([ROOT / "platformio.ini",
ROOT / "variants/heltec_v4/platformio.ini",
ROOT / "variants/heltec_v4_r8/platformio.ini"])
return config
def profile_option(config, section, option, seen=()):
key = (section, option)
if key in seen:
raise ValueError("cyclic profile interpolation")
if config.has_option(section, option):
value = config.get(section, option)
else:
parents = config.get(section, "extends", fallback="").replace(",", " ").split()
value = next(profile_option(config, parent, option, seen + (key,))
for parent in parents if config.has_section(parent))
return re.sub(r"\x24[{]([^}]+)[}]",
lambda match: profile_option(
config, *match[1].rsplit(".", 1), seen + (key,)),
value)
def button_defines(config, section):
flags = profile_option(config, section, "build_flags")
values = {}
for match in re.finditer(
r"(?:^|\s)-(D|U)\s*([A-Za-z_]\w*)(?:=([^\s]+))?", flags):
operation, name, value = match.groups()
if operation == "U":
values.pop(name, None)
else:
values[name] = value if value is not None else "1"
return values
def conditional_block(source, start):
"""Keep the production preprocessor branches, including nested guards."""
depth = 0
end = start
for line in source[start:].splitlines(keepends=True):
if re.match(r"\s*#if(?:def|ndef)?\b", line):
depth += 1
elif re.match(r"\s*#endif\b", line):
depth -= 1
end += len(line)
if depth == 0:
return source[start:end]
raise ValueError("unterminated conditional block")
V4_BUTTON_HARNESS = r'''
#include <Arduino.h>
#include <helpers/UsbHostSleepPolicy.h>
#include <helpers/ui/MomentaryButton.h>
#include <helpers/ui/DisplayPowerPolicy.h>
#include <array>
#include <stdexcept>
#include <iostream>
static void require(bool ok, const char* why) {
if (!ok) throw std::runtime_error(why);
}
struct SerialPort {
explicit operator bool() const { return false; }
bool isPlugged() const { return false; }
} Serial;
namespace mesh {
bool isUsbLoggingEnabled() { return false; }
struct Console { void printf(const char*) {} };
static Console console;
Console& usbConsolePort() { return console; }
}
using gpio_num_t = int;
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 std::array<int, 64> wake_levels{}, restored_interrupts{};
static unsigned sleep_calls = 0;
static int critical_depth = 0;
static bool press_during_sleep = false, radio_irq_high = false;
static void esp_sleep_enable_timer_wakeup(uint64_t) {}
static void esp_sleep_enable_gpio_wakeup() {}
static int gpio_get_level(gpio_num_t pin) {
return pin == PIN_USER_BTN ? digitalRead(pin) : (radio_irq_high ? HIGH : LOW);
}
static void gpio_wakeup_enable(gpio_num_t pin, int level) { wake_levels.at(pin) = level; }
static void gpio_wakeup_disable(gpio_num_t pin) { wake_levels.at(pin) = 0; }
static void gpio_set_intr_type(gpio_num_t pin, int type) { restored_interrupts.at(pin) = type; }
static void esp_light_sleep_start() {
++sleep_calls;
if (press_during_sleep) {
require(wake_levels[PIN_USER_BTN] == GPIO_INTR_LOW_LEVEL,
"resolved V4 profile did not arm active-low button wake");
require(wake_levels[P_LORA_DIO_1] == GPIO_INTR_HIGH_LEVEL,
"button wake replaced the radio wake source");
g_mock_pin_levels[PIN_USER_BTN] = LOW;
press_during_sleep = false;
}
}
#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;
bool isRadioTestActive() const { return false; }
};
struct ESP32Board : MainBoard {
bool inhibit_sleep = false;
uint32_t getIRQGpio() { return P_LORA_DIO_1; }
bool isExternalPowered() const { return false; }
@METHODS@
} board;
@BUTTON_CONSTRUCTOR@
struct TestDisplay {
mesh::ui::DisplayPowerPrefs prefs;
mesh::ui::DisplayPowerPolicy policy;
bool servicePower(bool usb) {
const bool before = policy.on();
policy.update(prefs, usb, false, false, millis());
return before != policy.on();
}
bool isOn() const { return policy.on(); }
void wake(mesh::ui::DisplayWake reason) { policy.wake(reason, millis()); }
} display;
struct UITask {
TestDisplay* _display = &display;
uint32_t _next_refresh = 0, _powering_off_at = 0, _next_read = 0;
int _prevBtnState = HIGH;
void resetRadioProfileDisplayPage() {}
void advanceRadioProfileDisplayPage() {}
void loop() {
@UI_BUTTON@
}
} ui;
static bool roleCanPowerSave() {
bool can_power_save = true;
@ROLE_SLEEP_GUARD@
return can_power_save;
}
int main() {
try {
static_assert(PIN_USER_BTN == 0 && P_LORA_DIO_1 == 14,
"fixture must use the actual V4 GPIOs");
resetArduinoMock();
g_mock_millis = 1000;
g_mock_pin_levels[PIN_USER_BTN] = HIGH;
user_btn.begin();
ui.loop();
require(!display.isOn(), "button-mode screen did not start blank");
require(roleCanPowerSave(), "idle button blocked initial power saving");
// A press already pending must be serviced instead of entering sleep.
g_mock_pin_levels[PIN_USER_BTN] = LOW;
board.sleep(30);
require(sleep_calls == 0 && critical_depth == 0,
"V4 slept over an already pressed button");
g_mock_pin_levels[PIN_USER_BTN] = HIGH;
// Simulate the physical edge arriving while the CPU is sleeping.
press_during_sleep = true;
board.sleep(30);
require(sleep_calls == 1 && digitalRead(PIN_USER_BTN) == LOW,
"fresh button press failed to wake the V4");
require(wake_levels[PIN_USER_BTN] == 0 && wake_levels[P_LORA_DIO_1] == 0
&& restored_interrupts[PIN_USER_BTN] == GPIO_INTR_DISABLE
&& restored_interrupts[P_LORA_DIO_1] == GPIO_INTR_POSEDGE
&& critical_depth == 0, "button/radio wake cleanup regressed");
ui.loop();
require(user_btn.needsPolling() && !roleCanPowerSave(),
"role slept before button debounce completed");
g_mock_millis += 25;
ui.loop();
require(user_btn.needsPolling() && !roleCanPowerSave(),
"held button stopped gesture polling");
g_mock_millis += 50;
g_mock_pin_levels[PIN_USER_BTN] = HIGH;
ui.loop();
require(user_btn.needsPolling() && !roleCanPowerSave(),
"role slept before release debounce completed");
g_mock_millis += 25;
ui.loop();
require(user_btn.needsPolling() && !roleCanPowerSave(),
"role slept over the pending multi-click deadline");
g_mock_millis += MOMENTARY_BUTTON_MULTI_CLICK_MS;
ui.loop();
require(display.isOn(), "real UITask click did not wake the screen");
require(!user_btn.needsPolling() && roleCanPowerSave(),
"completed gesture permanently disabled power saving");
board.sleep(30);
require(sleep_calls == 2, "released button failed to allow sleep again");
g_mock_millis += 15000;
ui.loop();
require(!display.isOn(), "button screen failed to expire normally");
} catch (const std::exception& error) {
std::cerr << error.what() << '\n'; return 1;
}
}
'''
class Esp32UsbSleepTest(unittest.TestCase):
def test_v4_profiles_inherit_button_wake_with_the_actual_gpio_pins(self):
config = v4_profile_config()
# These standalone partition-migration utilities deliberately bypass
# the V4 board/display/radio bases and have no user-button support.
utilities = {"env:heltec_v4_partition_migrator" + suffix for suffix in
("", "_lora_repeater", "_test_hold", "_test_usb_diagnostic")}
variants = configparser.ConfigParser(interpolation=None)
variants.read([ROOT / "variants/heltec_v4/platformio.ini",
ROOT / "variants/heltec_v4_r8/platformio.ini"])
profiles = [section for section in variants.sections()
if section.startswith("env:") and section not in utilities]
self.assertGreater(len(profiles), 30)
for section in profiles:
with self.subTest(profile=section):
defines = button_defines(config, section)
self.assertEqual(defines["MOMENTARY_BUTTON_WAKE_FROM_SLEEP"], "1")
self.assertEqual(defines["PIN_USER_BTN"], "0")
self.assertEqual(defines["P_LORA_DIO_1"], "14")
def test_v4_sleep_button_poller_and_real_ui_wake_the_screen(self):
config = v4_profile_config()
methods = "\n".join(board_method(signature) for signature in (
"void sleep(uint32_t secs) override",
"bool isUsbDataConnected() override",
"bool isUsbHostConnected() override"))
compiler = os.environ.get("CXX", "c++")
self.assertIsNotNone(shutil.which(compiler), "a C++17 compiler is required")
with tempfile.TemporaryDirectory(prefix="meshcore-v4-button-wake-") as directory:
for family in ("heltec_v4", "heltec_v4_r8"):
target = (ROOT / f"variants/{family}/target.cpp").read_text()
constructor = re.search(
r"MomentaryButton user_btn\([\s\S]*?\);", target)[0]
for role, suffix in (("simple_repeater", "repeater"),
("simple_room_server", "room_server"),
("simple_sensor", "sensor")):
section = f"env:{family}_{suffix}"
defines = button_defines(config, section)
ui_source = (ROOT / f"examples/{role}/UITask.cpp").read_text()
loop = ui_source.index("void UITask::loop()")
opening = ui_source.index("{", loop) + 1
button_start = ui_source.index("#if defined(PIN_USER_BTN)", opening)
button_block = conditional_block(ui_source, button_start)
ui = ui_source[opening:button_start] + button_block
main = (ROOT / f"examples/{role}/main.cpp").read_text()
guard_start = main.index("#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP)")
guard = conditional_block(main, guard_start)
harness = V4_BUTTON_HARNESS.replace("@METHODS@", methods)
harness = harness.replace("@BUTTON_CONSTRUCTOR@", constructor)
harness = harness.replace("@UI_BUTTON@", ui)
harness = harness.replace("@ROLE_SLEEP_GUARD@", guard)
cases = [("profile", harness, defines, None)]
if family == "heltec_v4" and role == "simple_repeater":
# Removing the actual inherited flag reproduces the
# missed GPIO wake using the same production methods.
missing = dict(defines, MOMENTARY_BUTTON_WAKE_FROM_SLEEP="0")
cases.append(("missing-flag", harness, missing,
"V4 slept over an already pressed button"))
cases.append(("missing-poller", harness.replace(
"can_power_save = can_power_save && !user_btn.needsPolling();",
""), defines, "role slept before button debounce completed"))
cases.append(("missing-display-wake", harness.replace(
"_display->wake(mesh::ui::DisplayWake::Button);",
"(void)0;"), defines,
"real UITask click did not wake the screen"))
for name, text, selected, expected_error in cases:
with self.subTest(profile=section, control=name):
stem = Path(directory) / f"{family}-{suffix}-{name}"
cpp = stem.with_suffix(".cpp")
cpp.write_text(text, encoding="ascii")
flags = [f"-D{key}={selected[key]}" for key in (
"PIN_USER_BTN", "P_LORA_DIO_1",
"MOMENTARY_BUTTON_WAKE_FROM_SLEEP", "DISPLAY_CLASS")]
result = subprocess.run([
compiler, "-std=c++17", "-Wall", "-Wextra",
"-DARDUINO_USB_CDC_ON_BOOT=1", "-DARDUINO_USB_MODE=1",
"-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=0",
"-DMESH_USB_LOGGING_AVAILABLE=0",
"-DUSER_BTN_LONG_PRESS_MILLIS=" +
selected.get("USER_BTN_LONG_PRESS_MILLIS", "1000"),
"-DUSER_BTN_MULTICLICK=" +
selected.get("USER_BTN_MULTICLICK", "1"),
"-DPOWEROFF_DELAY=2000",
*flags, "-I", str(ROOT / "test/mocks"),
"-I", str(ROOT / "src"), str(cpp),
str(ROOT / "src/helpers/ui/MomentaryButton.cpp"),
"-o", str(stem)], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr)
result = subprocess.run(
[str(stem)], capture_output=True, text=True, timeout=10)
if expected_error is None:
self.assertEqual(result.returncode, 0, result.stderr)
else:
self.assertNotEqual(result.returncode, 0)
self.assertIn(expected_error, result.stderr)
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",
+1
View File
@@ -25,6 +25,7 @@ build_flags =
-D P_LORA_KCT8103L_PA_CSD=2
-D P_LORA_KCT8103L_PA_CTX=5
-D PIN_USER_BTN=0
-D MOMENTARY_BUTTON_WAKE_FROM_SLEEP=1
-D PIN_VEXT_EN=36
-D PIN_VEXT_EN_ACTIVE=HIGH
-D LORA_TX_POWER=22 ; Default to maximum SX1262 drive
+1
View File
@@ -22,6 +22,7 @@ build_flags =
-D P_LORA_KCT8103L_PA_CTX=5 ; PA_CTX (LOW=RX LNA, HIGH=RX bypass)
-D P_LORA_TX_LED=46
-D PIN_USER_BTN=0
-D MOMENTARY_BUTTON_WAKE_FROM_SLEEP=1
-D PIN_VEXT_EN=40
-D PIN_VEXT_EN_ACTIVE=LOW
-D ADC_MULTIPLIER=5.0715f