Finally a Xiao Battery level over BLE is Possible and Easy.
In this video I show the build of the BLE Peripheral Sample for both the Nordic Dev board “nRF54L15DK” a.k.a. “the DK board” and the XIAO nRF54L15. The System On Chip or SOC as it’s known is the Target in the build process you select.
I used the Nrf_connect for Desktops , BLE app and the Nordic $9 Dongle to see the BLE advertising from the DLK and Xiao and make testing connections.
I show the Nrf_toolBox Mobile App as well for testing the actual results.
![]()
Bottom Line is it Does Sip power like a Camel in the desert. ![]()
Serial output when connected
---- Opened the serial port COM25 ----
[00:58:47.732,506] <inf> app: VBAT=4064 mV (84%)
*** Booting nRF Connect SDK v3.1.1-e2a97fe2578a ***
*** Using Zephyr OS v4.1.99-ff8f0c579eeb ***
[00:00:00.005,472] <inf> app: === XIAO nRF54L15 VBAT → BAS/DIS + RAWmV — Rev 2.2 ===
[00:00:00.005,499] <inf> app: Board: xiao_nrf54l15 | Build: Nov 1 2025 21:53:44
[00:00:00.007,266] <inf> fs_nvs: 2 Sectors of 4096 bytes
[00:00:00.007,272] <inf> fs_nvs: alloc wra: 0, f70
[00:00:00.007,276] <inf> fs_nvs: data wra: 0, 70
[00:00:00.007,352] <inf> bt_sdc_hci_driver: SoftDevice Controller build revision:
fc de 41 eb a2 d1 42 24 00 b5 f8 57 9f ac 9d 9e |..A...B$ ...W....
aa c9 b4 34 |...4
[00:00:00.008,563] <inf> bt_hci_core: HW Platform: Nordic Semiconductor (0x0002)
[00:00:00.008,578] <inf> bt_hci_core: HW Variant: nRF54Lx (0x0005)
[00:00:00.008,596] <inf> bt_hci_core: Firmware: Standard Bluetooth controller (0x00) Version 252.16862 Build 1121034987
[00:00:00.008,731] <inf> bt_hci_core: No ID address. App must call settings_load()
[00:00:00.009,006] <inf> bt_hci_core: HCI transport: SDC
[00:00:00.009,059] <inf> bt_hci_core: Identity: C4:64:B9:07:B6:7B (random)
[00:00:00.009,075] <inf> bt_hci_core: HCI: version 6.1 (0x0f) revision 0x3069, manufacturer 0x0059
[00:00:00.009,089] <inf> bt_hci_core: LMP: version 6.1 (0x0f) subver 0x3069
[00:00:00.010,616] <inf> app: Advertising as: "Xiao VBAT Peripheral"
[00:00:01.063,579] <inf> app: VBAT=4076 mV (86%)
[00:00:31.116,576] <inf> app: VBAT=4078 mV (86%)
[00:01:01.169,566] <inf> app: VBAT=4080 mV (86%)
[00:01:31.222,555] <inf> app: VBAT=4082 mV (86%)
[00:01:40.910,102] <inf> bas: BAS Notifications enabled
[00:02:01.275,597] <inf> app: VBAT=4086 mV (87%)
[00:02:31.328,620] <inf> app: VBAT=4086 mV (87%)
Attached is the Project ZIP with all you need to build or modify the included Source yourself or flash the HEX file as is to the Xiao.
one item not mentioned but will be in future video’s is the
“app.overlay” file that lets the build map/expose the ADC channel used to read the Voltage divider built into this Xiao.
More Testing and adjustments to come, suffice to say Having REAL battery readings available, with no Shenanigans is Awesome. ![]()
Xiao_VBAT_BLE.zip (199.4 KB)
Here is an Update,(main.c) source only if you get the compiler Warnings about Depreciated Config settings for the
"
— DIS characteristic strings (edit to taste) —
CONFIG_BT_DIS_MODEL=“XIAO nRF54L15”
CONFIG_BT_DIS_MANUF=“PJ Labs”
CONFIG_BT_DIS_SERIAL_NUMBER=“XIAO54-0001”
CONFIG_BT_DIS_SOFTWARE_REV=“1.0.0”
CONFIG_BT_DIS_HARDWARE_REV=“Rev A”
"
/*
* XIAO nRF54L15 — VBAT → BAS/DIS + LED blink
* Rev: 2.2-safe — 2025-11-01
*
* Notes:
* - Uses BAS to publish % (updated every 30s).
* - DIS is enabled by Kconfig (no runtime setters here, to avoid build errors).
* - Advertising uses BT_LE_ADV_CONN_FAST_1 (stable on NCS v3.1.1).
* - Requires app.overlay with:
* / { zephyr,user { io-channels = <&adc 7>; }; };
*/
#include <inttypes.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/devicetree.h>
#include <zephyr/logging/log.h>
LOG_MODULE_REGISTER(app, LOG_LEVEL_INF);
#include <zephyr/drivers/gpio.h>
#include <zephyr/drivers/adc.h>
#include <zephyr/drivers/regulator.h>
#include <zephyr/settings/settings.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/hci.h>
#include <zephyr/bluetooth/uuid.h>
#include <zephyr/bluetooth/gatt.h>
#include <zephyr/bluetooth/services/bas.h>
#include <zephyr/bluetooth/services/dis.h>
/* ===== LED (blink even without USB) ===== */
#define LED0_NODE DT_ALIAS(led0)
static const struct gpio_dt_spec led = GPIO_DT_SPEC_GET(LED0_NODE, gpios);
/* ===== ADC channel from zephyr,user overlay ===== */
#if !DT_NODE_EXISTS(DT_PATH(zephyr_user)) || \
!DT_NODE_HAS_PROP(DT_PATH(zephyr_user), io_channels)
#error "zephyr,user/io-channels missing in app.overlay"
#endif
#define ADC_SPEC_FROM_PROP(node_id, prop, idx) ADC_DT_SPEC_GET_BY_IDX(node_id, idx),
static const struct adc_dt_spec adc_channels[] = {
DT_FOREACH_PROP_ELEM(DT_PATH(zephyr_user), io_channels, ADC_SPEC_FROM_PROP)
};
/* Optional VBAT regulator (present on some Seeed DTS variants) */
#if DT_NODE_HAS_STATUS(DT_NODELABEL(vbat_pwr), okay)
#define HAVE_VBAT_REG 1
static const struct device *const vbat_reg = DEVICE_DT_GET(DT_NODELABEL(vbat_pwr));
#else
#define HAVE_VBAT_REG 0
#endif
/* ===== VBAT sampling ===== */
static uint16_t sample_buf;
static struct adc_sequence seq = {
.buffer = &sample_buf,
.buffer_size = sizeof(sample_buf),
};
static int vbat_mv_read(int32_t *out_mv)
{
#if HAVE_VBAT_REG
if (device_is_ready(vbat_reg)) {
regulator_enable(vbat_reg);
k_msleep(50);
}
#endif
const struct adc_dt_spec *ch = &adc_channels[0];
if (!device_is_ready(ch->dev)) {
return -ENODEV;
}
int err = adc_channel_setup_dt(ch);
if (err) {
return err;
}
adc_sequence_init_dt(ch, &seq);
err = adc_read_dt(ch, &seq);
if (err) {
return err;
}
int32_t mv = ch->channel_cfg.differential ?
(int32_t)((int16_t)sample_buf) : (int32_t)sample_buf;
err = adc_raw_to_millivolts_dt(ch, &mv);
if (err >= 0) {
mv *= 2; /* XIAO’s VBAT is typically through a 1/2 divider */
*out_mv = mv;
} else {
*out_mv = -1;
}
#if HAVE_VBAT_REG
if (device_is_ready(vbat_reg)) {
regulator_disable(vbat_reg);
}
#endif
return 0;
}
static int pct_from_mv(int32_t mv)
{
/* Simple 1S LiPo mapping: 3.30V -> 0%, 4.20V -> 100% */
if (mv < 3300) mv = 3300;
if (mv > 4200) mv = 4200;
return (int)((mv - 3300) * 100 / 900);
}
/* ===== BLE advertising (stable path on NCS v3.1.1) ===== */
static const struct bt_data ad[] = {
BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
BT_DATA_BYTES(BT_DATA_UUID16_ALL, BT_UUID_16_ENCODE(BT_UUID_BAS_VAL)),
};
static const struct bt_data sd[] = {
BT_DATA(BT_DATA_NAME_COMPLETE, CONFIG_BT_DEVICE_NAME, sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};
/* ===== GATT/conn callbacks (optional prints) ===== */
static void mtu_updated(struct bt_conn *conn, uint16_t tx, uint16_t rx)
{
LOG_INF("ATT MTU updated: tx %u, rx %u", tx, rx);
}
static struct bt_gatt_cb gatt_cb = {
.att_mtu_updated = mtu_updated,
};
static void connected(struct bt_conn *conn, uint8_t err)
{
if (err) {
LOG_WRN("Connected failed: 0x%02x", err);
} else {
LOG_INF("Connected");
}
}
static void disconnected(struct bt_conn *conn, uint8_t reason)
{
LOG_INF("Disconnected: 0x%02x", reason);
}
BT_CONN_CB_DEFINE(conn_cb) = {
.connected = connected,
.disconnected = disconnected,
};
/* ===== Periodic work: VBAT -> LOG + BAS ===== */
static void vbat_work_fn(struct k_work *w);
K_WORK_DELAYABLE_DEFINE(vbat_work, vbat_work_fn);
static void vbat_work_fn(struct k_work *w)
{
int32_t mv = 0;
if (vbat_mv_read(&mv) == 0 && mv > 0) {
int pct = pct_from_mv(mv);
if (pct < 0) pct = 0;
if (pct > 100) pct = 100;
bt_bas_set_battery_level((uint8_t)pct);
LOG_INF("VBAT=%" PRId32 " mV (%d%%)", mv, pct);
} else {
LOG_WRN("VBAT read failed");
}
k_work_schedule(&vbat_work, K_SECONDS(30));
}
/* ===== LED blink (independent of USB) ===== */
static void blink_fn(struct k_timer *t)
{
gpio_pin_toggle_dt(&led);
}
K_TIMER_DEFINE(blink_timer, blink_fn, NULL);
/* ===== BLE bring-up ===== */
static int bt_up_and_adv(void)
{
int err;
err = bt_enable(NULL);
if (err) {
LOG_ERR("Bluetooth init failed (%d)", err);
return err;
}
if (IS_ENABLED(CONFIG_SETTINGS)) {
settings_load();
}
bt_gatt_cb_register(&gatt_cb);
/* Stable, sample-proven param set on this SDK */
err = bt_le_adv_start(BT_LE_ADV_CONN_FAST_1, ad, ARRAY_SIZE(ad), sd, ARRAY_SIZE(sd));
if (err) {
LOG_ERR("Advertising start failed (%d)", err);
return err;
}
LOG_INF("Advertising as: \"%s\"", CONFIG_BT_DEVICE_NAME);
return 0;
}
/* ===== Main ===== */
int main(void)
{
LOG_INF("=== XIAO nRF54L15 VBAT → BAS/DIS + Blink — Rev 2.2-safe ===");
LOG_INF("Board: %s | Build: %s %s", CONFIG_BOARD, __DATE__, __TIME__);
/* LED first (visible life without console) */
if (device_is_ready(led.port)) {
gpio_pin_configure_dt(&led, GPIO_OUTPUT_INACTIVE);
k_timer_start(&blink_timer, K_MSEC(200), K_MSEC(500));
}
/* Bring up BLE and start advertising */
(void)bt_up_and_adv(); /* keep running even if BLE fails */
/* First VBAT sample shortly after boot, then every 30s */
k_work_schedule(&vbat_work, K_SECONDS(1));
while (1) {
k_sleep(K_SECONDS(5));
}
return 0;
}
Rebuilds , clean…here is the hex only ZIP
XIAO_VBAT_BLE_hex.zip (193.7 KB)
flash and go!
HTH
GL
PJ ![]()





