A Tracker That Sleeps Between Heartbeats
A pet tracker spends most of its life waiting. The dog is asleep on the sofa, the Bluetooth link to the hub is idle, and the collar's only job is to be ready the instant something happens. For a battery-powered device, "ready" and "awake" are not the same thing, and the gap between them is where battery life lives.
This post is about teaching our tracker firmware to fall asleep on its own, thousands of times a minute, without dropping the radio link, missing a motion event, or losing a sample from the inertial sensor. It covers the lock discipline that lets peripherals veto sleep, the wake sources that end it, the policy that keeps a bench unit reachable, and the telemetry that reports why the chip woke. The result, measured on the bench, was awake time falling from 100 percent to 16 percent with every data stream intact.
The Problem: Idle Is Not Free
Before this work, the tracker's CPU ran flat out whether or not it had anything to do. A real-time operating system with a periodic tick is always busy in a small way: every tick wakes the scheduler, and any task that polls on a short delay wakes it more often still. On a mains-powered device that is invisible. On a collar with a small cell, it is the single largest consumer of energy.
The hard part is not sleeping; the MCU can do that in one call. The hard part is sleeping safely. The Bluetooth link must survive, and the microphone interface, when it is recording, must not be starved of clock. The inertial sensor's wake-on-motion line must still be able to wake the chip. And a developer at a bench must still be able to reach the console and reflash a build that looks bricked.

The Approach: Let the Scheduler Decide, Let Peripherals Veto
The design follows the operating system's own model, described in the component's header. When every task is blocked and nothing holds a stay-awake lock, the idle loop drops the CPU into light sleep. Anything that needs the CPU awake takes a lock while it needs it, and releases it after:
1 /* lowpower.h — automatic light sleep (battery-lowpower-v2 step 3).
2 *
3 * With CONFIG_PM_ENABLE + tickless idle the CPU drops into light sleep whenever
4 * every task is blocked and no ESP_PM_NO_LIGHT_SLEEP lock is held. Peripherals
5 * that must keep running while idle hold such a lock (audio I2S while the mic
6 * is awake); BLE is handled by the controller (modem sleep, main-XTAL LP clock).
7 * The IMU wake-on-motion line (GPIO2, level-high) is a light-sleep GPIO wake
8 * source, so motion still wakes the chip within one interrupt latency. */
Three build options make that possible. Tickless idle lets the scheduler skip ticks while nothing is runnable, a three-tick threshold decides when an idle gap is long enough to be worth sleeping, and the Bluetooth controller is told to keep the main crystal powered through sleep. That last one matters on this board, which has no separate 32 kHz crystal; without it the radio controller held its own lock forever and the chip never slept.
2158 CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
2159 CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP=3
2160 CONFIG_BT_CTRL_MAIN_XTAL_PU_DURING_LIGHT_SLEEP=y
The runtime switch itself is one configuration call, with frequency scaling deliberately left for a later step:
54 static void apply(bool on)
55 {
56 #if CONFIG_PM_ENABLE
57 esp_pm_config_t pm = {
58 .max_freq_mhz = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ,
59 .min_freq_mhz = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ, /* no DFS yet: light sleep only */
60 .light_sleep_enable = on,
61 };
62 esp_err_t err = esp_pm_configure(&pm);
The Process
Audio holds the only lock
Only one peripheral is allowed to keep the CPU awake: the audio path, and only while the microphone interface is actually running. The lock is created once and then held or released by a single helper that the capture code calls on every transition:
1346 static esp_pm_lock_handle_t s_audio_pm_lock = NULL; /* NO_LIGHT_SLEEP while the I2S slave is running */
1347 static bool s_audio_pm_held = false;
1348 #endif
1349 static void audio_pm_hold(bool hold)
1350 {
1351 #if CONFIG_PM_ENABLE
1352 if (!s_audio_pm_lock || hold == s_audio_pm_held) return;
1353 if (hold) esp_pm_lock_acquire(s_audio_pm_lock); else esp_pm_lock_release(s_audio_pm_lock);
1354 s_audio_pm_held = hold;
Everything else had to be made sleep-compatible rather than sleep-blocking. The status LED is the clearest example. Its task polled every 10 milliseconds even when the LED was solid or off, which by itself woke the CPU a hundred times a second and prevented tickless sleep entirely. The fix is a comment and a number:
163 /* Nothing to animate. A 10 ms poll here woke the CPU 100x/s and alone
164 * prevented tickless light sleep (lowpower-v2 step 3); 250 ms is an
165 * invisible latency for a mode change. */
166 vTaskDelay(pdMS_TO_TICKS(250));
The LED's PWM was also moved onto the crystal clock, which the Bluetooth controller keeps powered, so a breathing LED keeps breathing through sleep instead of freezing mid-pulse.
Motion wakes the chip, but only when it should
The inertial sensor has two modes, described in an earlier post on wake-on-motion. In the armed mode it is the tracker's alarm clock: its interrupt line is registered as a level-triggered sleep wake source, so a dog standing up ends the sleep within one interrupt latency.
358 s_imu_mode = IMU_MODE_ARMED;
359 ESP_LOGI(TAG, "IMU -> ARMED (accel LP 35 Hz, WoM thr %d x4mg, gyro off)", IMU_WOM_THRESHOLD_LSB);
360 gpio_intr_enable((gpio_num_t)kInterruptPin);
361 gpio_wakeup_enable((gpio_num_t)kInterruptPin, GPIO_INTR_HIGH_LEVEL); /* WoM wakes the chip from light sleep */
In streaming mode the same line is explicitly removed as a wake source. The reason is written next to the pin configuration, with the measurement that justified it: a level wake on a pin that nobody clears makes every sleep attempt wake immediately, which the bench showed as roughly 730 sleep entries per second.
471 /* The light-sleep GPIO wake on this pin is enabled ONLY in ARMED mode
472 * (imu_enter_armed) and disabled in STREAM (imu_enter_stream): a level wake
473 * on a pin that is high while nobody clears it (STREAM has WoM/INT off, or
474 * the IMU is absent) makes every light-sleep attempt wake immediately —
475 * measured as ~730 sleep entries/s with the IMU unresponsive. */
Pins keep their configuration through sleep
One design rule came out of this work that applies to any input pin on a sleeping MCU. On sleep entry the SDK switches each pin to a separate sleep configuration, and by default that configuration drops the pull resistors. A pulled-up power button then floats, glitches, and looks like a press on every sleep cycle. The rule is to pin the awake configuration through sleep, and the button driver now does exactly that, with the reasoning kept in the source:
151 /* Light sleep (lowpower-v2): ESP-IDF switches GPIOs to their sleep config on
152 * sleep entry, which drops this pin's pull-up. The line then glitches, the
153 * ANYEDGE ISR fires every sleep cycle, button_task runs the shutdown path
154 * (orange fast-blink) and after 5 phantom presses reset_task reboots. Each
155 * phantom press also overflowed the old 2 KB stack -> panic (decoded
156 * 2026-10-08: task=reset, vApplicationStackOverflowHook). Keep the awake
157 * config (input + pull-up) through sleep. */
158 gpio_sleep_sel_dis(BUTTON_PIN);
The inertial sensor's interrupt pin gets the same treatment, and the button task now debounces for 30 milliseconds and acts only on a level that genuinely changed.

A policy that keeps the bench reachable
A tracker that sleeps aggressively is a tracker you can lose contact with. The policy knobs guard against that: sleep is on by default, but a unit that booted with a USB host attached stays awake so the console and flasher keep working, and every unit stays awake for 90 seconds after boot so a build that looks bricked can still be reflashed.
16 /* Build-time policy knobs */
17 #define LP_LIGHT_SLEEP_DEFAULT 1 /* 1 = light sleep on by default */
18 #define LP_KEEP_AWAKE_ON_USB 1 /* 1 = stay awake if a USB host was attached at boot (bench/debug: keeps the USB-JTAG console alive) */
19 #define LP_BOOT_GRACE_MS 90000 /* stay awake this long after boot so a bricked-looking build can still be reflashed over USB */
At initialisation the component reads the USB state, decides, logs the decision, and arms a one-shot timer that enables sleep when the grace period ends. The hub can also toggle sleep at runtime, and that toggle dumps the current lock holders to the console so "why is it not sleeping" has an immediate answer.
165 bool usb = usb_serial_jtag_is_connected();
166 s_ls_wanted = LP_LIGHT_SLEEP_DEFAULT && !(LP_KEEP_AWAKE_ON_USB && usb);
167 ESP_LOGW(TAG, "init: usb_host=%d default=%d -> light sleep wanted=%d (boot grace %u ms)",
168 (int)usb, LP_LIGHT_SLEEP_DEFAULT, (int)s_ls_wanted, (unsigned)LP_BOOT_GRACE_MS);
169 apply(false); /* explicit: awake during the boot grace */
170 const esp_timer_create_args_t a = { .callback = grace_cb, .name = "lp_grace" };
171 if (esp_timer_create(&a, &s_grace_timer) == ESP_OK)
172 esp_timer_start_once(s_grace_timer, (uint64_t)LP_BOOT_GRACE_MS * 1000ULL);
Telemetry: why did it wake?
The feature I am proudest of is the smallest. A callback runs after every idle pass and records whether the chip actually slept, for how long, and what woke it. Passes where the scheduler decided not to sleep are counted separately as "skipped" rather than as sleeps, which keeps the statistics honest:
27 static esp_err_t ls_exit_cb(int64_t sleep_time_us, void *arg)
28 {
29 (void)arg;
30 /* esp_pm calls this on EVERY idle pass, also when it decided NOT to sleep
31 * (slept_us == 0 because the next tick/alarm was closer than
32 * IDLE_TIME_BEFORE_SLEEP). Count those as "skipped", not as sleeps. */
33 if (sleep_time_us <= 0) { s_wc_short++; return ESP_OK; }
34 s_sleep_us_acc += sleep_time_us;
35 s_sleep_n++;
36 uint32_t c = (uint32_t)esp_sleep_get_wakeup_cause();
37 if (c < 16) s_wc[c]++;
38 return ESP_OK;
39 }
Those counters are folded into a six-field census string and an awake-time percentage, both shipped in the periodic battery frame the collar already sends to the hub:
128 mpack_write_cstr(&w, "aw"); mpack_write_u8 (&w, awake);
129 mpack_write_cstr(&w, "ls"); mpack_write_u8 (&w, lowpower_light_sleep_enabled() ? 1 : 0);
130 mpack_write_cstr(&w, "sn"); mpack_write_u32(&w, sleeps);
131 mpack_write_cstr(&w, "wc"); mpack_write_cstr(&w, lowpower_wake_census_take());
A census like "T120 G0 B55 U0 X0 S3" tells the hub, without a cable, that in the last minute the chip woke 120 times on a timer, 55 times for the radio, never for motion, and skipped sleep three times. The same frame now carries the reset reason and uptime, and if the previous run panicked, a one-line summary pulled from a dedicated core-dump partition. On a collar the USB console is unreachable, so the battery frame became the debugger.

The Results
On the bench with light sleep enabled, the tracker ran for seven minutes with no reset, around 33 sleeps per second, and awake time of 20 percent. On battery the awake time settled at 16 percent, down from 100 percent before the work began. The inertial stream kept its rate at roughly 612 frames per minute while sleeping, the radio link stayed stable, and a record command from the hub woke the audio codec and captured a clip with its motion sidecar.
A side effect of sizing every task stack from measured high-water marks, and shrinking a 100 KB static update buffer to 16 KB, was that free heap with the radio connected rose from 4.6 KB to 88 KB. The full battery-rundown measurement is the next gate; the awake-time figure is the one that is in hand.
Why It Matters at Hoomanely
Hoomanely is reinventing healthcare for pets — replacing reactive, imprecise care with continuous, clinical-grade monitoring that catches problems early. Our devices form a Physical Intelligence ecosystem: sensors fused at the edge, feeding the Biosense AI Engine that turns raw signals into personalized, preventive insights.
Continuous monitoring is only continuous if the battery lasts. A collar that needs charging every night is a collar that spends part of every day on a counter, and a gap in the record is a gap in the pet's health picture. Cutting awake time by more than four-fifths, without giving up a single sensor stream, is what turns a prototype that works on a bench into a device that works on a dog.
The census matters just as much. A fleet of collars in homes cannot be debugged over USB, so the firmware has to carry its own explanation: why it woke, why it rebooted, and what crashed. That is the same engineering value that runs through everything we build, a device that reports the truth about itself rather than one that merely appears to work.
Key Takeaways
- Sleep is a scheduling problem. With tickless idle, the question is not how to sleep but who is allowed to keep the CPU awake, and for how long.
- Give peripherals a lock, not an opinion. The audio path holds a stay-awake lock only while its interface runs; everything else had to become sleep-compatible.
- Audit every short poll. A 10-millisecond LED loop alone prevented sleep; retiring it was worth more than any clever optimisation.
- Pins change state when the chip sleeps. Keep the awake configuration on any input that matters, or a floating line will fake events on every sleep cycle.
- Count the wake-ups and ship the count. A six-field census in an existing telemetry frame replaced a debugger the field will never have.