How to Read and Transmit Battery Level over BLE from Your IoT Device

Microcontroller reading battery voltage and transmitting the level over Bluetooth Low Energy

The most ironic bug in IoT firmware: your battery reporting drains the battery. Send notifications too frequently, and you shave hours off the runtime of the very thing you’re trying to monitor. Send them too infrequently, and your user’s device dies without warning. The BLE Battery Service looks trivially simple (it’s a single byte) but the implementation choices around when and how you transmit that byte have real consequences for power budget, interoperability, and user experience.

Here’s what makes this worth doing correctly: the Bluetooth SIG already defined a standard Battery Service (UUID 0x180F). Implement it per spec, and iOS, Android, Windows, and most BLE gateways will recognize and display your device’s battery level automatically, no companion app logic required. Roll a custom characteristic instead, and you’re writing parsing code on every platform forever.

This article walks through implementing the BLE Battery Service at the protocol layer: GATT structure, notifications, advertising, and the glue code in between. The focus is protocol-agnostic pseudocode you can port to any BLE stack, with Zephyr-specific shortcuts noted where relevant. ADC hardware and voltage measurement are out of scope. That’s a separate problem with a dedicated article on ADC and voltage divider measurement.

What the BLE Battery Service Actually Contains

The Battery Service (BAS) is one of the simplest adopted services in the Bluetooth SIG catalog. It contains a single mandatory characteristic:

  • Battery Service UUID: 0x180F
  • Battery Level Characteristic UUID: 0x2A19
  • Value format: uint8, range 0–100, representing remaining capacity as a percentage
  • Required properties: Read
  • Optional (but recommended) properties: Notify

When notifications are supported, the characteristic includes a Client Characteristic Configuration Descriptor (CCCD) at UUID 0x2902. If your device has multiple batteries, you add multiple instances of the Battery Level characteristic, each with a Characteristic Presentation Format descriptor to distinguish them.

The standard matters because operating systems look for 0x180F specifically. When an iPhone connects to your peripheral and discovers this service, it displays the battery level in the Bluetooth settings, right next to AirPods and other accessories. That’s free UX you don’t get with a custom UUID.

┌──────────────────────────────────────┐
│  Battery Service (UUID: 0x180F)      │
│                                      │
│  ┌────────────────────────────────┐  │
│  │ Battery Level Char (0x2A19)   │  │
│  │  Properties: Read, Notify     │  │
│  │  Value: uint8 (0–100)         │  │
│  │                               │  │
│  │  ┌──────────────────────────┐ │  │
│  │  │ CCCD (0x2902)            │ │  │
│  │  │ Value: 0x0001 = notify   │ │  │
│  │  └──────────────────────────┘ │  │
│  └────────────────────────────────┘  │
└──────────────────────────────────────┘

Defining the GATT Table in Firmware

Regardless of your BLE stack, the registration follows the same pattern: declare the service, add the characteristic with its properties and permissions, and include the CCCD descriptor. Here’s platform-agnostic pseudocode:

// 1. Define the service
service = gatt_register_service(UUID_16BIT(0x180F))

// 2. Define the Battery Level characteristic
battery_char = gatt_add_characteristic(
    service,
    uuid       = UUID_16BIT(0x2A19),
    properties = READ | NOTIFY,
    permissions = PERM_READ,
    value_type = UINT8,
    read_cb    = on_battery_read,   // callback for read requests
)

// 3. CCCD is typically added automatically when NOTIFY is set,
//    but if your stack requires explicit registration:
cccd = gatt_add_descriptor(
    battery_char,
    uuid        = UUID_16BIT(0x2902),
    permissions = PERM_READ | PERM_WRITE,
)

Key details: the characteristic value is a single byte, so there are no endianness concerns. Read permission is open (no encryption required). Battery level is rarely sensitive data, though your threat model may differ. The CCCD needs both read and write permissions because the central reads it to check subscription state and writes to it to enable notifications.

Zephyr Shortcut: Enable CONFIG_BT_BAS=y in your prj.conf and Zephyr handles all of the above. The BT_GATT_SERVICE_DEFINE macro and the built-in bas subsystem wire up the service, characteristic, and CCCD automatically. You interact through a single API call: bt_bas_set_battery_level(uint8_t level). More on this below.

Mapping Raw Voltage to 0–100%

The BLE spec doesn’t care how you measure your battery. It only cares that you deliver a uint8 between 0 and 100. The translation from raw ADC reading to that percentage is entirely your firmware’s responsibility.

The simplest approach, a linear mapping between empty and full voltage, works as a starting point:

function voltage_to_percent(voltage_mv):
    V_MIN = 3000   // millivolts — cutoff voltage
    V_MAX = 4200   // millivolts — fully charged (Li-Ion)
    clamped = clamp(voltage_mv, V_MIN, V_MAX)
    return (clamped - V_MIN) * 100 / (V_MAX - V_MIN)

This is a simplification. Real lithium battery discharge curves are nonlinear. A Li-Ion cell sits near 3.7V for most of its life and then drops off a cliff. A linear map will report 50% when the battery is actually at ~70% real capacity. For production firmware, use a lookup table or piecewise linear approximation calibrated against your specific cell. See ADC and voltage divider measurement for the hardware side of this problem.

Regardless of method, enforce the 0–100 range. Never send 101 or 255. Some centrals will display garbage or ignore the characteristic entirely.

Pushing Updates with Notifications

A central can always read the Battery Level characteristic on demand. But polling is wasteful: the central has to initiate a request, wait for a response, and repeat on a timer. Notifications let the peripheral push updates only when something changes.

The mechanism relies on the CCCD. When a central writes 0x0001 to the CCCD, it’s subscribing to notifications. Your firmware must check this value before sending. Pushing data to an unsubscribed central is a protocol violation on most stacks and will typically be silently dropped or cause a disconnect.

Central                          Peripheral
  |                                  |
  |--- Write CCCD (0x0001) -------->|
  |                                  |
  |          [battery % changes]     |
  |                                  |
  |<--- Notification (value: 72) ---|
  |                                  |
  |          [battery % changes]     |
  |                                  |
  |<--- Notification (value: 71) ---|
  |                                  |

When to send is the critical design decision. Three common strategies:

  1. On percentage change. Sample the ADC periodically (e.g., every 30 seconds), but only send a notification when the integer percentage actually changes. This is the most power-efficient approach for most products.
  2. Periodic with change gating. Notify every 60 seconds, but only if the value differs from the last transmitted value. Gives the central a predictable update cadence.
  3. Threshold-based. Notify only at specific levels: 50%, 20%, 10%, 5%. Useful for devices where battery awareness matters primarily at low charge.
function maybe_notify_battery():
    current_percent = voltage_to_percent(read_adc())
    if current_percent != last_reported_percent:
        if cccd_enabled:
            gatt_notify(battery_char, current_percent)
        last_reported_percent = current_percent

The power trade-off is real: each notification means waking the radio, sending a packet, and waiting for acknowledgment. On a typical BLE peripheral, a single notification costs roughly 0.1–0.3 mA for a few milliseconds. Infrequent, change-gated notifications add negligible drain. Notifying every second will measurably shorten battery life, an irony worth avoiding.

Embedding Battery Level in Advertising Data

Notifications require an active connection. For non-connectable beacons, asset trackers, or any scenario where you want scanners to see battery level without connecting, embed it in the advertising payload.

The standard approach uses the Service Data AD type (0x16) with the BAS UUID followed by the percentage byte:

Advertising Data Field:
┌──────┬──────┬────────┬────────┬───────┐
│ Len  │ Type │ UUID   │ UUID   │ Level │
│ 0x04 │ 0x16 │ 0x0F   │ 0x18   │ 0x48  │  ← 72%
└──────┴──────┴────────┴────────┴───────┘
         Service Data   BAS UUID   Value
         AD Type        (little-endian)

The UUID is transmitted little-endian, so 0x180F becomes bytes 0x0F 0x18. The total cost is 5 bytes of your advertising payload, which is meaningful when legacy advertising caps you at 31 bytes. Extended advertising (BLE 5.0+) gives you up to 254 bytes, so the budget is less tight.

Two things to keep in mind: advertising data is unencrypted and visible to any scanner in range. And update the advertising data at a reasonable interval. Restarting advertising on every ADC sample wastes power and may cause brief scan gaps.

Zephyr RTOS: The One-Line BAS Implementation

If you’re on Zephyr, the framework ships a complete BAS implementation under subsys/bluetooth/services/bas. Setup is minimal:

In prj.conf:

CONFIG_BT_BAS=y

In your application code:

#include <zephyr/bluetooth/services/bas.h>

// Call this whenever you have a new percentage value:
bt_bas_set_battery_level(72);

That single function call updates the GATT characteristic value and sends notifications to any subscribed central. You still own the ADC-to-percentage conversion. Zephyr doesn’t know or care how you measure voltage.

The built-in BAS is excellent for prototyping and straightforward single-battery products. If you need custom notification cadence, multi-battery support, or want to integrate battery reporting with your advertising strategy, a manual implementation gives you more control.

Pitfalls That Waste Hours in Debugging

Values above 100. The spec says 0–100. Some centrals clamp silently. Others display “127%” or treat it as an error. Always clamp your output.

Stale reads. If your device sleeps for long intervals, the cached characteristic value may be minutes or hours old when a central reads it. Use the read callback to trigger a fresh ADC sample on demand, so connected centrals always get current data.

Multiple batteries, one characteristic. If your device has two batteries (e.g., a case and earbuds), you need separate Battery Level characteristic instances within the same service, each with a Characteristic Presentation Format descriptor (UUID 0x2904) that includes a description index or namespace to differentiate them.

Forgetting the CCCD check. Attempting to send notifications before the central subscribes wastes CPU cycles and may trigger stack-level errors. Always gate on the CCCD value.

Building BAS Into Your Next Product

The BLE Battery Service is the lowest-friction standard service you’ll ever implement. One UUID, one characteristic, one byte. But doing it per-spec, rather than stuffing a percentage into a custom characteristic, unlocks native OS battery display, gateway compatibility, and a protocol-level contract that every BLE central already understands.

Start with the GATT structure and notification logic outlined here. Layer in a proper discharge curve conversion when you move from prototype to production. And if you’re evaluating notification strategy, default to change-gated updates. Your battery will thank you for not over-reporting its own demise.

For more protocol-level walkthroughs, see the BLE Protocol Deep Dives pillar page.


Hubble Network enables BLE devices to transmit battery level and sensor data directly to satellites—no gateways, no terrestrial infrastructure. See how it works →