diff --git a/docs/releases/1.17.1.9.md b/docs/releases/1.17.1.9.md index e6745094b..6818ceeea 100644 --- a/docs/releases/1.17.1.9.md +++ b/docs/releases/1.17.1.9.md @@ -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 diff --git a/docs/test-results/hardware_validation_esp32_web_console_ota_2026-10-05.md b/docs/test-results/hardware_validation_esp32_web_console_ota_2026-10-05.md index a9e0d212c..3fa7e24f8 100644 --- a/docs/test-results/hardware_validation_esp32_web_console_ota_2026-10-05.md +++ b/docs/test-results/hardware_validation_esp32_web_console_ota_2026-10-05.md @@ -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. diff --git a/docs/test-results/hardware_validation_heltec_v4_native_console_2026-10-06.md b/docs/test-results/hardware_validation_heltec_v4_native_console_2026-10-06.md new file mode 100644 index 000000000..821e3da1d --- /dev/null +++ b/docs/test-results/hardware_validation_heltec_v4_native_console_2026-10-06.md @@ -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 ` 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. diff --git a/docs/test-results/hardware_validation_heltec_v4_native_console_2026-10-06.safe.json b/docs/test-results/hardware_validation_heltec_v4_native_console_2026-10-06.safe.json new file mode 100644 index 000000000..ae9b362fa --- /dev/null +++ b/docs/test-results/hardware_validation_heltec_v4_native_console_2026-10-06.safe.json @@ -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" + } +} diff --git a/test/test_esp32_usb_sleep.py b/test/test_esp32_usb_sleep.py index da4dadbca..2be799694 100644 --- a/test/test_esp32_usb_sleep.py +++ b/test/test_esp32_usb_sleep.py @@ -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 +#include +#include +#include +#include +#include +#include +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 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", diff --git a/variants/heltec_v4/platformio.ini b/variants/heltec_v4/platformio.ini index 6543597c8..671bca1c1 100644 --- a/variants/heltec_v4/platformio.ini +++ b/variants/heltec_v4/platformio.ini @@ -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 diff --git a/variants/heltec_v4_r8/platformio.ini b/variants/heltec_v4_r8/platformio.ini index 9557283d4..bb8e210de 100644 --- a/variants/heltec_v4_r8/platformio.ini +++ b/variants/heltec_v4_r8/platformio.ini @@ -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