How to Implement Adaptive BLE Advertising Intervals Based on Battery State

Adjusting BLE advertising intervals dynamically to extend battery life on low-power devices

Your coin cell is at 2.4V. The user finally notices the asset tracker is missing and opens the app. The device is advertising once every second, drawing the last microamps from a sagging cell, and the phone’s scan window misses it three times in a row. By the fourth try, the device is dead.

You picked that 1-second interval to save power. It killed the device’s last chance to be found.

This is the fixed-interval trap, and it’s the worst possible failure mode for a battery-powered BLE peripheral: silent right when intervention is needed. The fix is adaptive BLE advertising, a small state machine that reads battery voltage and changes the advertising interval as the cell ages. Here’s how to build one in an afternoon on Zephyr or nRF Connect SDK, with a state table you can paste in and tune.

The Tradeoff, Stated Plainly

Two facts in tension:

  • Average current draw scales roughly with 1/interval (TX power and PDU size matter, but interval dominates).
  • Mean discovery latency scales linearly with interval. A central doing continuous scan waits on average half an interval before it hears you, since your advertising events and its scan windows aren’t phase-aligned.

Pick a fixed interval and you’re stuck on one point of that curve for the device’s whole life.

Current draw (uA)
   ^
   |  *
   |   *
   |    *
   |      *
   |         *  *  *  *
   +------------------------> Advertising interval (ms)
      20  100  500  1000  2000

A ble battery aware design lets the device pick differently at each phase of its life:

  • Fresh battery: stretch the interval, bank the longevity.
  • Mid-life: balanced.
  • Low battery: shorten the interval so a human or gateway can find the device before it dies.

A device that gets louder as it weakens respects the user’s ability to do something about it.

Choosing Your Battery State Signal

You need a number that tracks remaining energy. Three options, in order of how often I’d reach for them:

Voltage (default). Read VBAT through the SAADC, either directly or via a divider. Free, no extra IC, good enough for primary cells like CR2032 where the discharge curve is reasonably monotonic. Two caveats: voltage sags under TX load, so sample during a quiet window or use a moving average over several reads. And calibrate your reference, the internal 0.6V band gap drifts.

State of Charge from a fuel gauge. If you’ve got a MAX17048 or similar on the board, use it. It models the chemistry curve and compensates for temperature. Worth it for Li-ion, overkill for a CR2032.

Temperature correction. Cold cells report falsely low voltage. If the device lives outdoors, read the on-die temp sensor (nrf_temp on Nordic parts) and either apply a small correction LUT or just widen your hysteresis bands when temp is below 0°C. Don’t overthink this on the first pass.

Energy-harvested cases. If you’re running off a supercap fed by a solar or TEG harvester through something like a BQ25570, your “battery voltage” is the storage cap voltage. Same state machine, different source. Add a “harvesting active” flag that biases toward shorter intervals when energy is flowing in.

Designing the State Machine

Four states, three transition thresholds, hysteresis on every boundary. That’s the whole structure.

Concrete numbers for a CR2032 (3.0V nominal, 2.0V cutoff). Recalibrate per chemistry:

+-------------------+----------------+------------------+-----------+
| State             | V threshold    | Adv interval     | Hysteresis|
+-------------------+----------------+------------------+-----------+
| HEALTHY           | > 2.85 V       | 1000 ms          | +0.05 V   |
| NOMINAL           | 2.65 - 2.85 V  | 500 ms           | +0.05 V   |
| CONSERVE          | 2.45 - 2.65 V  | 2000 ms          | +0.05 V   |
| CRITICAL_VISIBLE  | < 2.45 V       | 200 ms (burst)   | n/a       |
+-------------------+----------------+------------------+-----------+

The shape:

   [HEALTHY] --V<2.85--> [NOMINAL] --V<2.65--> [CONSERVE]
       ^                    ^                       |
       |                    |                       V
       |                    |                  V<2.45
       +-----V>2.90---------+                       |
                                                    V
                                          [CRITICAL_VISIBLE]
                                                    |
                                          V>2.50----+

Two design choices to defend:

CONSERVE stretches the interval to 2 seconds. The cell is past midlife but the user has weeks or months to act. Buy them that time. Most centrals doing background scans cycle on the order of several seconds, so a 2s interval still lands inside their connection-discovery window comfortably.

CRITICAL_VISIBLE shortens to 200ms. Yes, this burns the remaining charge faster. That’s the trade. You’re spending the last 10% of the cell on being findable instead of being alive-but-invisible. If the user has been notified (“battery low”) and ignored it for a week, the device’s last act should be to scream, not whisper.

Hysteresis is non-negotiable. Without that 50mV gap, a cell hovering near a threshold under TX load will flap between states every voltage sample. You’ll burn current re-initializing the advertising stack and your logs will be useless.

Sample voltage on a slow cadence, every 30 to 60 seconds, decoupled from the advertising loop itself. Battery voltage doesn’t change on millisecond timescales.

Implementation: Zephyr

The dynamic advertising interval pattern in Zephyr has one gotcha worth stating up front: you can’t change the advertising interval on a running advertising set. You stop, reconfigure bt_le_adv_param, and start again. Plan around the brief gap (it’s microseconds; nothing cares).

A sketch:

#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/drivers/adc.h>
#include <zephyr/kernel.h>

enum batt_state {
    ST_HEALTHY,
    ST_NOMINAL,
    ST_CONSERVE,
    ST_CRITICAL,
};

struct state_cfg {
    uint16_t interval_min;  /* in 0.625ms units */
    uint16_t interval_max;
};

static const struct state_cfg cfg[] = {
    [ST_HEALTHY]  = { 1600, 1632 },  /* 1000 ms */
    [ST_NOMINAL]  = {  800,  832 },  /*  500 ms */
    [ST_CONSERVE] = { 3200, 3232 },  /* 2000 ms */
    [ST_CRITICAL] = {  320,  352 },  /*  200 ms */
};

static enum batt_state current = ST_HEALTHY;

static enum batt_state next_state(enum batt_state s, int mv)
{
    /* Downward transitions: strict thresholds */
    if (mv < 2450) return ST_CRITICAL;
    if (mv < 2650 && s != ST_CRITICAL) return ST_CONSERVE;
    if (mv < 2850 && s == ST_HEALTHY) return ST_NOMINAL;

    /* Upward transitions: add 50 mV hysteresis */
    if (s == ST_CRITICAL && mv > 2500) return ST_CONSERVE;
    if (s == ST_CONSERVE && mv > 2700) return ST_NOMINAL;
    if (s == ST_NOMINAL  && mv > 2900) return ST_HEALTHY;

    return s;
}

static void apply_state(enum batt_state s)
{
    struct bt_le_adv_param p = BT_LE_ADV_PARAM_INIT(
        BT_LE_ADV_OPT_CONNECTABLE,
        cfg[s].interval_min,
        cfg[s].interval_max,
        NULL);

    bt_le_adv_stop();
    bt_le_adv_start(&p, ad, ARRAY_SIZE(ad), NULL, 0);
}

static void batt_work_handler(struct k_work *w)
{
    int mv = read_vbat_mv();             /* your SAADC helper */
    enum batt_state n = next_state(current, mv);
    if (n != current) {
        current = n;
        apply_state(n);
    }
    k_work_schedule(&batt_work, K_SECONDS(60));
}

Kconfig you’ll need: CONFIG_BT_PERIPHERAL=y, CONFIG_ADC=y, and whichever SAADC backend matches your SoC. If you’re running the Hubble SDK on top of Zephyr, the terrestrial advertising packet reference shows how the payload sits inside the same advertising frame, so this pattern composes cleanly with Hubble-formatted ADV data.

A subtlety: if you’ve enabled extended advertising (BT_LE_ADV_OPT_EXT_ADV), the same stop/reconfigure/start pattern applies via bt_le_ext_adv_* calls. Don’t mix the two APIs on the same set.

Nordic Connect SDK and Legacy SoftDevice Notes

nRF Connect SDK projects use the Zephyr code above unchanged. The Hubble Nordic SoftDevice reference application is a useful starting point if you’re on the SoftDevice path instead.

For legacy SoftDevice (s132/s140 on bare nRF SDK):

sd_ble_gap_adv_stop(adv_handle);
adv_params.interval = MSEC_TO_UNITS(new_ms, UNIT_0_625_MS);
sd_ble_gap_adv_set_configure(&adv_handle, &adv_data, &adv_params);
sd_ble_gap_adv_start(adv_handle, conn_cfg_tag);

For battery sampling use nrfx_saadc in blocking mode (single conversion, then power down the SAADC). It’s tens of microamps for a few hundred microseconds, negligible against the advertising budget.

Validating Your Implementation

Don’t ship the table I gave you without measuring. Two tools that earn their place on the bench:

  • Nordic Power Profiler Kit II for current capture. Cheap, integrates with nRF Connect for Desktop.
  • Otii Arc if you want fancier triggers and longer captures.

The protocol:

  1. Power the device from a bench supply, not a battery. You want to control voltage independently.
  2. Walk the supply from 3.0V down to 2.2V in 25mV steps, waiting 90 seconds at each step (longer than your sample cadence).
  3. Confirm each state transition happens at the threshold you expect, and confirm hysteresis: park the supply at 2.65V and nudge it ±30mV. Nothing should change. Nudge ±60mV; it should transition once and stay.
  4. Capture mean current in each state with the PPK2. Multiply by your expected dwell time per state to get a lifetime estimate.

This is the only step in the whole exercise that catches the real bugs. Your divider ratio is wrong. Your reference is drifting. Your SAADC is sampling during a TX burst and reading 200mV low. None of those show up until you put a calibrated supply on the input. BLE advertising optimization without measurement is just guessing in C.

Extending the Same Signal to Other Knobs

Adaptive intervals are one lever. Once the state machine is in place, the same battery-state signal can drive:

  • TX power scaling. Drop from +4 dBm to 0 dBm in CONSERVE, back up to +4 dBm in CRITICAL_VISIBLE for range.
  • A standard Battery Service (0x180F) advertised alongside your custom data, so a central can react without parsing your payload.
  • State transition logging to flash. A 16-byte record per transition (timestamp, old state, new state, voltage) gives you field diagnostics for free. Read it back when an RMA lands on your desk.

The pattern generalizes. Any property a peripheral can vary (interval, TX power, sensor cadence, sleep depth) can be driven by the same hysteretic state machine reading the same battery signal. A device that fails loud beats a device that fails quiet.


Hubble Network provides global BLE connectivity for battery-constrained devices, so the advertising intervals you tune actually translate into years of field life. See how it works →