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.

  • namelow-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

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.

~/.claude/skills/low-power-firmware/SKILL.md

--- name: low-power-firmware description: Designs and debugs battery-powered firmware by building a per-state power budget, measuring real current with a power profiler, choosing… license: MIT ---

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)

MCULight sleep / retentionDeepest useful modeWhat survives
ESP32 familyLight sleep ~0.1-1 mA, Wi-Fi association can be kept with modem sleepDeep sleep ~5-20 uA (chip)RTC memory (RTC_DATA_ATTR), RTC GPIO, ULP
STM32L4/U5Stop 2: ~1-3 uA with RAM retainedStandby/Shutdown: ~30-300 nAStop keeps RAM; Standby keeps backup registers only, reboots on wake
nRF52/nRF53System ON idle with RTC ~1.5-3 uASystem OFF ~0.3-1 uASystem OFF wakes via reset (GPIO, NFC, LPCOMP)
AVR (ATmega328P)Idle ~mAPower-down with WDT ~4-6 uA, without ~0.1-1 uARAM retained
RP2040Sleep ~1 mA rangeDormant ~0.2 mA range at board levelRAM 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

DrainTypical costFix
Linear regulator quiescent current (AMS1117 class)~5 mALow-Iq LDO (MCP1700 ~1.6 uA, TPS7A02 ~25 nA) or efficient buck with low-Iq mode
Battery voltage divider always connectedV/(R1+R2): 3.7 V across 20 k = 185 uA1 M + 1 M with a 100 nF cap at the ADC pin, or switch the divider with a MOSFET
Floating GPIO inputs10s-100s uA from input buffers togglingSet unused pins to analog/disabled or pull to a defined level
Pull-up held low by a sleeping peripheralV/R per line: 3.3 V / 4.7 k = 0.7 mAPower the peripheral from a GPIO or load switch, release lines in sleep
Sensor in continuous modeuA to mAOne-shot/forced mode, or cut its power rail between reads
SD card idle0.1-several mAPower-gate the card
Power or status LED1-20 mARemove, or pulse for a few ms
USB-UART bridge on dev boardmAMeasure on target hardware
Debug probe attached0.1-2 mAUnplug, 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_ENABLE and CONFIG_FREERTOS_USE_TICKLESS_IDLE, then call esp_pm_configure() with light_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

NeedWake source
Periodic samplingRTC timer / low-power timer
Button or reed switchGPIO level/edge wake (respect which pins are wake-capable)
MotionAccelerometer wake-on-motion interrupt pin; MCU stays asleep
Threshold on analog valueLow-power comparator or ULP coprocessor (ESP32), LPCOMP (nRF)
Network commandNot 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

  1. Profiler capture of a full cycle on the target board, probe unplugged, after power cycle.
  2. 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.
  3. battery_life.py with measured numbers meets the target with at least 20-30% margin.
  4. 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 113 lines · 5.1 KB
#!/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.

esc