From 6ec5fc5603a1d0be645beb4d5f02d14e94317cb2 Mon Sep 17 00:00:00 2001 From: Xuan-Son Nguyen Date: Wed, 18 Feb 2026 15:12:29 +0100 Subject: [PATCH] feat: lower CPU freq on idle, add HalPowerManager (#852) ## Summary Continue my experiment from https://github.com/crosspoint-reader/crosspoint-reader/pull/801 This PR add the ability to lower the CPU frequency on extended idle period (currently set to 3 seconds). By default, the esp32c3 CPU is set to 160MHz, and now on idle, we can reduce it to just 10MHz. Note that while this functionality is already provided by [esp power management](https://docs.espressif.com/projects/esp-idf/en/v4.3/esp32c3/api-reference/system/power_management.html), the current Arduino build lacks of this, and enabling it is just too complicated (not worth the effort compared to this PR) Update: more info in https://github.com/crosspoint-reader/crosspoint-reader/pull/852#issuecomment-3904562827 ## Testing Pre-condition for each test case: the battery is charged to 100%, and is left plugged in after fully charged for an extra 1 hour. The table below shows how much battery is **used** for a given duration: | case / duration | 6 hrs | 12 hrs | | --- | --- | --- | | `delay(10)` | 26% | 48% | | `delay(50)`, PR https://github.com/crosspoint-reader/crosspoint-reader/pull/801 | 20% | Not tested | | `delay(50)` + low CPU freq (This PR) | Not tested | 25% | | `delay(10)` + low CPU freq (1) | Not tested | Not tested | (1) I decided not to test this case because it may not make sense. The problem is that CPU frequency vs power consumption do not follow a linear relationship, see [this](https://www.arrow.com/en/research-and-events/articles/esp32-power-consumption-can-be-reduced-with-sleep-modes) as an example. So, tight loop (10ms) + lower CPU freq significantly impact battery life, because the active CPU time is now much higher compared to the wall time. **So in conclusion, this PR improves ~150% to ~200% battery use time per charge.** The projected battery life is now: ~36-48 hrs of reading time (normal reading, no wifi) --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? **NO** --- lib/hal/HalGPIO.cpp | 19 ---- lib/hal/HalGPIO.h | 6 -- lib/hal/HalPowerManager.cpp | 95 ++++++++++++++++++++ lib/hal/HalPowerManager.h | 56 ++++++++++++ src/activities/Activity.cpp | 3 + src/activities/ActivityWithSubactivity.cpp | 3 + src/activities/settings/ClearCacheActivity.h | 1 + src/activities/settings/OtaUpdateActivity.h | 1 + src/main.cpp | 18 ++-- 9 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 lib/hal/HalPowerManager.cpp create mode 100644 lib/hal/HalPowerManager.h diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 89ce13ba..64a251de 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -1,11 +1,9 @@ #include #include -#include void HalGPIO::begin() { inputMgr.begin(); SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS); - pinMode(BAT_GPIO0, INPUT); pinMode(UART0_RXD, INPUT); } @@ -23,23 +21,6 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); } unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } -void HalGPIO::startDeepSleep() { - // Ensure that the power button has been released to avoid immediately turning back on if you're holding it - while (inputMgr.isPressed(BTN_POWER)) { - delay(50); - inputMgr.update(); - } - // Arm the wakeup trigger *after* the button is released - esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); - // Enter Deep Sleep - esp_deep_sleep_start(); -} - -int HalGPIO::getBatteryPercentage() const { - static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0); - return battery.readPercentage(); -} - bool HalGPIO::isUsbConnected() const { // U0RXD/GPIO20 reads HIGH when USB is connected return digitalRead(UART0_RXD) == HIGH; diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 615a8d63..45ca50a5 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -38,12 +38,6 @@ class HalGPIO { bool wasAnyReleased() const; unsigned long getHeldTime() const; - // Setup wake up GPIO and enter deep sleep - void startDeepSleep(); - - // Get battery percentage (range 0-100) - int getBatteryPercentage() const; - // Check if USB is connected bool isUsbConnected() const; diff --git a/lib/hal/HalPowerManager.cpp b/lib/hal/HalPowerManager.cpp new file mode 100644 index 00000000..b4d8d05c --- /dev/null +++ b/lib/hal/HalPowerManager.cpp @@ -0,0 +1,95 @@ +#include "HalPowerManager.h" + +#include +#include +#include + +#include + +#include "HalGPIO.h" + +HalPowerManager powerManager; // Singleton instance + +void HalPowerManager::begin() { + pinMode(BAT_GPIO0, INPUT); + normalFreq = getCpuFrequencyMhz(); + modeMutex = xSemaphoreCreateMutex(); + assert(modeMutex != nullptr); +} + +void HalPowerManager::setPowerSaving(bool enabled) { + if (normalFreq <= 0) { + return; // invalid state + } + + auto wifiMode = WiFi.getMode(); + if (wifiMode != WIFI_MODE_NULL) { + // Wifi is active, force disabling power saving + enabled = false; + } + + // Note: We don't use mutex here to avoid too much overhead, + // it's not very important if we read a slightly stale value for currentLockMode + const LockMode mode = currentLockMode; + + if (mode == None && enabled && !isLowPower) { + LOG_DBG("PWR", "Going to low-power mode"); + if (!setCpuFrequencyMhz(LOW_POWER_FREQ)) { + LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", LOW_POWER_FREQ); + return; + } + isLowPower = true; + + } else if ((!enabled || mode != None) && isLowPower) { + LOG_DBG("PWR", "Restoring normal CPU frequency"); + if (!setCpuFrequencyMhz(normalFreq)) { + LOG_DBG("PWR", "Failed to set CPU frequency = %d MHz", normalFreq); + return; + } + isLowPower = false; + } + + // Otherwise, no change needed +} + +void HalPowerManager::startDeepSleep(HalGPIO& gpio) const { + // Ensure that the power button has been released to avoid immediately turning back on if you're holding it + while (gpio.isPressed(HalGPIO::BTN_POWER)) { + delay(50); + gpio.update(); + } + // Arm the wakeup trigger *after* the button is released + esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW); + // Enter Deep Sleep + esp_deep_sleep_start(); +} + +int HalPowerManager::getBatteryPercentage() const { + static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0); + return battery.readPercentage(); +} + +HalPowerManager::Lock::Lock() { + xSemaphoreTake(powerManager.modeMutex, portMAX_DELAY); + // Current limitation: only one lock at a time + if (powerManager.currentLockMode != None) { + LOG_ERR("PWR", "Lock already held, ignore"); + valid = false; + } else { + powerManager.currentLockMode = NormalSpeed; + valid = true; + } + xSemaphoreGive(powerManager.modeMutex); + if (valid) { + // Immediately restore normal CPU frequency if currently in low-power mode + powerManager.setPowerSaving(false); + } +} + +HalPowerManager::Lock::~Lock() { + xSemaphoreTake(powerManager.modeMutex, portMAX_DELAY); + if (valid) { + powerManager.currentLockMode = None; + } + xSemaphoreGive(powerManager.modeMutex); +} diff --git a/lib/hal/HalPowerManager.h b/lib/hal/HalPowerManager.h new file mode 100644 index 00000000..80f667d2 --- /dev/null +++ b/lib/hal/HalPowerManager.h @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "HalGPIO.h" + +class HalPowerManager; +extern HalPowerManager powerManager; // Singleton + +class HalPowerManager { + int normalFreq = 0; // MHz + bool isLowPower = false; + + enum LockMode { None, NormalSpeed }; + LockMode currentLockMode = None; + SemaphoreHandle_t modeMutex = nullptr; // Protect access to currentLockMode + + public: + static constexpr int LOW_POWER_FREQ = 10; // MHz + static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms + + void begin(); + + // Control CPU frequency for power saving + void setPowerSaving(bool enabled); + + // Setup wake up GPIO and enter deep sleep + // Should be called inside main loop() to handle the currentLockMode + void startDeepSleep(HalGPIO& gpio) const; + + // Get battery percentage (range 0-100) + int getBatteryPercentage() const; + + // RAII helper class to manage power saving locks + // Usage: create an instance of Lock in a scope to disable power saving, for example when running a task that needs + // full performance. When the Lock instance is destroyed (goes out of scope), power saving will be re-enabled. + class Lock { + friend class HalPowerManager; + bool valid = false; + + public: + explicit Lock(); + ~Lock(); + + // Non-copyable and non-movable + Lock(const Lock&) = delete; + Lock& operator=(const Lock&) = delete; + Lock(Lock&&) = delete; + Lock& operator=(Lock&&) = delete; + }; +}; diff --git a/src/activities/Activity.cpp b/src/activities/Activity.cpp index 6cd8493e..c725c2d5 100644 --- a/src/activities/Activity.cpp +++ b/src/activities/Activity.cpp @@ -1,5 +1,7 @@ #include "Activity.h" +#include + void Activity::renderTaskTrampoline(void* param) { auto* self = static_cast(param); self->renderTaskLoop(); @@ -9,6 +11,7 @@ void Activity::renderTaskLoop() { while (true) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); { + HalPowerManager::Lock powerLock; // Ensure we don't go into low-power mode while rendering RenderLock lock(*this); render(std::move(lock)); } diff --git a/src/activities/ActivityWithSubactivity.cpp b/src/activities/ActivityWithSubactivity.cpp index 40da93f1..2af75d73 100644 --- a/src/activities/ActivityWithSubactivity.cpp +++ b/src/activities/ActivityWithSubactivity.cpp @@ -1,9 +1,12 @@ #include "ActivityWithSubactivity.h" +#include + void ActivityWithSubactivity::renderTaskLoop() { while (true) { ulTaskNotifyTake(pdTRUE, portMAX_DELAY); { + HalPowerManager::Lock powerLock; // Ensure we don't go into low-power mode while rendering RenderLock lock(*this); if (!subActivity) { render(std::move(lock)); diff --git a/src/activities/settings/ClearCacheActivity.h b/src/activities/settings/ClearCacheActivity.h index 8bfbe1f3..5976e212 100644 --- a/src/activities/settings/ClearCacheActivity.h +++ b/src/activities/settings/ClearCacheActivity.h @@ -13,6 +13,7 @@ class ClearCacheActivity final : public ActivityWithSubactivity { void onEnter() override; void onExit() override; void loop() override; + bool skipLoopDelay() override { return true; } // Prevent power-saving mode void render(Activity::RenderLock&&) override; private: diff --git a/src/activities/settings/OtaUpdateActivity.h b/src/activities/settings/OtaUpdateActivity.h index 7978acf4..76711c1b 100644 --- a/src/activities/settings/OtaUpdateActivity.h +++ b/src/activities/settings/OtaUpdateActivity.h @@ -34,4 +34,5 @@ class OtaUpdateActivity : public ActivityWithSubactivity { void loop() override; void render(Activity::RenderLock&&) override; bool preventAutoSleep() override { return state == CHECKING_FOR_UPDATE || state == UPDATE_IN_PROGRESS; } + bool skipLoopDelay() override { return true; } // Prevent power-saving mode }; diff --git a/src/main.cpp b/src/main.cpp index 1d9d01ae..d0a422ab 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -183,7 +184,7 @@ void verifyPowerButtonDuration() { if (abort) { // Button released too early. Returning to sleep. // IMPORTANT: Re-arm the wakeup trigger before sleeping again - gpio.startDeepSleep(); + powerManager.startDeepSleep(gpio); } } @@ -206,7 +207,7 @@ void enterDeepSleep() { LOG_DBG("MAIN", "Power button press calibration value: %lu ms", t2 - t1); LOG_DBG("MAIN", "Entering deep sleep"); - gpio.startDeepSleep(); + powerManager.startDeepSleep(gpio); } void onGoHome(); @@ -283,6 +284,7 @@ void setup() { t1 = millis(); gpio.begin(); + powerManager.begin(); // Only start serial if USB connected if (gpio.isUsbConnected()) { @@ -319,7 +321,7 @@ void setup() { case HalGPIO::WakeupReason::AfterUSBPower: // If USB power caused a cold boot, go back to sleep LOG_DBG("MAIN", "Wakeup reason: After USB Power"); - gpio.startDeepSleep(); + powerManager.startDeepSleep(gpio); break; case HalGPIO::WakeupReason::AfterFlash: // After flashing, just proceed to boot @@ -391,7 +393,8 @@ void loop() { // Check for any user activity (button press or release) or active background work static unsigned long lastActivityTime = millis(); if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) { - lastActivityTime = millis(); // Reset inactivity timer + lastActivityTime = millis(); // Reset inactivity timer + powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity } const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); @@ -426,11 +429,12 @@ void loop() { // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response // Otherwise, use longer delay to save power if (currentActivity && currentActivity->skipLoopDelay()) { - yield(); // Give FreeRTOS a chance to run tasks, but return immediately + powerManager.setPowerSaving(false); // Make sure we're at full performance when skipLoopDelay is requested + yield(); // Give FreeRTOS a chance to run tasks, but return immediately } else { - static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // 3 seconds - if (millis() - lastActivityTime >= IDLE_POWER_SAVING_MS) { + if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) { // If we've been inactive for a while, increase the delay to save power + powerManager.setPowerSaving(true); // Lower CPU frequency after extended inactivity delay(50); } else { // Short delay to prevent tight loop while still being responsive