Skip to content

fix(ble): reliably expose and update BLE battery level (BAS)#10622

Draft
jamesarich wants to merge 2 commits into
developfrom
fix/ble-battery-level-bas
Draft

fix(ble): reliably expose and update BLE battery level (BAS)#10622
jamesarich wants to merge 2 commits into
developfrom
fix/ble-battery-level-bas

Conversation

@jamesarich
Copy link
Copy Markdown

Summary

Wires up the BLE Battery Service (0x180F / Battery Level 0x2A19) to behave per the Bluetooth BAS 1.1 spec: the Battery Level characteristic always holds a valid 0–100 value and is pushed to subscribers on change. This is what drives the OS-level battery indicator (Android/iOS/Windows "connected device" battery readout); the Meshtastic app's own battery display continues to use Device Metrics telemetry over the mesh service and is unaffected.

What was wrong

  • NimBLE (ESP32): updateBatteryLevel() only ever called setValue() while connected, and the characteristic was never seeded. A BAS client reading 0x2A19 right after connecting got an undefined/empty value — a READ-must-always-return-valid violation.
  • Update path: the only caller of updateBatteryLevel() was MeshService::refreshLocalMeshNode() (phone connect / GPS / position / menu). A GPS-less device with a phone connected emitted almost no 0x2A19 notifications, so the level went stale.

Changes

  • NimBLE: seed an initial level at service setup; always cache the value on update so a READ works even while disconnected; only notify() when a client is connected.
  • Power: mirror the battery level to the Battery Service from Power::readPowerStatus() on change, so it refreshes independent of GPS/position events.

Regressions fixed (introduced by the periodic push, caught in self-review)

  • NimBLE use-after-free: BLEDevice::deinit(true) frees the GATT objects but left the global BatteryCharacteristic dangling. Several AdminModule paths (e.g. serial-config entry) deinit BLE while config.bluetooth.enabled stays true, so the new periodic push would dereference freed memory. Now nulled in deinit().
  • NRF52: guard blebas.write() on the nrf52Bluetooth instance so the periodic push can't call it before the Battery Service is begun in setup().

Notes / out of scope

  • NRF54L15 (Zephyr) still has no Battery Service (updateBatteryLevel is a stub) — separate, larger task.
  • BAS 1.1's optional new characteristics (Battery Level Status 0x2BED, Critical/Energy/Health Status, etc.) are not implemented; only the mandatory Battery Level characteristic, which is what clients consume.

Testing

  • trunk fmt clean on all three files.
  • Not yet built/flashed in this environment (no PlatformIO). Needs a build of an ESP32 target (e.g. heltec-v3) and an NRF52 target (e.g. rak4631) to confirm; ideally verify the OS-level battery readout on a phone.

🤖 Generated with Claude Code

The Battery Service (0x180F / 0x2A19) is now wired up per the Bluetooth
BAS spec: the Battery Level characteristic always holds a valid 0-100
value and is pushed on change.

- NimBLE: seed an initial level at setup and cache the value on every
  update so a READ returns the current level even while disconnected;
  only notify when a client is connected.
- Power: mirror the battery level to the Battery Service from
  readPowerStatus() on change, so it updates independent of GPS/position
  events (previously the only push path was MeshService).

Also fixes two regressions the above would otherwise introduce:

- NimBLE use-after-free: BLEDevice::deinit(true) frees the GATT objects
  but left the global BatteryCharacteristic dangling. Several AdminModule
  paths (e.g. serial-config entry) deinit BLE while config.bluetooth.enabled
  stays true, so the periodic push would deref freed memory. Null the
  pointer in deinit().
- NRF52: guard blebas.write() on the nrf52Bluetooth instance so the new
  periodic push can't call it before the Battery Service is begun in setup().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added needs-review Needs human review bugfix Pull request that fixes bugs labels Jun 3, 2026
@jamesarich jamesarich requested review from garthvh and thebentern June 3, 2026 17:38
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves BLE Battery Service (BAS, 0x180F / 0x2A19) behavior so the Battery Level characteristic is always readable with a valid value and updates are pushed to connected subscribers, driven by the power subsystem rather than only by phone/GPS/UI refresh paths.

Changes:

  • Mirror battery percentage updates from Power::readPowerStatus() into updateBatteryLevel() on change.
  • ESP32 (NimBLE): seed the BAS characteristic during service setup; cache the characteristic value on every update; notify only when connected; clear the global characteristic pointer on deinit to avoid UAF.
  • nRF52: guard BAS writes so periodic updates don’t run before the Bluetooth instance is initialized.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
src/Power.cpp Pushes battery % changes into the platform BLE updateBatteryLevel() hook from the power read loop.
src/platform/nrf52/NRF52Bluetooth.cpp Prevents BAS writes before the nRF52 Bluetooth stack/service is initialized.
src/nimble/NimbleBluetooth.cpp Seeds and updates the BAS Battery Level characteristic correctly; clears dangling pointers on deinit.

Comment thread src/Power.cpp
Comment on lines +967 to +975
// Mirror battery level to the BLE Battery Service (0x2A19), pushing only on change.
if (hasBattery == OptTrue) {
static int lastBleBatteryPercent = -1;
int pct = powerStatus2.getBatteryChargePercent();
if (pct != lastBleBatteryPercent) {
lastBleBatteryPercent = pct;
updateBatteryLevel(pct);
}
}
Comment on lines +869 to +873
if (!config.bluetooth.enabled || !BatteryCharacteristic)
return;

// Cache the value so a READ works without a subscriber; notify only when connected.
BatteryCharacteristic->setValue(&level, 1);
Comment on lines 355 to +359
/// Given a level between 0-100, update the BLE attribute
void updateBatteryLevel(uint8_t level)
{
blebas.write(level);
if (nrf52Bluetooth) // skip until the Battery Service has been begun in setup()
blebas.write(level);
Comment on lines +860 to +862
// Seed an initial 0-100 level so an early read of 0x2A19 returns a valid value.
uint8_t initialLevel = (powerStatus && powerStatus->getHasBattery()) ? powerStatus->getBatteryChargePercent() : 0;
BatteryCharacteristic->setValue(&initialLevel, 1);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Pull request that fixes bugs needs-review Needs human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants