From ab4ea6d87c7b51e3bc93f7482f945be1127534ef Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:33:03 +0200 Subject: [PATCH] ESP power saving p1 --- zephcore/CMakeLists.txt | 27 ++++++++ .../boards/common/esp32_cpu_80mhz.overlay | 38 +++++++++++ zephcore/boards/common/pm_esp32.conf | 63 +++++++++++++++++++ zephcore/boards/common/pm_esp32.overlay | 36 +++++++++++ .../esp32/heltec_wifi_lora32_v3/board.overlay | 8 --- .../heltec_wifi_lora32_v4_procpu.dts | 8 --- .../heltec_wifi_lora32_v43_procpu.dts | 8 --- .../heltec_wireless_tracker_v2_procpu.dts | 8 --- .../esp32/station_g2/station_g2_procpu.dts | 8 --- .../thinknode_m9_esp32s3_procpu.dts | 8 --- .../0002-kconfig-cpu-freq-80mhz.patch | 38 +++++++++++ .../zephyr/0012-lora-sx126x-dio1-wakeup.patch | 43 +++++++++++++ 12 files changed, 245 insertions(+), 48 deletions(-) create mode 100644 zephcore/boards/common/esp32_cpu_80mhz.overlay create mode 100644 zephcore/boards/common/pm_esp32.conf create mode 100644 zephcore/boards/common/pm_esp32.overlay create mode 100644 zephcore/patches/modules/hal-espressif/0002-kconfig-cpu-freq-80mhz.patch create mode 100644 zephcore/patches/zephyr/0012-lora-sx126x-dio1-wakeup.patch diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index dae5ef7..415ec73 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -342,6 +342,33 @@ endif() # ESP32's DRAM by ~10KB. Rather than trim WiFi/heap to an unverifiable margin, # the classic ESP32 repeater stays CLI-only on simple boot (handled in build.sh). # The ESP32-S3/C-series have the DRAM headroom and keep WiFi OTA. +# ========== ESP32 CPU clock ========== +# Espressif parts idle in the tens of mA (WAITI gates the core clock but leaves +# PLL/peripherals/RAM powered), so CPU frequency is a first-order term in a +# battery node's draw — unlike nRF52, which already idles at microamps. Pull +# every ESP32 build down to 80 MHz, matching what Arduino MeshCore ships. +# Needs patches/modules/hal-espressif/0002 (upstream omits 80 from its DT map). +# +# Observers are the exception and keep the SoC default (240 MHz on S3/classic, +# 160 MHz on C3/C6): they run a full WiFi + TLS + MQTT stack and are mains- or +# solar-with-a-big-panel powered, so throughput matters more than current. +# Implemented as "don't apply the overlay" rather than an override, so the +# per-SoC maximum is inherited instead of hardcoded here. +if(ZEPHCORE_PLATFORM_CONF MATCHES "esp32_common" AND NOT EXTRA_CONF_FILE MATCHES "observer") + set(ZEPHCORE_ESP32_CPU_OVERLAY + "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/esp32_cpu_80mhz.overlay") + if(EXISTS ${ZEPHCORE_ESP32_CPU_OVERLAY}) + if(EXTRA_DTC_OVERLAY_FILE) + set(EXTRA_DTC_OVERLAY_FILE + "${EXTRA_DTC_OVERLAY_FILE};${ZEPHCORE_ESP32_CPU_OVERLAY}" CACHE STRING "" FORCE) + else() + set(EXTRA_DTC_OVERLAY_FILE + "${ZEPHCORE_ESP32_CPU_OVERLAY}" CACHE STRING "" FORCE) + endif() + message(STATUS " ESP32 CPU: 80 MHz (observers keep the SoC maximum)") + endif() +endif() + if(ZEPHCORE_PLATFORM_CONF MATCHES "esp32_common" AND EXTRA_CONF_FILE MATCHES "repeater" AND NOT BOARD MATCHES "/esp32/") set(ZEPHCORE_WIFI_OTA_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/wifi_ota.conf") diff --git a/zephcore/boards/common/esp32_cpu_80mhz.overlay b/zephcore/boards/common/esp32_cpu_80mhz.overlay new file mode 100644 index 0000000..5c87ae0 --- /dev/null +++ b/zephcore/boards/common/esp32_cpu_80mhz.overlay @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: MIT + * ESP32 family: run the CPU at 80 MHz. + * + * Espressif parts have no equivalent of the nRF52's "idle at microamps in + * WFI": WAITI gates the core clock but leaves the PLL, peripherals and RAM + * powered, so an idle ESP32 sits in the tens of mA and CPU frequency is a + * first-order term in that. Arduino MeshCore ships its ESP32 variants at + * 80 MHz for the same reason (ESP32_CPU_FREQ=80 in variants/heltec_v3, + * heltec_v4, heltec_tracker, rak3112, xiao_c3, ...). + * + * 80 requires patches/modules/hal-espressif/0002: upstream's DT-to-Kconfig map + * lists only the per-SoC defaults (96/120/160/240) and omits 80, so without it + * the build fails with "'CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ' undeclared". Do NOT + * reach for 96 to dodge that -- it is an ESP32-H2 frequency, rejected by + * rtc_clk_cpu_freq_mhz_to_config() on every SoC we build, and it fails at BOOT + * rather than at build time. + * + * The mesh workload is event-driven and nowhere near CPU-bound: wake on a + * packet, run some AES and LittleFS, go back to idle. Airtime dominates every + * latency that matters — the slowest presets spend seconds on air per packet. + * + * Only cpu0 is set, deliberately. clock_control_esp32.c reads exactly + * DT_INST(0, DT_CPU_COMPAT) — cpu0 — so a cpu1 property is never consulted, + * and omitting it lets this one file serve the dual-core Xtensa parts (S3, + * classic) and the single-core RISC-V parts (C3, C6) alike. Referencing + * &cpu1 here would be a dtc error on the latter. + * + * Applied automatically to non-observer ESP32 builds — see the ESP32 CPU + * clock block in CMakeLists.txt. Kept out of the per-board DTS files on + * purpose: one place to change, and it makes the observer exception a simple + * "don't include this" rather than an override that would have to know each + * SoC's maximum (240 MHz on S3/classic, 160 MHz on C3/C6). + */ + +&cpu0 { + clock-frequency = ; +}; diff --git a/zephcore/boards/common/pm_esp32.conf b/zephcore/boards/common/pm_esp32.conf new file mode 100644 index 0000000..2428fdd --- /dev/null +++ b/zephcore/boards/common/pm_esp32.conf @@ -0,0 +1,63 @@ +# ESP32 system power management — light sleep between events. +# +# west build -b heltec_wifi_lora32_v3/esp32s3/procpu zephcore --pristine -- \ +# -DEXTRA_CONF_FILE="boards/common/repeater.conf;boards/common/pm_esp32.conf" +# +# OPT-IN, and deliberately not enabled by default. Read this before shipping it. +# +# What it buys: Espressif parts have no equivalent of the nRF52's "idle at +# microamps in WFI" — WAITI gates the core clock but leaves PLL, peripherals +# and RAM powered, so an idle ESP32 sits in the tens of mA. Light sleep is +# sub-mA. On a repeater that is the difference between the MCU dominating the +# power budget and the radio dominating it. +# +# REPEATERS ONLY. Do not pair this with a companion build. The Espressif HCI +# driver (drivers/bluetooth/hci/hci_esp32.c) takes no pm_policy locks, so +# nothing stops the SoC light-sleeping mid-advertising or mid-connection; the +# NXP driver does take them, Espressif's does not. repeater.conf sets +# CONFIG_BT=n, which removes the problem rather than papering over it. +# +# BOARD REQUIREMENT — DIO1 must be an RTC-capable GPIO. On ESP32-S3 +# esp_sleep_is_valid_wakeup_gpio() reduces to RTC_GPIO_IS_VALID_GPIO(), i.e. +# GPIO 0-21. A board wiring DIO1 above that CANNOT wake on a received packet: +# +# OK heltec_wifi_lora32_v3 / v4 / v43, heltec_wireless_tracker(_v2) +# -> DIO1 = GPIO14 +# NOT OK xiao_esp32s3 (GPIO39), station_g2 (GPIO48), thinknode_m9 (GPIO42) +# +# On a NOT-OK board the driver logs "Pin N is not wakeup capable" at boot and +# the node will light-sleep straight through inbound packets while looking +# excellent on a current meter. Check that log line before trusting any +# measurement. +# +# Also required and supplied by the paired pm_esp32.overlay: +# - &rtc_timer enabled. soc/espressif/common/power.c refuses to sleep without +# it, logging "Sleep skipped. Make sure '&rtc_timer' is enabled as a wakeup +# source." — i.e. CONFIG_PM=y alone silently does nothing. +# - &gpio0 as a wakeup-source, so the driver's PM resume hook calls +# esp_sleep_enable_gpio_wakeup(). +# And patches/zephyr/0012 marks DIO1 itself with GPIO_INT_WAKEUP. +# +# Kernel time is safe across sleep: the Xtensa/ESP32 timer drivers implement +# the LPM hooks (esp32_lptim_hook_on_lpm_entry/exit) that read the RTC counter +# and announce elapsed ticks, so k_uptime_get() and every maintenance deadline +# stay correct. +# +# UNVALIDATED ON HARDWARE. Verify in this order: (1) no "not wakeup capable" +# line at boot, (2) the node still receives packets, (3) only then measure +# current. A deaf repeater is the expected failure and it is not obvious. + +CONFIG_PM=y + +# The RTC timer is a COUNTER driver. Enabling the DT node alone is not enough: +# without the counter subsystem drivers/counter is never built, no device +# object is emitted, and power.c's DEVICE_DT_GET_OR_NULL(rtc_timer) fails to +# link ("undefined reference to __device_dts_ord_NN"). COUNTER_RTC_ESP32 is +# then default y off the enabled DT node. +CONFIG_COUNTER=y + +# System-managed device PM stays off. prj.conf pins it off globally so that +# turning on CONFIG_PM cannot silently start suspending devices from the idle +# thread — that is the trap that historically killed GPS. Device suspends stay +# explicit (pm_device_action_run from the main thread). +CONFIG_PM_DEVICE_SYSTEM_MANAGED=n diff --git a/zephcore/boards/common/pm_esp32.overlay b/zephcore/boards/common/pm_esp32.overlay new file mode 100644 index 0000000..6c5d14b --- /dev/null +++ b/zephcore/boards/common/pm_esp32.overlay @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: MIT + * Devicetree half of boards/common/pm_esp32.conf — auto-paired by + * zephcore_auto_pair_overlay() in CMakeLists.txt, so passing the .conf is + * enough; this file does not need to be listed separately. + */ + +/* + * The RTC timer is the wake source for every timed sleep. Upstream leaves it + * status = "disabled" (zephyr/dts/xtensa/espressif/esp32s3/esp32s3_common.dtsi), + * and soc/espressif/common/power.c checks for it in rtc_wakeup_enable(): + * without a ready rtc_timer device it logs "Sleep skipped. Make sure + * '&rtc_timer' is enabled as a wakeup source." and returns false — so + * CONFIG_PM=y on its own is a silent no-op. This is the single easiest thing + * to forget when enabling ESP32 PM. + */ +&rtc_timer { + status = "okay"; + wakeup-source; +}; + +/* + * Arms the GPIO wake path. gpio_esp32_pm_action() calls + * esp_sleep_enable_gpio_wakeup() on PM_DEVICE_ACTION_RESUME, but only when the + * PORT is wakeup-capable — that is what pm_device_wakeup_is_capable() tests, + * and it is set by this property. Per-PIN arming is separate and comes from + * patches/zephyr/0012, which passes GPIO_INT_WAKEUP on the DIO1 configure. + * Both halves are needed: the port enables the wake source, the pin selects + * which line triggers it. + * + * gpio0 covers GPIO 0-31, which is where every RTC-capable pin lives on the + * S3 (0-21) — so this is also the only port that can carry a LoRa DIO1 wake. + */ +&gpio0 { + wakeup-source; +}; diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.overlay b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.overlay index 8b108f1..01c392b 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.overlay +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v3/board.overlay @@ -166,14 +166,6 @@ }; /* Reduce CPU clock from 240 MHz to 160 MHz — saves power, HAL minimum */ -&cpu0 { - clock-frequency = ; -}; - -&cpu1 { - clock-frequency = ; -}; - /* I2C sensors — auto-detected at runtime */ &i2c0 { #include "../../common/sensors-i2c.dtsi" diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v4/heltec_wifi_lora32_v4_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v4/heltec_wifi_lora32_v4_procpu.dts index ad2fcba..d23dc86 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v4/heltec_wifi_lora32_v4_procpu.dts +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v4/heltec_wifi_lora32_v4_procpu.dts @@ -164,14 +164,6 @@ }; /* Reduce CPU clock from 240 MHz to 160 MHz — saves power, HAL minimum */ -&cpu0 { - clock-frequency = ; -}; - -&cpu1 { - clock-frequency = ; -}; - &ledc0 { pinctrl-0 = <&ledc0_default>; pinctrl-names = "default"; diff --git a/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts b/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts index ec05dd8..316e88f 100644 --- a/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts +++ b/zephcore/boards/esp32/heltec_wifi_lora32_v43/heltec_wifi_lora32_v43_procpu.dts @@ -167,14 +167,6 @@ }; /* Reduce CPU clock from 240 MHz to 160 MHz — saves power, HAL minimum */ -&cpu0 { - clock-frequency = ; -}; - -&cpu1 { - clock-frequency = ; -}; - &ledc0 { pinctrl-0 = <&ledc0_default>; pinctrl-names = "default"; diff --git a/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts b/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts index 20c1074..e003b13 100644 --- a/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts +++ b/zephcore/boards/esp32/heltec_wireless_tracker_v2/heltec_wireless_tracker_v2_procpu.dts @@ -214,14 +214,6 @@ status = "okay"; }; -&cpu0 { - clock-frequency = ; -}; - -&cpu1 { - clock-frequency = ; -}; - &i2c0 { status = "okay"; clock-frequency = ; diff --git a/zephcore/boards/esp32/station_g2/station_g2_procpu.dts b/zephcore/boards/esp32/station_g2/station_g2_procpu.dts index 4910150..095a209 100644 --- a/zephcore/boards/esp32/station_g2/station_g2_procpu.dts +++ b/zephcore/boards/esp32/station_g2/station_g2_procpu.dts @@ -41,14 +41,6 @@ }; /* Reduce CPU clock from 240 MHz to 160 MHz — HAL Kconfig minimum supported */ -&cpu0 { - clock-frequency = ; -}; - -&cpu1 { - clock-frequency = ; -}; - &usb_serial { status = "okay"; }; diff --git a/zephcore/boards/esp32/thinknode_m9/thinknode_m9_esp32s3_procpu.dts b/zephcore/boards/esp32/thinknode_m9/thinknode_m9_esp32s3_procpu.dts index 55b5aad..3d843d5 100644 --- a/zephcore/boards/esp32/thinknode_m9/thinknode_m9_esp32s3_procpu.dts +++ b/zephcore/boards/esp32/thinknode_m9/thinknode_m9_esp32s3_procpu.dts @@ -66,14 +66,6 @@ * CPU-bound consumer. 240 also removes this board's only timing anomaly, * which matters while the CPU-jitter entropy source is under investigation * (jitter health check failed here — see memory/findings.md). */ -&cpu0 { - clock-frequency = ; -}; - -&cpu1 { - clock-frequency = ; -}; - /* USB Serial/JTAG stays disabled (SoC default): its PHY pads are GPIO19/20, * and on this board GPIO20 is the keypad's I2C1 SDA. The USJ driver's init * re-attaches the USB pad (usb_serial_jtag_ll_phy_enable_pad) and enables the diff --git a/zephcore/patches/modules/hal-espressif/0002-kconfig-cpu-freq-80mhz.patch b/zephcore/patches/modules/hal-espressif/0002-kconfig-cpu-freq-80mhz.patch new file mode 100644 index 0000000..e9849b5 --- /dev/null +++ b/zephcore/patches/modules/hal-espressif/0002-kconfig-cpu-freq-80mhz.patch @@ -0,0 +1,38 @@ +Add 80 MHz to the DT-derived CPU frequency mapping. + +zephyr/Kconfig derives ESP_DEFAULT_CPU_FREQ_MHZ from the devicetree cpu0 +clock-frequency via a lookup table listing 96/120/160/240. Those four values +are exactly the per-SoC DEFAULTS in Zephyr's own espressif dtsi files (H2 96, +C2 120, C3/C6 160, esp32/C5/S2/S3 240) -- the table enumerates defaults, not +capabilities, so a board that deliberately downclocks falls through it and the +symbol resolves to nothing: + + clk.c:120: error: 'CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ' undeclared + sleep_modes.c:235: error: 'CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ' undeclared + +80 MHz is valid on every part we build -- rtc_clk.c accepts 80/160/240 on +esp32 and esp32s3, 80/160 on C3, 80/120/160 on C6 -- and Espressif's own IDF +Kconfig.cpu offers ESP_DEFAULT_CPU_FREQ_MHZ_80. This is a gap in the Zephyr +port glue, not a limitation of the silicon or of IDF. + +ZephCore runs every non-observer ESP32 build at 80 MHz for battery life +(boards/common/esp32_cpu_80mhz.overlay). + +Do NOT 'fix' this by selecting 96 instead: 96 is in the table but is an +ESP32-H2 frequency, rejected by rtc_clk_cpu_freq_mhz_to_config() on all of our +SoCs, and it fails at BOOT rather than at build time. + +Upstream candidate: the omission looks accidental. +--- +diff --git a/zephyr/Kconfig b/zephyr/Kconfig +index e9fb2b90a8..f8793f5d59 100644 +--- a/zephyr/Kconfig ++++ b/zephyr/Kconfig +@@ -105,6 +105,7 @@ config ESP_SYSTEM_RTC_EXT_XTAL_BOOTSTRAP_CYCLES + # CPU frequency in MHz derived from devicetree clock-frequency property + config ESP_DEFAULT_CPU_FREQ_MHZ + int ++ default 80 if ESP_CLK_FREQ_HZ = 80000000 + default 96 if ESP_CLK_FREQ_HZ = 96000000 + default 120 if ESP_CLK_FREQ_HZ = 120000000 + default 160 if ESP_CLK_FREQ_HZ = 160000000 diff --git a/zephcore/patches/zephyr/0012-lora-sx126x-dio1-wakeup.patch b/zephcore/patches/zephyr/0012-lora-sx126x-dio1-wakeup.patch new file mode 100644 index 0000000..295c048 --- /dev/null +++ b/zephcore/patches/zephyr/0012-lora-sx126x-dio1-wakeup.patch @@ -0,0 +1,43 @@ +Mark DIO1 as a system wakeup source. + +Without this, enabling CONFIG_PM on an ESP32 gives a node that light-sleeps +correctly and is deaf: the SX1262 asserts DIO1 on a received packet, but the +SoC is not armed to wake on that pin, so the frame is dropped and the CPU only +resumes on the next RTC timer deadline. The failure is silent and looks like a +success on a current meter, which is the worst possible shape for it. + +The Zephyr ESP32 GPIO driver arms the wake in its CONFIGURE path -- +gpio_esp32_config() reads (flags & GPIO_INT_WAKEUP) at drivers/gpio/gpio_esp32.c +and calls esp_sleep_enable_ext1_wakeup_io() -- not in the interrupt path, which +strips the bit. So the flag has to go on gpio_pin_configure_dt(), not on the +gpio_pin_interrupt_configure_dt() call below it. + +GPIO_INT_WAKEUP is deliberately NOT part of GPIO_INT_MASK (see +include/zephyr/drivers/gpio.h), precisely so it can be passed to +gpio_pin_configure() without tripping its "Interrupt flags are not supported" +assert. Drivers that do not implement wakeup ignore the bit, so this stays a +no-op on nRF/STM32/MG24 rather than needing a per-platform guard. + +Board caveat, not fixable here: on ESP32-S3 esp_sleep_is_valid_wakeup_gpio() +reduces to RTC_GPIO_IS_VALID_GPIO(), i.e. GPIO 0-21 only. Boards wiring DIO1 +above that (xiao_esp32s3 GPIO39, station_g2 GPIO48, thinknode_m9 GPIO42) log +"Pin N is not wakeup capable" and cannot use light sleep with LoRa RX at all. +Heltec V3/V4/V43 and the Wireless Trackers put DIO1 on GPIO14 and are fine. + +--- a/drivers/lora/native/sx126x/sx126x_hal.c ++++ b/drivers/lora/native/sx126x/sx126x_hal.c +@@ -150,7 +150,13 @@ + LOG_ERR("DIO1 GPIO not ready"); + return -ENODEV; + } +- ret = gpio_pin_configure_dt(&config->dio1, GPIO_INPUT); ++ /* GPIO_INT_WAKEUP: let a DIO1 assertion wake the SoC from a system ++ * low-power state, so an inbound packet is not slept through when ++ * CONFIG_PM is enabled. Outside GPIO_INT_MASK by design, so this is ++ * legal here and ignored by drivers without wakeup support. ++ */ ++ ret = gpio_pin_configure_dt(&config->dio1, ++ GPIO_INPUT | GPIO_INT_WAKEUP); + if (ret < 0) { + LOG_ERR("Failed to configure DIO1 GPIO: %d", ret); + return ret;