The LED that ate our battery

How status LEDs drained our prototype collar in 7-10 days against a power budget of 75-100 days, and why the first fixes missed.

Our power budget said the collar should last 75 to 100 days. It drained in 7 to 10 days, roughly a 10x miss. The collar is a Seeed XIAO nRF52840 Sense with a 6-axis IMU. It sends raw IMU windows over BLE to the phone, and a small neural net on our servers classifies the activity.

What we tried

On April 24 we fixed a green LED that was always on in the Zephyr firmware's main.c. It was lit in every state except deep sleep, drawing about 10 mA. The first change replaced it with a 100 ms heartbeat pulse every 5 seconds, about 0.02 mA on average. Later that day we turned all LEDs off by default: green only while a BLE sync is in progress, blue blinking while advertising, red off permanently. The commit expected battery life to go from about 5-10 days back to 75-100 days.

Earlier, in March, we had also cut the IMU to 12.5 Hz in idle mode (about 90 µA saved), lengthened the idle-loop delay, and added adaptive sampling that drops to a 60-second interval after 5 calm minutes.

What actually happened

In late June the collar was draining in 7 to 10 days. On June 28 we first patched the Zephyr tree again (v0.6.0): logging, LEDs, flash power management and rarer deep-sleep advertising. But the firmware actually deployed on the collar is the Arduino NimBLE prototype, not the Zephyr build, and the April LED fixes had also gone into the Zephyr code.

The root cause was the main-loop LED block in the NimBLE firmware, which ran every iteration. Idle and freeze blinked red and green, a connection held blue solid, and sampling blinked green. The RGB LED was lit nearly the whole time the pet was awake, drawing milliamps continuously. Idle CPU sleep was fine: n-able's FreeRTOS delay() already sleeps the CPU.

The fix

We cut NimBLE v0.8.0. All status LEDs were gated behind a USB-attached check, reading VBUS through NRF_POWER->USBREGSTATUS. Deep-sleep advertising went from every 5 minutes to every 30, while the awake state stays at 5 minutes for sync UX. Serial over USB only starts when a cable is attached. We kept TX power at the maximum +8 dBm for room-to-room range, but changed the setting from 9, an out-of-range value that was silently clamped, to the valid 8. The radio is only on during advertising bursts and sync, so it was not the drain. We compiled with the n-able Arduino core and converted the hex to a UF2 file for flashing.

Then came v0.8.1, the same day. On this board the VBUS bit reads present even on LiPo power, so with v0.8.0 the RGB LED stayed lit on battery, the exact drain we were fixing. We dropped the VBUS dependency. Status LEDs are now off in all normal operation. Only the user-triggered identify command blinks them, plus a brief double green blink at boot to confirm a successful flash.

What is still open

The CPU sleeps through FreeRTOS delay(), but true System OFF deep sleep is not implemented. It needs an LSM6DS3 wake-on-motion interrupt routed to an nRF GPIO SENSE wake source. Getting it wrong bricks the collar until a manual reset, so it needs bench validation before it ships on a live pet. We also need to measure whether the USB peripheral (TinyUSB) stays powered on battery, since the n-able core may keep it up and draw milliamps.

If you are building something similar

  • Verify which firmware tree is actually running on the device before debugging power draw.
  • Hardware registers lie. Test USB detection logic on battery power, not just while plugged in.
  • Status LEDs are a luxury on a small battery. Turn them off by default.

Have a thought?

We build in public to learn. If you've solved this on nRF52 before, let us know.

Reply by email

Denys Zarubin

Founder, Spain

Investors

Request the deck

Join the
early list.

Prototype phase. Not certified. Not for sale. No payment required.

You’re on the list. We’ll email you when the collar is ready. In the meantime, download the free Fudini app.