feat(core): Improve battery reporting
Implement a new battery level reporting system with support of:
- Reporting battery level over USB HID
- Reporting battery levels for split keyboard over BLE
- Customizable BLE Battery Service Characteristic Presentation Format description and User Format Descriptor.
- Reporting the lowest battery level as the main battery level for split keyboards.
This PR fully works feature wise and is ready for review. I'm primartly looking for feedback and opinions on the design/architecture and variable/devicetree naming. In particular I'm not sure if my Kconfig changes make sense and I'm not sure if zmk,battery-reporting should be made more generic and possibly used by other parts of the codebase in the future.
The battery-reporting-test snippet is left in the PR for now to make my testing easier and show how the configuration looks like. It will be removed before this PR is marked as ready for merge.
I'll write the documentation after the code changes are reviewed and mostly complete to avoid potentially having to rewrite it multiple times.
Closes #2892
PR check-list
- [ ] Branch has a clean commit history
- [ ] Additional tests are included, if changing behaviors/core code that is testable.
- [x] Proper Copyright + License headers added to applicable files (Generally, we stick to "The ZMK Contributors" for copyrights to help avoid churn when files get edited)
- [ ] Pre-commit used to check formatting of files, commit messages, etc.
- [ ] Includes any necessary documentation changes.
Main use cases
As a user with a wireless split keyboard:
- With the keyboard connected to hosts via BLE, I want to be able to see the lowest battery level of my keyboard directly in the OS battery indicator without third party software, similar to how wireless earbuds work.
- With the keyboard connected to hosts via BLE, I want to be able to see individual battery levels of each part of the keyboard with the help of third party software.
As a user with a wireless split keyboard plus a ZMK dongle:
- Same as previous use case for BLE.
- With the dongle connected to hosts via USB, I want to have the ability to see the lowest battery level of my keyboard parts.
- I want to be able to hide the battery level of the dongle.
HID report compatibility
Linux kernel 5.13 and up supports the HID report used in this PR and will create a power_supply device for it. It will show up in /sys/class/power_supply as hid-<iSerial>-battery. It will then be picked up by UPower and subsequently desktop environments.
Windows does not read and parse the HID report used in this PR. However, it's possible to open and read the report without admin privileges, so it should be relatively easy to create a third party tool that reads the battery level and shows it in the system tray. See below for POC script.
Implementation notes
The feature implemented in this PR depends on stable source number of each peripheral part after they are paired.
I specifically went around of endpoints.c for reporting battery level over HID, as the keyboard should always be able to report battery level over USB HID regardless of which endpoint is currently active.
The battery HID report is not present in the HID descriptor for BLE, otherwise it would show up twice on Linux (one from HID and another from BAS).
For non-split and split peripherals, the battery level is reported directly in battery.c. For split central CONFIG_BT_BAS is disabled and everything is handled in battery_reporting.c.
Testing
I have tried compiling with (almost) every combination of the related Kconfig options with a script and they all build fine.
I have tested on two nRF52840 based controller configured as a split keyboard connected to Linux and Windows hosts.
I don't own any Apple devices so I can't test on them.
Screenshot of the Power page in GNOME Settings. First entry is from USB HID. Second entry is from BLE.
POC for Windows
Proof of concept script for reading battery level over HID on Windows. Save as .ps1 and run in powershell.
Details
# Note: admin privilege is not required
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
Write-Host "Running with administrator privilege: $isAdmin`n"
# Download HidLibrary at https://www.nuget.org/packages/hidlibrary/3.3.40
# Click "Download package" on the right side and extract the .nupkg file (it's a zip archive)
# Use the "net45" version of the library
# Add-Type -Path "Z:\Path\To\HidLibrary\lib\net45\HidLibrary.dll"
Add-Type -Path ".\HidLibrary.dll"
$HID_VID = 0x1D50
$HID_PID = 0x615E
$HID_USAGE_PAGE = 0x84
$HID_USAGE = 0x05
$hidDevices = [HidLibrary.HidDevices]::Enumerate($HID_VID, $HID_PID)
if (($hidDevices -eq $null) -or ($hidDevices.Count -eq 0)) {
Write-Error "No HID devices found"
exit
}
$selectedDevice = $null
foreach ($device in $hidDevices) {
$capabilities = $device.Capabilities
Write-Host "Found device with UsagePage: $($capabilities.UsagePage), Usage: $($capabilities.Usage)"
if ($capabilities.UsagePage -eq $HID_USAGE_PAGE -and $capabilities.Usage -eq $HID_USAGE) {
$selectedDevice = $device
Write-Host "Matching device found: $($device.DevicePath)"
Write-Host " Description: $($device.Description)"
Write-Host " Capabilities: $($device.Capabilities)"
$device.Capabilities | Format-List *
Write-Host " Attributes: $($device.Attributes)"
$device.Attributes | Format-List *
}
}
if ($selectedDevice -eq $null) {
Write-Error "No matching HID collection found"
exit
}
$hidDevice = $selectedDevice
Write-Host "`n`n`n`nOpening device: $($hidDevice.DevicePath)"
try {
if (!$hidDevice.IsOpen) {
$hidDevice.OpenDevice()
}
Write-Host "Connected to HID device. Waiting for input reports..."
while ($true) {
$report = $hidDevice.ReadReport()
if ($report -ne $null) {
$data = $report.Data
Write-Host "Report received: $($data.Length) bytes"
Write-Host "Battery Level: $($data[0])"
$report | Format-List *
}
Start-Sleep -Milliseconds 500
}
}
finally {
if ($hidDevice.IsOpen) {
$hidDevice.CloseDevice()
}
}
Hey, I am really interested in this (especially the HID reporting), so I tried it out on my split keyboard with a dongle (I switched to your fork and added CONFIG_ZMK_BATTERY_REPORTING_HID=y to my config). It seemed to work like a charm, but I am somewhat confused by the Battery-Levels that are reported (I tested it on MacOs and Linux but I will mainly include the MacOs screenshots).
Before your fork I had my keyboard setup in a way that the battery levels would be displayed on a screen connected to the dongle and both levels would be reported via Bluetooth. So in MacOs two battery levels for my keyboard would be reported. Now 4 levels are reported. The first one being HID and the second one I assume is the "lowest" battery level. What confuses me is that those two numbers seem to jump between values around 43 and 96 randomly and aren't consistent with either of the two levels reported via Bluetooth (which are also showed on the dongle's screen).
Is this a bug, or did I miss something in configuring it?
Thanks for testing! Nice to know macos shows battery level from HID out of the box, though seems like it's showing up as "charging" so that might need some tweaking.
Are you building locally? Can you provide your build configuration? Preferably using the zmk-config template. If you're in ZMK Discord feel free to ping me there. From your description I'm guessing you might have ZMK_BATTERY_REPORTING_SPLIT_REPORT_LOWEST_CHARGE=n and the dongle probably is using the mock battery sensor?
There are bugs in this PR (related to the macro maze) I haven't got around fixing, but those probably aren't causing the behavior you're seeing.
Thanks for the reply. I was also kinda confused, that macos shows the Battery, because my Logitech mouse does not appear..
In my config I did not configure ZMK_BATTERY_REPORTING_SPLIT_REPORT_LOWEST_CHARGE at all and since the default is y that should not be the problem I think. The mock battery sensor might be one issue, unfortunately I won't be able to verify that until Friday.
I was going to fork your PR on the weekend anyways, because I want to play around with reporting multiple battery levels over HID (from what I read this should not really be possible, but I want to try anyways). Then I will also try verifying that the mock battery sensor was the issue.
I'll ping you on Discord if I run into any other issues :+1: Thanks again.
Logitech uses a proprietary HID extension/protocol (not sure what the correct term is) called "HID++". Apple probably don't have built-in support for that.
Linux kernel do have HID++ support and will use it to get more data from Logitech devices. (Which is why for Logitech devices the power_supply device names are hidpp_battery_n, it's coming from the hidpp driver.)
I did try multiple HID battery values, Linux will only create one power_supply device and use the last battery value.
https://zmk.dev/docs/troubleshooting/building-issues#devicetree-related-issues
Check <build_folder>/zephyr/zephyr.dts and see what is the chosen zmk,battery.
Force pushed:
- Rebased to current
mainbranch. - Added "Charging" field to the battery HID report, macOS should not show the keyboard as charging anymore.
- Other fixes and cleanup
TODO:
- Move validation of
cpfinzmk,battery-reportingfrom devicetree binding to C preprocessor macro. - Add
#defines for common CPF descriptor values?
Hello! I tried your fork with my split keyboard and dongle on Linux. Everything works great, but when the halves go to sleep, the driver shows 100% charge. Is there anything that can be done about this, such as making the driver return an empty value or last actual value? Thanks for your work!
I honestly don't remember if I tried disconnecting peripheral sides but I'm expecting it to show 0% and not considered in lowest battery value.
https://github.com/zmkfirmware/zmk/blob/018d04d9aac1851c247e53e1a5059e79309515cb/app/src/split/bluetooth/central.c#L960-L965
Maybe it's not working for some reason, I'll test when I get the chance.
Hi @HeavyDutySoul . I just tested again, when a peripheral side disconnect from central it shows up as 0% as expected.
If you mean the lowest battery level is showing 100% because the dongle is at 100%, adding the following to your keymap will remove the dongle's battery level and preventing it being considered in lowest charge.
/ {
chosen {
zmk,battery-reporting = &battery_reporting;
};
battery_reporting: battery_reporting {
compatible = "zmk,battery-reporting";
dongle {
hidden;
};
left {
display-name = "Left";
cpf = <0x010D>;
};
right {
display-name = "Right";
cpf = <0x010E>;
};
};
};
I'm use HID driver in linux and lowest battery level is configured, also dongle is hide. Bat capacity when halves sleep show 100%.
Oops, I get what you mean now and fixed. Personally I prefer reporting 0% instead of the last available value but that can be made into an option if necessary.
I try last commit, HID driver report 0%, do not report actual battery capacity.
Hey, setting new_lowest_level to 0 by default leads to problems if the first battery_part is hidden. Because in that case the new_lowest_level does not get overridden with zmk_battery_state_of_charge() and will therefore always be 0 if I'm not mistaken.
Sorry about that, should be fixed now. I was not running my own code in production and it clearly shows :facepalm:
After initial troubleshooting with @Genteure on Discord (most of those troubles were my fault) and applying the snippet from https://github.com/zmkfirmware/zmk/pull/2938#issuecomment-3013458672 to my keymap, this works pretty well.
- GEIGEIGEIST Totem (actually Ergomech Totemist, but there's little difference software-wise)
- 3xSEEED XIAO
- Arch Linux BTW
- Gnome 48
Picked up by UPower, shows the lowest half battery level both in settings and Bluetooth Battery Meter extension (actually not only BT, but that's what it's called).
Screenshots
I'm using this PR on my keyboard now. I don't like that HID battery report wakes the host (linux) from sleep/suspend but I also don't know what could make it not to do that.
I'm using this PR on my keyboard now. I don't like that HID battery report wakes the host (linux) from sleep/suspend but I also don't know what could make it not to do that.
I don't think it does for me, or it just doesn't significantly influence laptop battery consumption overnight. I turn both keyboard halves off in the evening though. Maybe not convenient for everyone, but I like things to be explicitly on or off without smart timeouts and other bs to begin with, so I always did that.
For me:
- Suspend the PC
- Keyboard parts deep sleep 15 min later and disconnect from the dongle
- Dongle sends battery is now at 0%, because none of the parts are connected
- PC wakes up from HID report
It's especially a problem for this specific pair of keyboard parts because the battery switch on both of them are broken... I can't turn them off even if I want to.