Bluetooth headphone battery support
Status: The tray Bluetooth integration is enabled by default. Battery discovery has been validated on the Baseus Bass BS2 Lite and Xiaomi J18; the BLE GATT path has not been tested on real hardware. Do not treat Bluetooth support as universal. To disable it in a local build, run
cargo run --release --package volty-tray --no-default-features.
Bluetooth scope
Volty reads battery percentages from connected Bluetooth devices when Windows
supplies the System.Devices.BatteryLife property. It checks both the
connected-device properties and present Bluetooth PnP device nodes, since
Windows can expose that property through either surface. For connected
Hands-Free Profile (HFP) devices, it also checks the RFCOMM service metadata
for the same property without opening an RFCOMM connection. It reads connected
Windows-paired Bluetooth Low Energy (BLE) devices through the standardized GATT
Battery Service and Battery Level characteristic:
- Battery Service:
0x180F - Battery Level characteristic:
0x2A19
The Windows property and GATT backend do not infer percentages from advertising data. Only values from 0 through 100 are accepted; the Windows property’s unknown sentinel (101) is treated as unavailable. Charging state remains unknown because neither source reports it.
Volty does not initiate pairing, connect paired devices, or perform broad
Bluetooth scans. A paired device whose Windows connection status is not
Connected is skipped, even if Windows still exposes a stale battery
property for its PnP device node. For PnP battery properties on child endpoints,
Volty aggregates Bluetooth connection flags across the node and its Bluetooth
ancestors, and correlates the node’s Bluetooth address with connected WinRT
Classic devices when Windows does not publish connection flags. This avoids
treating a connected headset’s Hands-Free endpoint as disconnected. Each
asynchronous Windows Runtime operation has a five-second timeout, and the
paired-device pass has a conservative 30-second overall budget. If that budget
is exhausted, already collected readings are retained, remaining paired
devices are skipped, and an explicit warning is recorded. A device without an
available Windows battery property or GATT service is skipped; failures are
logged without suppressing valid readings from other devices or the HID
backend. If Windows reports multiple Battery Services or Battery Level
characteristics for one device, Volty reports an ambiguity rather than
inventing left/right/case labels or combining the values.
Classic Bluetooth Hands-Free Profile (HFP) battery indications and manufacturer-specific protocols are separate follow-up work. They are not interchangeable with GATT. Volty does not open a second HFP/RFCOMM connection: Windows owns the active audio profile, and a competing profile connection may fail or interfere with audio. A headphone can therefore show a battery percentage in Windows while Volty has no non-invasive battery source for it. Devices that expose neither the Windows battery property nor the standard GATT service need a separately implemented protocol.
Validated hardware
| Device | Transport | Result |
|---|---|---|
| Baseus Bass BS2 Lite | Bluetooth Classic (HFP/A2DP) | 100%, Windows PnP battery property |
| Xiaomi J18 headphones | Bluetooth (profile not recorded) | 100%; exact Windows property source not recorded |
The reading comes from the headset’s Hands-Free AG PnP node through the
System.Devices.BatteryLife property, exposed to SetupAPI as
DEVPKEY_Device_BatteryLevel
({104EA319-6EE2-4701-BD47-8DDBF425BBE5}, 2). For this headset, neither the
WinRT connected-device property nor the HFP RFCOMM service metadata returned a
value, so the SetupAPI PnP phase is required in practice. An earlier build read
the wrong property key
({49cd1f76-5626-4B17-A4E8-18B4AA1A2213}, 10), which silently produced no
readings. The corrected key was verified on live hardware.
After reconnecting, Windows may take some time to republish the headset’s battery property. An initial scan can therefore find no reading; allow later polls to run before treating the device as unsupported.
Windows reports no charging state for this source, so the headset is shown with a percentage and unknown charging state. This validation does not cover BLE GATT, disconnect/reconnect behavior, or low-battery notifications.
Current architecture and integration boundary
The HID path enumerates interfaces, resolves each against
SUPPORTED_HID_DEVICES, and calls a BatteryProtocol using a HidInterfaceInfo and
device index. That contract is specifically for HID; a Bluetooth GATT device
should not be forced into it. Both sources meet at the BatteryProvider trait
in volty-battery, which yields a stable DeviceId, a DeviceLabel and a
BatteryStatus. See protocol.rs,
provider.rs and the polling flow
in app.rs.
The tray accepts multiple device rows, computes the lowest reported percentage for its icon, and sends per-device low-battery notifications.
The implemented structure keeps the backends separate:
- Keep
BatteryProtocol,HidBatteryDevice, andSUPPORTED_HID_DEVICESHID-specific. volty-batteryowns the protocol-neutral normalized reading types and the provider trait.volty-hidretains HID++ decoding and returns the shared model.volty-bluetoothreads the Windows battery-life property and owns GATT discovery and battery reads, exposed asBluetoothProvider. Keep Windows Bluetooth transport out ofvolty-hidand tray rendering.hidapionly handles HID and is not a GATT transport.- The tray polling worker polls every provider and merges successful
readings. Device ids carry the source prefix (
hid:,bluetooth:), so HID and Bluetooth notification state cannot collide.
Bluetooth stays behind the bluetooth feature, enabled by default in the tray.
Its three probes (battery-life property, SetupAPI, paired GATT) are merged by
opaque device id under a shared budget; that is a discovery strategy, not a
protocol. The validated PnP reading on the Baseus headset meets the criteria
for a trustworthy Windows reading with a stable device id and motivates default
enablement. Two headset models are now hardware-confirmed; broader coverage
remains limited, especially hardware validation of the BLE GATT path. Build with
--no-default-features to omit Bluetooth.
Runtime behavior
Device discovery
- Volty checks connected Bluetooth devices and present Bluetooth PnP nodes for the Windows battery-life property, and enumerates Windows-paired Bluetooth LE devices. PnP nodes are accepted only when Windows reports a connected flag or their Bluetooth address matches a connected Classic device. It does not pair devices or perform a broad Bluetooth discovery scan.
- A device must expose GATT Battery Service
0x180Fand Battery Level characteristic0x2A19; classic HFP and vendor-specific battery reports are not read.
Battery model
- The protocol-neutral reading types live in
volty-battery; HID++ decoding remains involty-hid. - A valid one-byte Battery Level value from 0 through 100 is reported as a percentage. Charging state remains unknown because this characteristic does not provide that information.
GATT reads
volty-bluetoothuses Windows Runtime Bluetooth/GATT APIs and reads GATT only while Windows reports the device as connected. Every asynchronous operation has a five-second timeout and is cancelled if it expires. The paired-device pass also stops at its 30-second overall budget, retaining partial readings and warning when remaining devices are skipped.- Missing services are skipped. Read errors are reported per device and do not suppress valid readings from other devices.
- Multiple matching services or characteristics are reported as ambiguous; values are not combined and components are not assigned invented labels.
Polling and tray
- When the
bluetoothfeature is enabled (the tray default), the tray poller queries HID and Bluetooth independently. It preserves successful readings if the other source fails, and keeps low-battery notification state keyed by source-specific device identity. Builds with--no-default-featurespoll HID only. - Bluetooth names are shown without HID slot numbers. The existing tray list and lowest-percentage icon behavior are intended to apply to both sources.
Diagnostics and privacy
- The Diagnose report identifies whether the Bluetooth integration is enabled in this build. Bluetooth scan counts and reading status are included when the feature is enabled.
- Discover Devices Now triggers an immediate HID poll; it also polls Bluetooth when the feature is enabled. Trace logs explain HID interface matches or skips and Bluetooth selector, Windows battery-property, and GATT Battery Service match results.
- Opaque Windows Bluetooth device IDs and addresses are retained only internally for stable notification identity; they are not written to logs or reports. HID interface paths are also excluded from discovery logs.
Hardware capture and replay
When the tray is built with the bluetooth feature (enabled by default),
volty capture and the Diagnose action add Bluetooth records to the same
.volty.jsonl file used for HID captures:
bluetooth_deviceidentifies each observed device with a capture-localbt-Nid and records whether Windows exposed it as classic, PnP, or BLE.bluetooth_propertykeeps the raw Windows property byte, property name, and type before validation.bluetooth_gattkeeps the raw Battery Level payload plus the service and characteristic UUIDs.readingrecords the normalized result withdevice_indexset tonull.
Bluetooth addresses and opaque Windows device ids are never written. Friendly
names follow the capture privacy profile and are omitted by --strict.
volty capture replay works without a Bluetooth backend and without hardware:
it re-runs the recorded property or GATT bytes through the same decoders used
by the tray and fails if the normalized reading does not match. A capture made
by a Bluetooth-enabled build can therefore be verified in a
--no-default-features build.
Replay reproduces what Windows exposed on the captured machine; it does not
prove support for other headsets or transports.
See Hardware capture for the record format, privacy rules, and the capture submission workflow.
Verification and hardware validation
Automated tests
- Battery Level decoding accepts valid values from 0 through 100 and rejects malformed or out-of-range values.
- Existing HID++ parsing, polling, notification, and tray behavior remains unchanged.
- Bluetooth capture mapping replaces opaque device ids with capture-local
bt-Nids, honors both privacy profiles, and never serializes a Windows device id or Bluetooth address. - Property and GATT replay re-decode synthetic evidence to the recorded reading, reject mismatches and unknown sentinels, and require a matching device record.
Validated so far:
- The Baseus Bass BS2 Lite appears through the Windows PnP battery property with a valid percentage and is aggregated under the connected Classic device’s canonical identity.
- Xiaomi J18 headphones also report 100%. Their exact transport profile and Windows property source were not recorded.
- The default build records the headset as
bt-1with a rawbluetooth_propertyvalue and a reading whosedevice_indexisnull.volty capture replayreproduces the 100% reading in default builds and builds with--no-default-features. - The generated capture contains no Bluetooth address, opaque PnP instance id,
or device path; only the capture-local
bt-1id and the privacy-filtered friendly name appear. - Diagnose output includes Bluetooth scan counts and reading status without Bluetooth addresses or device paths.
Still required:
- A live BLE GATT capture from a headset exposing Battery Service, replayed without hardware.
- A tested BLE headset exposing Battery Service appears in the device list with its reported percentage.
- Removing or disconnecting a validated headset does not hang the UI or stop HID monitoring; it is found again after reconnect/refresh.
- With both a supported HID peripheral and headset attached, both appear and the tray icon uses the lowest available percentage.
- Low-battery notification behavior works for the headset without duplicate alerts across polls.
- A second Bluetooth Classic headset confirms that the PnP property path is not specific to one device.
Follow-up scope
Only pursue HFP or vendor-specific support after collecting concrete target headset models and confirming what Windows exposes for them. Any direct HFP implementation must be explicitly tested for interaction with the Windows audio profile before being enabled during routine polling. Add each follow-up as a separate backend with its own tests and hardware validation; do not widen the generic BLE claim to imply support for every Bluetooth headset.