Low-Power Firmware skill for Claude Code
Measured microamps, not datasheet wishes. Claude builds a per-state power budget from profiler measurements, computes average current and battery life with derating and self-discharge, and attacks the biggest consumer first, usually radio time. It hunts hidden drains such as regulator quiescent current, always-on dividers, floating pins and attached debug probes, and picks sleep modes and wake sources per MCU.
- name
low-power-firmware - size196 lines · ≈2,400 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Designs and debugs battery-powered firmware by building a per-state power budget, measuring real current with a power profiler, choosing sleep modes and wake sources, killing hidden drains (regulators, dividers, floating pins, debug probes), and duty cycling radios. Covers ESP32 deep sleep, STM32 Stop/Standby, nRF52 System OFF, AVR power-down, FreeRTOS tickless idle, and Zephyr PM. Use when a battery dies too fast, sleep current is in mA instead of uA, a device must last months or years, or when estimating battery life. Try asking
- My ESP32 sensor node draws 8 mA in deep sleep instead of microamps, find the leak
- Will this LoRa tracker last a year on two AA cells? Here are my measured currents
- Add tickless idle and stop mode to this STM32 FreeRTOS firmware
Use it when
- A battery-powered device dies sooner than expected
- Sleep current is far above the datasheet figure
- Estimating battery life or sizing a battery
- Choosing sleep modes and wake sources for a new design
Skip it when
- ESP32 Wi-Fi, partitions and NVS without a power target — use esp32-firmware
- Task design and priorities in FreeRTOS — use freertos-tasks
How to check the result
A profiler capture on the target board with the probe unplugged shows sleep current near the datasheet value, and the measured budget meets the life target with margin.
The common mistake it prevents
Measuring sleep current with the debugger still attached or on a dev board, then chasing firmware ghosts that are really debug domains, LEDs and regulators.
Done well means: there is a written power budget per state, every number in it was measured with a profiler (not taken from a datasheet headline), sleep current matches the chip’s datasheet within a small factor, the radio is on only when it must be, and the battery-life estimate includes derating and self-discharge.
Ground rules
- Measure before optimizing. A multimeter in series shows an average at best and its burden voltage can brown out the MCU during radio bursts; use a power profiler (Nordic PPK2, Joulescope, Otii) or a shunt plus oscilloscope.
- Measure with the debug probe unplugged and after a full power cycle. An attached or recently used SWD/JTAG session keeps debug power domains alive on many MCUs (STM32, nRF52) and adds hundreds of uA to mA.
- Dev boards lie: USB-UART bridges, power LEDs, and linear regulators on an Arduino Uno or generic ESP32 DevKit draw 5-50 mA in “sleep”. Validate on the target board, or cut the LED and regulator traces deliberately.
- The radio dominates almost every budget. Transmitting less and connecting faster beats shaving MCU clock cycles.
- Electrical safety: LiPo/Li-ion cells need a protection circuit (over-discharge, over-current, short) and a proper charger IC; never charge below 0 C or above about 45 C, never charge without a temperature-aware charger in an enclosure, and add reverse-polarity protection on battery inputs. Coin cells (CR2032) cannot source radio TX peaks directly; buffer with a low-leakage capacitor.
Workflow
- [ ] 1. Write the target: battery type/capacity and required life
- [ ] 2. List states per cycle (boot, sense, radio connect, TX, sleep) with durations
- [ ] 3. Measure each state's current on the target board with a profiler
- [ ] 4. Compute average current and life (scripts/battery_life.py); find the top consumer
- [ ] 5. Fix sleep current first if it exceeds the datasheet by >2x (hidden drains table)
- [ ] 6. Shrink the dominant active state (radio time, sensor settle time)
- [ ] 7. Re-measure a full cycle; repeat until the estimate meets the target with margin
- [ ] 8. Soak test: run for days, log battery voltage and reset reasons
Estimating battery life
python3 scripts/battery_life.py --capacity 2000 --period 300 \
--state boot:45mA:0.25s --state wifi_tx:180mA:2.5s --state sleep:12uA:rest \
--regulator-iq 5uA --derate 0.8 --self-discharge 2 --target-days 365
The script prints per-state charge share, average current, estimated life, the biggest
consumer, and (with --target-days) the average current needed to hit the target. Exit
code 1 means the target is missed, 2 means bad input. Units accept A/mA/uA/nA and
s/ms/us/min/h; one state may use rest to fill the remainder of --period.
Rule of thumb from the math: with a 300 s cycle, a 2.5 s Wi-Fi burst at 180 mA averages about 1.5 mA, which is 100 times a 12 uA sleep floor. Shortening the burst matters far more than halving sleep current.
Sleep modes (typical datasheet orders of magnitude; check your exact part)
| MCU | Light sleep / retention | Deepest useful mode | What survives |
|---|---|---|---|
| ESP32 family | Light sleep ~0.1-1 mA, Wi-Fi association can be kept with modem sleep | Deep sleep ~5-20 uA (chip) | RTC memory (RTC_DATA_ATTR), RTC GPIO, ULP |
| STM32L4/U5 | Stop 2: ~1-3 uA with RAM retained | Standby/Shutdown: ~30-300 nA | Stop keeps RAM; Standby keeps backup registers only, reboots on wake |
| nRF52/nRF53 | System ON idle with RTC ~1.5-3 uA | System OFF ~0.3-1 uA | System OFF wakes via reset (GPIO, NFC, LPCOMP) |
| AVR (ATmega328P) | Idle ~mA | Power-down with WDT ~4-6 uA, without ~0.1-1 uA | RAM retained |
| RP2040 | Sleep ~1 mA range | Dormant ~0.2 mA range at board level | RAM retained |
Choose the shallowest mode that keeps what you need and wakes fast enough; a deep mode that forces a 300 ms reboot and Wi-Fi reconnect every 10 s can cost more than light sleep.
Hidden drains checklist
| Drain | Typical cost | Fix |
|---|---|---|
| Linear regulator quiescent current (AMS1117 class) | ~5 mA | Low-Iq LDO (MCP1700 ~1.6 uA, TPS7A02 ~25 nA) or efficient buck with low-Iq mode |
| Battery voltage divider always connected | V/(R1+R2): 3.7 V across 20 k = 185 uA | 1 M + 1 M with a 100 nF cap at the ADC pin, or switch the divider with a MOSFET |
| Floating GPIO inputs | 10s-100s uA from input buffers toggling | Set unused pins to analog/disabled or pull to a defined level |
| Pull-up held low by a sleeping peripheral | V/R per line: 3.3 V / 4.7 k = 0.7 mA | Power the peripheral from a GPIO or load switch, release lines in sleep |
| Sensor in continuous mode | uA to mA | One-shot/forced mode, or cut its power rail between reads |
| SD card idle | 0.1-several mA | Power-gate the card |
| Power or status LED | 1-20 mA | Remove, or pulse for a few ms |
| USB-UART bridge on dev board | mA | Measure on target hardware |
| Debug probe attached | 0.1-2 mA | Unplug, power cycle |
Firmware patterns
ESP-IDF deep sleep with persisted state:
#include "esp_sleep.h"
#include "driver/rtc_io.h"
RTC_DATA_ATTR static uint32_t boot_count; // survives deep sleep, not power loss
void app_main(void) {
boot_count++;
if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_TIMER) {
sample_and_maybe_send(); // keep radio work batched and short
}
esp_sleep_enable_timer_wakeup(300ULL * 1000000ULL); // microseconds
// GPIO wake: ext0/ext1 on ESP32/S2/S3 (RTC GPIOs only);
// C3/C6 use esp_deep_sleep_enable_gpio_wakeup().
esp_deep_sleep_start();
}
- Cut ESP32 Wi-Fi time: static IP instead of DHCP, cache BSSID and channel in RTC memory, send then disconnect immediately; consider ESP-NOW or BLE for short payloads.
- Automatic light sleep between tasks: enable
CONFIG_PM_ENABLEandCONFIG_FREERTOS_USE_TICKLESS_IDLE, then callesp_pm_configure()withlight_sleep_enable = true.
FreeRTOS on other MCUs: set configUSE_TICKLESS_IDLE 1 (or a port-specific value) so
idle periods suppress the tick; block tasks on queues/notifications instead of polling with
vTaskDelay(1), which wakes the CPU 1000 times a second.
Zephyr: enable CONFIG_PM=y and CONFIG_PM_DEVICE=y (plus CONFIG_PM_DEVICE_RUNTIME=y
for drivers that support it); the kernel enters low-power states automatically when all
threads sleep. Use k_sleep() rather than busy loops, and suspend unused devices.
AVR (bare ATmega, not an Uno board):
#include <avr/sleep.h>
#include <avr/power.h>
void sleepNow() {
ADCSRA = 0; // ADC off (~200+ uA saved)
power_all_disable();
set_sleep_mode(SLEEP_MODE_PWR_DOWN);
sleep_enable();
sleep_bod_disable(); // ATmega328P: must directly precede sleep_cpu()
sleep_cpu(); // wake via WDT or external interrupt
sleep_disable();
power_all_enable();
}
Wake sources
| Need | Wake source |
|---|---|
| Periodic sampling | RTC timer / low-power timer |
| Button or reed switch | GPIO level/edge wake (respect which pins are wake-capable) |
| Motion | Accelerometer wake-on-motion interrupt pin; MCU stays asleep |
| Threshold on analog value | Low-power comparator or ULP coprocessor (ESP32), LPCOMP (nRF) |
| Network command | Not possible from deep sleep; poll on wake or use a low-power radio with scheduled windows (BLE advertising, LoRaWAN class A/B) |
Supply and brownout
- Radio TX peaks (ESP32 Wi-Fi 300-500 mA, cellular modems up to 2 A) pull the rail down through battery and regulator resistance. Add bulk capacitance near the module, size the regulator for the peak, and keep the brownout detector enabled.
- Lithium thionyl chloride (LiSOCl2) primaries have very low self-discharge but limited pulse current and passivation delay; pair them with a supercapacitor or hybrid layer capacitor for radio bursts.
- Read battery voltage under load (right after a TX) to detect a weak cell; idle voltage hides it.
Gotchas
- Race to sleep: running the CPU faster to finish sooner usually beats a slow clock, because static current dominates while awake. Measure both if unsure.
- Peripherals keep clocks running after use; disable them (and their GPIO alternate functions) before sleeping.
- Logging over UART keeps the CPU and peripheral awake; disable logs or lower the level in battery builds.
- Watchdog timers can wake or reset the device repeatedly in sleep; configure them for the sleep period or use a low-power watchdog mode.
- Deep sleep on ESP32 resets the CPU: code after
esp_deep_sleep_start()never runs, and regular RAM is lost.
Output: power budget
| State | Current | Duration per cycle | Charge (uAh/cycle) | Share |
|---|---|---|---|---|
| Boot + sensor read | 45 mA | 0.25 s | 3.1 | 2% |
| Wi-Fi connect + TX | 180 mA | 2.5 s | 125 | 97% |
| Deep sleep | 12 uA | 297 s | 1.0 | 1% |
Cycle 300 s, average 1.55 mA, 2000 mAh x 0.8 derate -> ~41 days. Target 365 days needs
<= 128 uA average. Next action: replace Wi-Fi reconnect per cycle with batched hourly uploads.
Verification
- Profiler capture of a full cycle on the target board, probe unplugged, after power cycle.
- Sleep current within about 2x of the datasheet value for the chosen mode; otherwise work through the hidden drains table one item at a time.
battery_life.pywith measured numbers meets the target with at least 20-30% margin.- Multi-day soak: battery voltage logged, no unexpected resets (check reset reason register), wake-ups happen on schedule.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/battery_life.py
#!/usr/bin/env python3
"""Estimate average current and battery life for a duty-cycled device (stdlib only).
Describe one repeating cycle as states, each with a current and a duration.
One state may use duration "rest" to fill the remainder of --period.
python3 battery_life.py --capacity 2000 --period 300 \
--state boot:45mA:0.25s --state wifi_tx:180mA:2.5s --state sleep:12uA:rest
Units: current accepts A, mA, uA, nA; duration accepts s, ms, us, min, h.
Exit status: 0 = estimate meets --target-days (or no target), 1 = misses the
target, 2 = bad input.
"""
from __future__ import annotations
import argparse
import re
import sys
CUR = {"a": 1e3, "ma": 1.0, "ua": 1e-3, "na": 1e-6}
DUR = {"s": 1.0, "ms": 1e-3, "us": 1e-6, "min": 60.0, "h": 3600.0}
def parse_qty(text: str, table: dict[str, float], what: str) -> float:
m = re.fullmatch(r"\s*([0-9]*\.?[0-9]+)\s*([a-zA-Z]+)\s*", text)
if not m or m.group(2).lower() not in table:
raise ValueError(f"cannot parse {what} '{text}' (units: {', '.join(table)})")
return float(m.group(1)) * table[m.group(2).lower()]
def main() -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--capacity", type=float, required=True, help="rated battery capacity in mAh")
p.add_argument("--state", action="append", required=True,
help="name:current:duration, e.g. tx:120mA:1.5s or sleep:8uA:rest")
p.add_argument("--period", type=float, help="cycle period in seconds (required if a state uses 'rest')")
p.add_argument("--derate", type=float, default=0.8,
help="usable fraction of rated capacity (temperature, cutoff voltage, aging); default 0.8")
p.add_argument("--self-discharge", type=float, default=2.0,
help="battery self-discharge in %% of capacity per month; default 2 (Li-ion ~2-3, LiSOCl2 ~0.1)")
p.add_argument("--regulator-iq", default="0uA", help="always-on regulator/divider quiescent current, e.g. 5uA")
p.add_argument("--target-days", type=float, help="required battery life in days")
a = p.parse_args()
try:
states = []
rest_idx = None
for i, s in enumerate(a.state):
parts = s.split(":")
if len(parts) != 3:
raise ValueError(f"state '{s}' must be name:current:duration")
name, cur, dur = parts
ma = parse_qty(cur, CUR, "current")
if dur.strip().lower() == "rest":
if rest_idx is not None:
raise ValueError("only one state may use 'rest'")
rest_idx = i
states.append([name, ma, 0.0])
else:
states.append([name, ma, parse_qty(dur, DUR, "duration")])
iq = parse_qty(a.regulator_iq, CUR, "current")
except ValueError as e:
print(f"error: {e}", file=sys.stderr)
return 2
busy = sum(s[2] for s in states)
if rest_idx is not None:
if not a.period:
print("error: --period is required when a state uses 'rest'", file=sys.stderr)
return 2
if busy >= a.period:
print(f"error: active states take {busy:.3f}s, longer than the {a.period}s period", file=sys.stderr)
return 2
states[rest_idx][2] = a.period - busy
period = sum(s[2] for s in states)
if period <= 0:
print("error: total cycle duration is zero", file=sys.stderr)
return 2
charge = {s[0]: s[1] * s[2] for s in states} # mA*s per cycle
total = sum(charge.values())
avg_ma = total / period + iq
sd_ma = a.capacity * (a.self_discharge / 100.0) / (30.4 * 24) # equivalent continuous drain
usable = a.capacity * a.derate
hours = usable / (avg_ma + sd_ma)
days = hours / 24
print(f"Cycle period {period:.3f} s ({3600 / period:.1f} cycles/hour)")
print(f" {'state':<14} {'current':>12} {'time':>12} {'charge share':>13}")
for name, ma, dur in states:
share = charge[name] / total * 100 if total else 0
cur_txt = f"{ma:.3f} mA" if ma >= 1 else f"{ma * 1000:.2f} uA"
print(f" {name:<14} {cur_txt:>12} {dur:>10.4f} s {share:>12.1f}%")
if iq:
print(f" regulator/divider quiescent: {iq * 1000:.2f} uA continuous")
print(f"Average current: {avg_ma * 1000:.1f} uA ({avg_ma:.4f} mA)")
print(f"Self-discharge equivalent: {sd_ma * 1000:.1f} uA")
print(f"Usable capacity: {usable:.0f} mAh of {a.capacity:.0f} mAh (derate {a.derate:.2f})")
print(f"Estimated life: {days:.0f} days ({days / 365:.2f} years)")
dominant = max(charge, key=charge.get)
print(f"Biggest consumer: '{dominant}' at {charge[dominant] / total * 100:.0f}% of cycle charge -> optimize this first")
if a.target_days is not None:
if days < a.target_days:
need = usable / (a.target_days * 24) - sd_ma
print(f"MISS: target {a.target_days:.0f} days needs average current <= {max(need, 0) * 1000:.1f} uA")
return 1
print(f"OK: meets target of {a.target_days:.0f} days")
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.