Skip to content

perf(power): Tier-2 automatic light sleep, -64% idle (AI FYI) - #119

Open
johnbuckman wants to merge 1 commit into
mainfrom
ai-fyi/tier2-light-sleep-poc
Open

perf(power): Tier-2 automatic light sleep, -64% idle (AI FYI)#119
johnbuckman wants to merge 1 commit into
mainfrom
ai-fyi/tier2-light-sleep-poc

Conversation

@johnbuckman

@johnbuckman johnbuckman commented Aug 10, 2026

Copy link
Copy Markdown

⚠️ AI-written, FYI only. This entire change and its testing were done by an AI agent (Claude) on a hardware-in-the-loop bench. It is posted for review and discussion, not as something that has to be merged. Numbers are from a single V8.1 unit.

What this changes

Enables ESP-IDF automatic light sleep so the SoC powers down between events instead of running the core continuously. Idle-ON current drops from 110.6 mA to 39.7 mA (-64%).

Three parts:

  1. sdkconfig (new opt-in from-source env [env:esp32s3_pm]): CONFIG_PM_ENABLE + FREERTOS_USE_TICKLESS_IDLE + esp_pm_configure(light_sleep=on), and the NimBLE controller's low-power clock on RTC_SLOW (internal RC) — this board has no external 32 kHz crystal (CONFIG_RTC_CLK_SRC_INT_RC), and the default main-crystal LP clock dies in light sleep.
  2. A blocking yield at the end of loop() — the actual unlock (see below).
  3. Serial hardening for light sleep — UART wake-on-rx + a stay-awake grace window.

All runtime code is guarded by #if CONFIG_PM_ENABLE, so the stock precompiled build (env:esp32s3) is completely unchanged.

How it was found

A config-only attempt (PM flags + esp_pm_configure) gave no benefit — 87.7 mA, worse than plain 80 MHz scaling, and never dipped toward a sleep floor. Root cause: loop() has no blocking call (pureScale() -> scale.update() is non-blocking), so the loop task never yields, the FreeRTOS idle task never runs, and tickless idle can never enter. Adding one vTaskDelay at the end of loop() was the whole fix:

Build Idle vs baseline
baseline idle-ON 110.6 mA
PM flags only, no yield 87.7 mA none (loop never blocks)
PM + loop yield 39.7 mA -64%

Min dips to ~30 mA (asleep), max ~50 mA (awake) = real light-sleep duty-cycling.

Serial hardening

Light sleep turns off the UART clock, so incoming bytes were dropped mid-command. Fixed with:

  • esp_sleep_enable_uart_wakeup(UART_NUM_0) (+ threshold) so an incoming byte wakes the SoC;
  • a stay-awake grace window: t_lastSerialActivity is stamped when serial bytes arrive, and the loop only yields the sleep-enabling vTaskDelay(10ms) when serial has been quiet >250 ms; during a burst it uses vTaskDelay(1) (below the tickless-idle threshold) so it stays awake and responsive without busy-spinning.

Result: serial functional regression 8/8 consistently (5/5 runs during development), idle power unchanged (the grace window costs nothing at idle — it only stays awake during actual serial traffic).

How it was tested

Committed-code verification: boot log shows [pm] esp_pm_configure ls=on 240/80 -> ESP_OK and [pm] uart0 wake-on-rx -> ESP_OK; USB-serial functional regression 8/8 (boot, weight stream through light sleep, v, oledoff/oledon, tare, stability); idle 39.7 mA @ 5.2 V on the KM003C. Build/flash: pio run -e esp32s3_pm -t upload.

Relationship to #118

This is an alternative to, not stacked on, #118 (manual CPU 80 MHz scaling). PM/DFS does frequency scaling automatically, so the manual setCpuFrequencyMhz calls from #118 would conflict; this branch is cut from main without them. If Tier-2 is adopted, it supersedes #118.

Known gaps / NOT verified

  • BLE over-the-air not tested — host BLE tooling is blocked non-interactively on the test Mac. It boots and advertises without crashing on the internal-RC LP clock, but pairing/streaming under light sleep (with the sloppier RC clock) needs a phone/app test. This is the most important thing to check before trusting it.
  • The first byte after a sleep period is still lost (it trips the UART wake and is consumed) — absorbed by a host-side wake-preamble convention, not a firmware bug, but worth a protocol note.
  • The yield is a crude fixed 10 ms, not synced to the load cell. A DRDY-interrupt-driven block would be cleaner and likely lower still.
  • Requires a from-source framework rebuild (the sdkconfig flags are compile-time); slower than the precompiled path.
  • Single V8.1 bench unit, full battery, room temperature.

Update: #121 builds directly on this branch (it includes these two commits) and extends it into a full power profile — BLE-wakeable low-power sleep and a 240 MHz pin when connected + live (for the upcoming extension mechanism), all verified over real BLE. If you're evaluating the light-sleep approach, #121 is the fuller version.

Enable ESP-IDF automatic light sleep (CONFIG_PM_ENABLE + tickless idle +
esp_pm_configure light_sleep=on), with the NimBLE controller's low-power
clock on RTC_SLOW (internal RC) since this board has no external 32kHz
crystal. New opt-in from-source env [env:esp32s3_pm] carries the sdkconfig.

Config flags alone do nothing here: the main loop never blocks, so the
FreeRTOS idle task never runs and tickless idle never enters. The fix is a
blocking yield at the end of loop(); with it, idle drops 110.6 -> 39.7 mA
(-64%) on a V8.1 unit. All runtime bits are guarded by CONFIG_PM_ENABLE, so
the change is inert on the stock precompiled build.

Serial is hardened for light sleep: UART0 wake-on-rx, plus a stay-awake
grace window after serial activity (t_lastSerialActivity) so a command's
bytes aren't lost to a mid-command sleep. Functional serial regression is
consistently 8/8 with idle power unchanged.

AI-written change, posted as an FYI for review; not necessarily to merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants