Volty
Volty is a Windows system-tray battery monitor for supported HID devices and XInput gamepads, written in Rust. It reads supported Logitech HID++, standard HID battery reports, coarse XInput battery levels, and an experimental Bluetooth provider enabled by default. Bluetooth hardware validation is limited to one Classic headset; BLE GATT has not been validated on hardware.
Volty is a hobby project with limited hardware validation. Device support and battery readings are best-effort; Volty is not production-ready and is not affiliated with Logitech.
Start here
- Install the latest release
- See verified and experimental devices
- Configure Volty
- Troubleshoot or report a device
At a glance
- Windows 10/11, 64-bit x86; standalone executable, no installer.
- Battery tray icon, per-device monitoring, configurable low-battery alerts, estimated time remaining, and local history.
- Left-click the tray icon to open the local battery-history dashboard in a native WebView2 window, with a browser fallback; right-click for device stats and other actions.
- Hardware captures can be inspected and replayed locally; Volty never uploads diagnostics automatically.
- The default build monitors HID devices, XInput gamepads, and Bluetooth devices. Bluetooth remains experimental with limited hardware validation.
Battery history dashboard
The local dashboard shows recent battery levels, estimated remaining time, and
history charts. Open it by left-clicking the tray icon or run
volty dashboard; see dashboard options and details.
Dashboard data stays on your device.
Technical reference
For project architecture, protocol support, and development information, see the technical reference and its related pages in the sidebar.
Get the executable from the latest GitHub release. For project changes, see the changelog.
Install and run
Download a release
Download the latest
Volty release.
For a standard 64-bit Windows PC, choose
Volty-vX.Y.Z-x86_64-pc-windows-msvc.exe. The matching .sha256 file contains
the executable’s SHA-256 checksum. Volty is a standalone executable; no
installer or additional runtime file is needed.
Save the executable where you want to keep it, then double-click it. Volty starts in the Windows notification area. On first launch, choose whether to start Volty automatically when you sign in. This setting applies to the current Windows user, does not require administrator privileges, and can be changed later from the tray menu. If enabled, Windows starts Volty from the executable’s current location, so keep the file there.
Use the tray app
Volty monitors supported devices while it runs. The icon shows the lowest
reported battery level by default; you can pin a device or choose a numeric
percentage icon. Hover over the icon for a compact line per device, including
its name, percentage, estimated remaining time when available, and last-seen age.
A reading is marked stale after the configured stale period.
Low-battery alerts are Windows notifications. They are enabled by default and fire at 20%, 10%, and 5% once per discharge cycle. Their thresholds and repeat cooldown are configurable.
Right-click the icon for the compact menu: manage Monitored Devices, toggle Start on Boot, check for updates, open About Volty, or quit. Volty checks for updates on startup and once per day while running. When a stable GitHub release is available, Windows shows a notification and the tray item changes to Update available!; selecting it asks before downloading. You can also select Check for Updates… to check manually. Volty verifies the executable against the release’s SHA-256 file, then restarts through a temporary helper that replaces the executable at its current location. The existing Start on Boot entry keeps pointing to that same path. The destination folder must be writable by your Windows user; if an update cannot be installed, Volty restarts the previous version and reports the error. Existing installations must install the first release that includes this updater manually; later releases can be installed from the tray menu.
Show Advanced Options reveals manual discovery, tray customization, configuration and log access, hardware capture, diagnostics, and reset. The advanced-menu choice is saved locally; Reset Volty returns to the compact menu.
Unchecking a device in Monitored Devices excludes it from tray status, icon percentage, low-battery alerts, and battery history. Unlisted devices are monitored by default.
Tray display
Under Show Advanced Options → Tray Display, choose:
- Battery (default): a battery glyph filled to the percentage.
- Percentage: the number drawn using a built-in pixel font. The icon shows the number without a percent sign; unknown levels use the neutral battery icon.
The same submenu can pin the icon to a discovered device. Lowest battery restores the default. If a pinned device is missing or unchecked, Volty falls back to the lowest monitored percentage and reports Pinned device unavailable. The pin remains saved and takes effect again if the device returns.
Display mode and pin are stored in
%LOCALAPPDATA%\Volty\device-state.sqlite3, not in config.toml.
Reset Volty
Reset Volty asks for confirmation, deletes %APPDATA%\Volty\config.toml and
%LOCALAPPDATA%\Volty\device-state.sqlite3, disables Start on Boot, and
clears the first-run startup preference. Volty then reinitializes with fresh
defaults, including the compact tray menu. The startup prompt appears again on
the next launch. Logs and the battery history database are retained.
Configuration and local data
Volty stores settings per Windows user and does not require administrator
privileges. Use Show Advanced Options → Open Configuration to create or
open %APPDATA%\Volty\config.toml. Volty watches the file for changes, and
Discover Devices Now also reloads it. If a new configuration is invalid,
Volty reports the error and keeps the last valid settings active.
low_battery_thresholds = [20, 10, 5] # 1-100 percent; normalized and deduplicated
low_battery_cooldown_minutes = 120 # repeat interval after the lowest threshold; 0 disables repeats
poll_interval_seconds = 120 # 10-3600 seconds after a successful reading
retry_interval_seconds = 15 # 5-300 seconds after an error
battery_eta_window_minutes = 10 # 1-180 minutes of discharge history
stale_after_minutes = 15 # 1-1440 minutes without a fresh reading before marking stale
notifications_enabled = true
log_level = "info" # error, warn, info, debug, or trace
# Optional tray color-map overrides. Thresholds are inclusive upper bounds.
# red_max_percent must be below yellow_max_percent; colors use #RRGGBB.
# [battery_color_map]
# red_max_percent = 15
# yellow_max_percent = 30
# red = "#d23030"
# yellow = "#ebbe23"
# green = "#2daa4b"
The old singular low_battery_threshold key is not accepted; replace it with
low_battery_thresholds. Thresholds are constrained to 1–100, deduplicated,
and sorted from highest to lowest. Each threshold fires once per discharge
cycle. After the lowest threshold fires, the alert can repeat after each
configured cooldown; 0 disables repeats.
The default tray colors are green above 30%, yellow from 16–30%, and red at 15% or below. Red and yellow cutoffs can be overridden (red: 0–99, yellow: 1–100) and must remain ordered. Omitted colors and thresholds retain their defaults.
Battery estimates and stale readings
Battery ETA appears after a full configured history window of discharging readings. It is hidden while charging or when the recent trend is unreliable. Volty also keeps a per-device long-run discharge rate based only on time the app was running and watching the device. After restart this may provide an ETA once at least an hour and two percentage points of discharge have been observed; the recent-window estimate takes over when available.
Tray labels show last-seen age after one minute and mark a reading stale after
stale_after_minutes without a fresh reading. Labels refresh about once a
minute, so a sleeping or disconnected device is not presented as live.
Logs
Choose Show Advanced Options → Open Log File to open the current daily log
under %APPDATA%\Volty. Files are named volty-YYYY-MM-DD.log, rotate at 5 MiB
with one .1 backup, and are retained for seven days. The default info level
records lifecycle events and errors; debug adds successful battery readings;
trace adds low-level details. Changes to log_level take effect when the
configuration reloads.
Battery history
Monitored-device samples are stored locally in
%LOCALAPPDATA%\Volty\battery-history.sqlite3. Samples are appended when a
reading changes or at least five minutes have passed since its previous sample.
Entries older than 30 days are pruned automatically. Recording failures are
logged and do not interrupt polling. The database is never uploaded.
The native dashboard can pause or resume history recording for one device without changing whether Volty monitors it. Its per-device history cleanup removes stored samples; Forget all Volty data also clears that device’s Volty-owned monitoring, notification, rate, and pinned-device preferences.
Run volty history while the tray app is running to summarize the database; it
does not open devices.
volty history
volty history --days 30
volty history --device receiver
volty history --csv > battery-history.csv
The default view covers seven days and reports sample count, first and last
timestamps (UTC), latest/minimum/maximum percentage, and an estimated discharge
rate when enough decreasing readings are available. --device filters by a
case-insensitive substring of device identity or name; --database <path> reads
a different database. CSV columns are
timestamp_utc,identity,name,percent,charging,voltage_mv; unreported percent or
voltage values are empty.
Troubleshooting and diagnostics
Diagnose and report an issue
Choose Show Advanced Options → Diagnose / Report an Issue to open the
Windows Diagnose window. It shows recent app events, active configuration,
Bluetooth read status when that feature is enabled, and HID interfaces visible
to hidapi. It saves a text report under %APPDATA%\Volty\reports and a
machine-readable capture under %APPDATA%\Volty\captures.
The report includes HID IDs, usage information, and available manufacturer or product names, but omits HID paths, serial numbers, and Bluetooth IDs or addresses. Review both files before sharing; names and log messages may still identify your setup. Nothing is uploaded automatically. Copy Log copies the report, and Open Issue starts a GitHub issue draft.
To save only a capture, choose Show Advanced Options → Save Hardware Capture. The tray status shows its file name and a Windows notification opens the file. See Hardware captures for its contents, privacy properties, review steps, and submission workflow.
Battery history dashboard
Generate a self-contained dashboard from the locally stored battery history:
volty dashboard
volty dashboard --days 14
volty dashboard --window --days 14
volty dashboard --output .\battery-dashboard.html --no-open
volty dashboard --database .\battery-history.sqlite3
By default, the dashboard covers the last seven days, writes
%TEMP%\volty-dashboard.html, and opens it in the default browser. Use
--no-open to only write the file, or --window to show it in a native
WebView2 window. The Microsoft WebView2 Runtime is required for the native
window; if it is unavailable, Volty writes the HTML file and opens it in the
default browser. Left-click the Volty tray icon to open the same native window.
Only one native dashboard window can be open at a time; opening it again brings
the existing window to the foreground and refreshes its data.
The native window supports filtering by device, previewing age- and inactivity-
based history cleanup, forgetting one device’s history or Volty preferences,
and pausing or resuming future history recording without changing monitoring.
It can also list and delete managed capture and diagnostic report files.
The generated HTML file and any browser fallback are read-only. Raw HID traces
created with volty hid sniff --output <path> are stored at the selected path,
may contain button or keyboard input, and are not scanned or deleted by the
dashboard. All data stays local; nothing is uploaded.
Command-line capture
From a PowerShell prompt, use the release executable or run through Cargo from the repository root:
volty capture
volty capture --strict
volty capture --output .\capture.volty.jsonl
volty capture inspect .\capture.volty.jsonl
volty capture replay .\capture.volty.jsonl
--strict omits model strings. inspect validates a saved capture and
replay checks whether its recorded HID or Bluetooth evidence can be decoded
again without hardware. Bluetooth evidence is recorded when the bluetooth
feature is enabled (the default for the tray); builds made with
--no-default-features can still inspect and replay Bluetooth records.
HID diagnostics
The executable has explicit, on-demand HID++ diagnostics. They run before the
tray app’s single-instance check; launching without a hid subcommand starts
the normal tray app.
volty hid inspect
volty hid inspect --vid 0x046d --pid 0xc547 --usage-page 0xff00 --usage 0x0002
volty hid features --vid 0x046d --pid 0xc547 --usage-page 0xff00 --usage 0x0002 --device-index 0x01
volty hid sniff --vid 0x04b4 --pid 0x2412 --interface 2 --duration-seconds 15 --output .\vader4pro.hidtrace.jsonl --include-input
volty hid trace .\vader4pro.hidtrace.jsonl
inspect lists matching interface IDs, usage page/usage, interface number,
bus, product name, and hidapi path. Paths appear only in this explicit command;
serial numbers are never printed. Selectors include --vid, --pid,
--usage-page, --usage, --interface, and --path. Commands opening a device
require exactly one interface match. Use inspect to narrow an ambiguous
selector. The USB interface number is not the HID++ --device-index, which
addresses a paired device behind a receiver.
features queries Root/FeatureSet and lists logical feature IDs with each
device’s dynamically assigned runtime index, type, and version. Do not copy a
runtime index from one device to another. volty hid raw exists for exceptional
diagnostics but requires --allow-state-changing because arbitrary raw requests
cannot safely be classified as read-only. Prefer inspect and features.
Review diagnostic output before sharing it; interface paths can identify local
devices.
sniff is a separate, passive report-level trace for protocol investigation.
It requires --include-input because raw reports can contain buttons or
keystrokes, runs for at most 120 seconds, and refuses to overwrite a file.
trace validates the JSONL file and summarizes its report IDs and sizes. The
trace omits interface paths and serial numbers but must still be reviewed
before sharing. It sees input reports available to Volty, not USB traffic or
output/feature requests made by another application; see
raw HID report traces.
For a worked Logitech example, see Adding a Logitech HID++ device. For a battery support report, use the Hardware report issue template and attach a reviewed capture.
Device support
Volty supports Logitech HID++ 2.0 interfaces, standard HID battery reports, and XInput gamepad battery levels on Windows 10/11. Listed devices have been tested on hardware; replayable captures are also confirmed where the backend provides them. Other devices that use supported protocols may work on a best-effort basis but are not verified.
| Device or family | Connection | Battery backend | Tested functionality | Status |
|---|---|---|---|---|
| Logitech G502 X Superlight RGB | USB cable or Lightspeed receiver | Logitech HID++ 2.0 Unified Battery (0x1004) | Percentage, charging/discharging state, HID++ capture and offline replay | Verified |
| Logitech G502 X PLUS (slot 1) | Not recorded | HID battery provider (specific report not recorded) | 43%; estimated ETA of 1d 16h shown in tray | Verified; live tray observation |
Logitech HID++ devices exposing only Battery Voltage (0x1001) | Any HID++ 2.0 transport | Battery Voltage fallback | Estimated percentage from millivolts; raw voltage retained and replayed | Experimental; synthetic tests only, not hardware-validated |
| Baseus Bass BS2 Lite | Bluetooth Classic (HFP/A2DP) | Windows PnP battery property (DEVPKEY_Device_BatteryLevel) | Percentage, property capture and offline replay; charging state unavailable | Verified; Bluetooth enabled by default |
| Xiaomi J18 headphones | Bluetooth (profile not recorded) | Windows Bluetooth battery discovery (exact property not recorded) | Battery percentage observed; capture/replay not recorded | Verified; Bluetooth enabled by default |
| Keychron K8 Pro | USB cable or Bluetooth | None exposed to Windows | No standard HID battery, BLE GATT Battery Service, or usable vendor command | Not supported |
Flydigi controllers on the vendor protocol (VID 0x37d7, e.g. Vader 5 Pro) | USB cable, vendor HID interface | Flydigi 5A A5 01 info report (state and 20 % level step in byte 11, per SDL’s open-source driver) | Percentage in 20 % steps, charging/charged state | Experimental; synthetic tests only, not hardware-validated |
| Flydigi Vader 4 Pro (DInput mode) | USB cable or 2.4 GHz receiver | Candidate battery byte in the 05 EC device-info reply on interface 2 (0xffa0) | Reply observed, but its battery byte was not correlated with charge state | Investigating; do not treat the field as a reading yet |
| Flydigi Vader 4 Pro (XInput mode) | USB cable | Flydigi XInput vendor reply A5 10 | 32-byte report observed on USB; battery byte 0x02 is approximately 40%, discharging | Investigated; no live Volty transport; integration work stopped |
| Xbox One wireless controller | Xbox Wireless Adapter | Windows XInput XInputGetBatteryInformation | Alkaline/NiMH buckets mapped to marked percentage estimates (empty 0%, low 20%, medium 50%, full 90%); no charging state | Verified by XInput provider hardware smoke test; estimates drive the tray icon/color but not ETA, history, or numeric low-battery alerts |
| Generic HID battery devices | Any HID transport | Standard HID battery usages | Percentage, charging state when available, layout capture and offline replay | Experimental; no named device verified |
Verified means a reading was observed on real hardware. Capture/replay confirmation is listed separately when tested. Experimental means the code path is implemented and covered by synthetic fixtures, but no named hardware has been verified. Investigating means current host-readable interfaces did not expose a usable battery reading; it does not rule out an undocumented device-specific protocol. Not supported means testing found no non-invasive host-readable battery source.
Generic HID support covers interfaces whose report descriptor declares a standard battery level (Generic Device Controls Battery Strength or the Battery System page). A device exposing battery only through a vendor-defined report is not detected unless its protocol is implemented. Bluetooth support is enabled in default builds; the Baseus Bass BS2 Lite and Xiaomi J18 have been validated for battery discovery, while BLE GATT has not been tested on real hardware. See Bluetooth headphone battery support.
For a new device, collect and review a hardware capture, then open a Hardware report. Describe the connection, expected behavior, and what Volty showed. See Hardware captures for privacy and submission guidance. For raw HID protocol work, use the separate opt-in HID report trace; it does not record USB traffic sent by another application.
The Vader 4 Pro investigation findings document
hardware observations and Windows transport limits. In brief, the A5 10
reply and battery byte were confirmed in USB captures, but the normal XInput
and HID APIs do not expose the extended bytes. Volty has no live reader, and
integration work is stopped.
The independent protocol analysis
documents the same A5 10 reply layout, while its Space Station 4 analysis
identifies the XInput transport as Flydigi’s Xbox filter driver and
XboxComm64.dll. Local USB captures and exact observations are detailed in the
investigation findings.
The public Vader 4 Pro Reader
prototype supports DInput over cable or dongle but lists battery reporting as
unavailable. The DInput device report
identifies four interfaces as 04b4:2412. Volty’s passive 05 EC observation
did not correlate its candidate battery byte with charge state. The DInput
battery byte remains uncorrelated and is not reported as a reading.
The project’s current primary use case is the Logitech G502 X Superlight RGB. Hardware testing is limited; Volty is a hobby project and is not production-ready or affiliated with Logitech.
Flydigi Vader 4 Pro battery investigation
Decision
Stop work on integrating the Vader 4 Pro for now. Volty does not have a live battery reader for it. The XInput reply format is understood, but Windows does not expose the complete reply through the ordinary XInput or HID APIs. Reaching it requires a lower-level driver path, and the mechanism that makes the controller produce the reply is not yet understood.
No device-specific Windows service, kernel driver, or tray-provider integration
is being pursued. A pure decoder for captured A5 10 replies exists in
volty-hid, but it is not connected to a live transport.
Confirmed protocol
The controller in its normal mode enumerates as XInput device 045e:028e.
XInputGetBatteryInformation reports a wired device; Microsoft documents the
battery level as meaningful only for wireless controllers, so that API does
not provide this controller’s battery reading
(API documentation).
Space Station’s service log contains 32-byte controller reports with the Flydigi response beginning at offset 14. For example:
00-14-00-00-00-00-20-01-FC-00-14-FF-E8-FF-A5-10-69-43-8C-93-38-55-69-02-02-00-06-00-00-00-95-00
In this report, byte 14 is A5, byte 15 is response ID 10, and byte 23 is
the packed battery value. 0x02 was observed and represents two of five 20%
steps (approximately 40%), discharging. The high nibble encodes state (0
discharging, 1 charging, 2 charged); the low nibble is a level from zero to
five. A zero value was observed at connection and should be treated as
not-ready, not empty. The layout is also described in the
independent protocol analysis.
USB captures
USBPcap captures made on 2026-11-01 confirmed that the extended reports travel
on the USB bus as 32-byte interrupt-IN (0x81) transfers. The gamepad data is
at the start of each report and the Flydigi response is at offset 14. The
captures contained no A5 command going out from the host.
During the capture with Space Station active, its traffic included interrupt
OUT (0x05) rumble packets:
00 08 00 AB BA 00 00 00
00 08 00 BA AB 00 00 00
00 08 00 AB BA 00 00 00
00 08 00 00 00 00 00 00
Those packets correspond to XInput motor values 0xAB and 0xBA. The info
replies appeared after controller re-initialization activity, including
descriptor reads, SET_CONFIGURATION, XInput capability reads, and LED
packets. This is a timing correlation, not proof of the trigger; no host-side
A5 command was visible.
A separate capture with Space Station stopped sent the same rumble sequence
three times using XInputSetState. It produced the rumble OUT packets but no
A5 replies. Therefore, the rumble sequence alone is not sufficient to trigger
the response.
Windows access limits
- The regular HID
IG_00path yielded 14-byte reports. - XInput and the
xusb22IOCTL_XUSB_GET_STATEexposed standard gamepad state, not the bytes after the XInput payload. - Static inspection of Flydigi’s
XboxComm64.dllidentified a filter-manager port (\XboxFilterPort) for receiving messages andDeviceIoControlcalls on\\.\XboxFilterfor writes/readiness. - From an unelevated process,
\\.\XboxFiltercould be opened, but its tested IOCTLs failed withERROR_GEN_FAILURE; connecting to\XboxFilterPortfailed withE_ACCESSDENIED. - USBPcap can capture the raw USB transfer with its capture driver active, but it is a separate driver dependency and an elevated capture workflow, not a shipped Volty reader.
Thus, no zero-dependency user-mode route to the extended report was found. A custom kernel adapter could potentially expose the raw report, but would need a Windows driver project, installation/elevation, and signed distribution. That would be a substantial and higher-risk addition, not a lightweight, dependency-free user-mode adapter.
Capture artifacts and scope
The captures remain local and are not included in the repository:
C:\Users\phili\vader-pcap-123917\— Space Station active; contains the extended reports.C:\Users\phili\vader-pcap-125129-magic\— Space Station stopped; rumble control capture with noA5replies.
This investigation did not validate the DInput battery candidate, determine
what triggers the A5 response, or implement a live reader. Revisit only if a
safe, maintainable Windows transport becomes available and Vader 4 Pro support
is prioritized again.
Hardware capture
A hardware capture is a local, machine-readable record of what Volty observed from a HID device or a Bluetooth battery source. It separates evidence from expectations: a bug report can say what a device should show, while the capture contains the interface or device metadata, protocol frames, raw property values, extracted values, and normalized reading that Volty actually saw. The same records can be replayed locally as regression fixtures for protocol and decoder tests.
Captures are read-only evidence. Volty does not upload them, and creating one does not change device settings, pairing, or configuration. The current primary use case is the Logitech G502 X Superlight RGB; Bluetooth recording is enabled by default in the tray. Capture support overall is still experimental with limited hardware validation.
What is recorded
A capture can contain these kinds of records:
- Interface metadata: HID details such as vendor and product IDs, bus type, usage page and usage, and interface number.
- Backend match: which catalog device and protocol backend matched the interface, and whether resolution succeeded or was ambiguous.
- HID++ feature table: logical feature IDs with the runtime indexes, types, and versions reported by the device.
- HID++ exchanges: raw request and response frames, limited to an allowlist of read-only queries such as feature discovery and battery status.
- Generic HID layout: battery-related report-descriptor capabilities, the values extracted from them, and the charging or discharging state bits, not the full input report.
- Bluetooth devices: a capture-local
bt-Nidentifier, the Windows surface that exposed the device (classic, PnP, or BLE), and the privacy-filtered friendly name. - Bluetooth property evidence: the raw Windows battery-property byte before range validation, together with the property name and type.
- Bluetooth GATT evidence: the raw GATT Battery Level payload before length or range validation, together with the service and characteristic UUIDs.
- Normalized readings: the source label, reported level when the device truly reports a percentage, charging or full state, unknown state, and timestamp.
- Events and markers: capture start and stop, discovery or read errors, and annotations attached to the capture.
HID++ captures are allowlisted to read-only queries. The exporter does not send state-changing requests just to enrich a capture, and it does not record input, button, or keystroke traffic. Bluetooth recording only reads Windows device properties and the standard GATT Battery Service; it does not pair devices, open an HFP/RFCOMM connection, or perform a broad Bluetooth scan.
What is deliberately not recorded
A capture must not contain any of the following:
- Windows device paths or raw interface paths
- Device serial numbers
- Windows usernames or computer names
- Bluetooth addresses, pairing keys, or opaque Windows Bluetooth device ids
- Keystrokes, button events, or other user input
- Full generic HID input reports; only battery-related descriptor details and extracted values are kept
- Custom HID++ device names, which are excluded by default
The capture file is created locally and is never uploaded automatically. It records observations only; it does not change device settings or run commands.
Generate a capture from the tray
- Right-click the Volty tray icon, select Show Advanced Options, then choose Save Hardware Capture for a capture alone or Diagnose / Report an Issue for a text report plus a capture.
- Volty saves the timestamped
.volty.jsonlhardware capture under%APPDATA%\Volty\captures. The tray status line shows the saved file name, and the log records the full path. A Windows notification appears; click its body or Open Capture button to open the saved file. Diagnose also saves a text report under%APPDATA%\Volty\reportsand shows both exact paths. Bluetooth evidence is included in default tray builds; building with--no-default-featuresdisables Bluetooth recording. - Review the capture before sharing it, then attach it to a Hardware report issue if you are reporting device support or battery behavior.
If the capture file is not created, check the tray status or log for the error; for Diagnose, the window also shows the failure.
Command-line capture
volty capture writes the same read-only snapshot from a console:
volty capture
volty capture --output .\g502.volty.jsonl
volty capture --strict
volty capturewrites a timestamped file under%APPDATA%\Volty\capturesand prints its path plus a summary.--output <path>writes to a specific file instead.--strictomits model-level manufacturer, product, and Bluetooth friendly names, keeping only numeric identifiers and protocol evidence.- Bluetooth records are added only when the executable was built with the tray
bluetoothfeature. Default builds produce HID-only captures but can still replay Bluetooth records from someone else’s capture.
Inspect or replay a captured file without opening a device:
volty capture inspect .\g502.volty.jsonl
volty capture replay .\g502.volty.jsonl
inspectvalidates the schema and prints the same bounded summary as a fresh snapshot.replaydecodes recorded HID++ exchanges, generic HID layouts, and Bluetooth property or GATT evidence through the same parsers the tray uses. It checks that the normalized readings can be reproduced from the recorded evidence. State-changing requests are rejected, and an exchange that does not match its response fails replay.
Replay is local-only and does not write another capture. A replay success means Volty can reproduce the recorded decoding result, not that the device behaves that way in every firmware or connection mode.
Raw HID report traces
For protocol investigation, Volty has a separate, explicit HID report trace. Unlike a battery capture, a trace stores raw HID input bytes and can therefore contain button or keyboard input. It is local-only, has a finite duration and payload limit, and requires an explicit acknowledgment:
volty hid inspect --vid 04b4 --pid 2412
volty hid sniff --vid 04b4 --pid 2412 --interface 2 --duration-seconds 15 --output .\vader4pro.hidtrace.jsonl --include-input
volty hid trace .\vader4pro.hidtrace.jsonl
sniff opens exactly one selected HID interface, records its input reports for
1–120 seconds (10 seconds by default), and never sends a report. It refuses to
overwrite an existing output file. The trace contains numeric interface
metadata, report IDs, raw bytes, and relative timestamps; it excludes device
paths and serial numbers. Review the file before sharing it.
This is a user-mode HID report capture, not a USB bus sniffer: it does not see
output or feature requests made by another application, nor USB control
transfers. Volty’s report transport can record input, output, and feature
operations made by future Volty protocol probes, but sniff currently records
passive input reports only. Capturing Flydigi Space Station’s requests still
requires an external bus capture such as
USBPcap with Wireshark; Volty does not install
a filter driver or require administrator access for its own trace. The USBPcap
project page currently lists Windows 7, 8, and 10; it does not explicitly list
Windows 11, so verify compatibility before installing it.
Raw HID traces use the separate volty.hid_trace/v1 schema and are not accepted
as battery snapshots or uploaded automatically. A validated trace can be
summarized with volty hid trace <file> and parsed with the
volty-capture trace reader for later protocol tests.
Review before sharing
Open the .volty.jsonl file in a text editor. It is JSON Lines: one JSON object
per line, so it can be read without special tooling. Check for strings you do not
want to make public, including paths, serial numbers, usernames, computer names,
Bluetooth addresses, device names, and anything else specific to your setup.
The format is designed to exclude paths, serial numbers, usernames, computer
names, and Bluetooth addresses. Bluetooth devices are represented by
capture-local bt-N ids rather than addresses or opaque Windows device ids.
Still review the actual file before attaching it to a public issue. If you want
to omit model-level manufacturer, product, and Bluetooth friendly strings as
well, generate the capture with volty capture --strict; the tray export uses
the standard profile. Strict mode is an extra privacy option, not a substitute
for reading the file. If you are not comfortable sharing the contents, leave
the capture out of the report and describe the concern instead.
Submit a capture
Create an issue from the Hardware report template and attach the
.volty.jsonl file by dragging it into a comment on the issue after submitting
the form. Include:
- the device model
- the connection type (USB cable, Lightspeed receiver, Bluetooth, or other)
- the Volty version and Windows version
- what Volty showed and what you expected, or the reading from vendor software
GitHub issues are public. Attach only a capture you have reviewed and are comfortable sharing. Nothing is uploaded automatically by Volty.
Limitations
A capture records one system’s observed behavior. It is not a guarantee that every device of the same model behaves identically. Firmware, connection type, and Windows support can affect the reading. Bluetooth battery discovery is available in default builds and has been validated on the Baseus Bass BS2 Lite and Xiaomi J18. The BLE GATT capture path has not yet been tested on real hardware.
For the record layout and replay details, see the capture format reference.
Architecture
Volty is a modular Cargo workspace. Protocol handling is separated from tray rendering and app behavior.
volty-battery Shared BatteryStatus, DeviceId, DeviceLabel, provider trait
volty-hid HID interface matching, HID++ and generic HID backends
volty-bluetooth Windows Bluetooth battery provider (enabled by default)
volty-tray Windows tray app, XInput and HID polling, settings, history and UI
volty-capture Versioned JSONL battery captures and raw HID trace schema
Runtime flow
HID interfaces -> supported HID catalog -> HID backend --------+
XInput controller battery API -> XInput provider ---------------+-> BatteryProvider
Windows Bluetooth properties/GATT -> Bluetooth backend --------+ |
v
DeviceSession -> tray, ETA,
history and alerts
volty-battery defines transport-neutral BatteryStatus, stable DeviceId,
and BatteryProvider. Providers yield normalized readings; tray code does not
see HID++ opcodes, device indexes, interface paths, or Bluetooth transport
details.
volty-hid owns HID interface matching and protocol behavior. The single
SUPPORTED_HID_DEVICES catalog resolves each interface to no device, exactly
one device, or an ambiguity error; catalog order never selects between
overlapping matchers. Its current entries cover Logitech HID++ 2.0, Flydigi’s
vendor protocol, and generic standard HID battery reports. The generic matcher
accepts only non-vendor usage pages and probes/caches a usable battery layout.
For protocol investigation, volty-hid also exposes report-level I/O and an
opt-in recording wrapper. volty-capture stores those observations in a
separate bounded trace schema, keeping raw button/keyboard data out of regular
battery captures. The CLI’s passive trace records input reports only; it does
not sniff another process or the USB bus. See
HID report traces.
HID identity does not depend on the Windows interface path, which can change when USB re-enumerates. Stable interface keys use VID/PID, interface number, usage page/usage, and (when present) a serial-number hash. The tray’s device state, monitoring preferences, and low-battery de-duplication use a stable device identity plus HID++ slot.
volty-bluetooth is separate from the HID backend and remains experimental. It
is enabled by the tray crate’s default features; build with
--no-default-features to exclude it. See the
Bluetooth support notes.
volty-tray owns polling, configuration, local state, notifications, and tray
presentation. A new provider implements BatteryProvider; new HID protocols
implement BatteryProtocol and register a specific HidBatteryDevice in the
catalog. Its XInput source reads coarse battery levels for connected gamepads
and maps them to marked percentage estimates (empty 0%, low 20%, medium 50%,
full 90%). Providers preserve whether a percentage was reported or estimated;
estimates can drive the tray icon and color, but do not feed ETA, discharge-rate
learning, percentage history, or numeric low-battery thresholds.
The XInput player slot is the best available device identity. See
Generic HID battery support and
Adding a Logitech HID++ device.
The Windows tray crate centralizes executable-resource icon loading, native
window icons, the WebView window icon, and the dashboard favicon in
app_icon.rs. The local dashboard is organized as dashboard/model.rs for
history aggregation, dashboard/render.rs for HTML generation, and
dashboard/window.rs for the native WebView host; CLI orchestration remains in
dashboard.rs. Native dashboard data-management requests use a typed local
WebView IPC bridge and operate on Volty-owned history, preferences, and managed
diagnostic files; standalone HTML exports remain read-only.
Capture format and replay
This technical reference describes the .volty.jsonl capture format and how
Volty uses captures in tests. For instructions on creating, reviewing, and
sharing a capture, see Hardware captures.
Replay behavior
volty capture replay checks that the recorded evidence can reproduce the
normalized reading. It can replay:
- HID++ exchanges: allowlisted requests and responses through the same frame parsing and battery decoding used by the app.
- Generic HID samples: recorded descriptor capabilities and extracted values through the generic battery scaling and state mapping.
- Bluetooth values: raw Windows battery-property bytes or GATT payloads through the production decoders.
Replay validates decoding, not the physical device. A successful replay does not prove that all devices with the same model or IDs behave identically. Missing, malformed, or out-of-range evidence can cause replay to fail.
Record layout
Captures use the volty.capture/v2 schema and UTF-8 JSON Lines. Each line is a
JSON object. A header record identifies the schema version and a footer
record closes a complete capture.
Normalized battery readings retain percentage provenance in the percentage
field: {"kind":"exact","value":N} is reported by the source,
{"kind":"estimated","value":N,"method":"..."} is an estimate, and
{"kind":"unavailable"} means no percentage is available. The separate
level and raw_level fields retain the coarse bucket and raw protocol value.
Capture summaries prefix estimates with ~.
| Record | Purpose |
|---|---|
header | Capture metadata and schema version. |
interface | HID interface metadata, without paths or serial numbers. |
match | Device and backend match result for the interface. |
bluetooth_device | Capture-local Bluetooth device ID, kind, and filtered name. |
bluetooth_property | Raw Windows battery-property value. |
bluetooth_gatt | Raw GATT Battery Level payload and UUIDs. |
hidpp_features | HID++ feature table. |
hid_layout | Generic HID battery descriptor capabilities. |
hidpp_exchange | One allowlisted read-only HID++ request/response exchange. |
hid_sample | Extracted generic HID battery sample and state bits. |
reading | One normalized device reading. |
event | Capture lifecycle, discovery, or read error. |
marker | Annotation or phase marker. |
footer | End-of-capture summary and completion status. |
Not every capture contains every record. HID++ captures include feature and
exchange records; generic HID captures include layout and sample records;
Bluetooth captures include a device record and property or GATT evidence.
Bluetooth devices use capture-local bt-N identifiers rather than Windows
device IDs or Bluetooth addresses.
Test corpus guidance
Fixtures in tests/hardware exercise captured and synthetic evidence. Captures
in the test corpus may be classified as:
| State | Meaning |
|---|---|
observed | Recorded from hardware, but not independently confirmed or encoded as an expected result. |
regression | Expected behavior is covered by a test. |
verified | Corroborated by independent evidence, such as vendor software or repeated measurements. |
broken | A known failure retained for diagnosis. |
A capture is not promoted to verified by replay alone. Before adding a real
capture to the test corpus, remove private information and confirm that the
captured behavior should become a regression expectation.
Generic HID battery-level support
Volty’s goal is to monitor battery levels from HID peripherals across vendors and protocols. HID describes how a device exposes reports; it does not mean every device reports battery state in the same format. A battery reading may come from a standardized HID report or a vendor-specific protocol, and each format needs a supported discovery and decoding path.
Current implementation: two HID battery device families are registered in
SUPPORTED_HID_DEVICES: Logitech HID++ 2.0 and generic standard HID battery
reports. The generic path covers any HID interface whose report descriptor
declares a usable battery level (Generic Device Controls Battery Strength,
Battery System relative/absolute state of charge, or remaining and full-charge
capacity) and reads it through the Windows HID parser, showing the reading as
“HID Battery”. The Logitech HID++ path reads Unified Battery (0x1004), then
legacy Battery Status (0x1000), then Battery Voltage (0x1001). A device
that exposes its battery only through a vendor-defined report still needs its
own protocol adapter.
Current use case: The project owner’s current primary use case is the Logitech G502 X Superlight RGB; device support remains best-effort and hardware validation is limited.
Battery-reading flow
HID peripheral
|
v
Enumerate HID interfaces
|
v
Resolve each interface against SUPPORTED_HID_DEVICES
| no match: no device selected
| multiple matches: ambiguity error
| exactly one match
|
v
Selected device's protocol backend
|
v
Protocol-specific query and decoder
|
v
Normalized battery reading (BatteryStatus)
|
v
HidProvider -> BatteryProvider (DeviceId + DeviceLabel + BatteryStatus)
|
+-- Tray icon
+-- Low-battery notifications
Protocol-specific code should stop at the decoder boundary. The rest of the
application should consume a normalized reading with a percentage only when
the device actually reports a percentage, along with any known charging state.
In the current code, BatteryStatus is that normalized representation.
The workspace has two user-facing paths today:
| Path | What it does | Current scope |
|---|---|---|
| Tray polling | Queries a battery through the built-in API, then updates the icon and notifications. | Logitech HID++ Unified Battery (0x1004), legacy Battery Status (0x1000), and Battery Voltage (0x1001) fallback, plus standard HID battery usages (Generic Device Controls Battery Strength and Battery System state of charge or capacity). |
| HID diagnostics CLI | Inspects interfaces and HID++ feature mappings for on-demand diagnostics. | HID++ 2.0 diagnostics; separate from tray polling. Standard HID battery descriptors are exercised through the tray path instead. |
On-demand diagnostics do not select a tray backend or add device support. Tray polling is selected only by the device catalog and its registered protocol backends.
Protocol and device extension points
The HID crate separates interface-to-device matching from protocol behavior:
HidBatteryDevicesupplies a family name and returns itsBatteryProtocol. Overridematcheswhen the default protocol-level interface match is too broad.BatteryProtocolsupplies its name, interface matcher, ordereddevice_indices, andquery_battery, which owns transport, response decoding, and conversion toBatteryStatus. Optionalquery_device_namemetadata is a separate operation and must not delay a battery reading.SUPPORTED_HID_DEVICESis the single dispatch catalog used to select a device and its backend.
During tray polling, Volty enumerates HID interfaces and calls
resolve_hid_device for each one. The resolver checks the devices in
SUPPORTED_HID_DEVICES: no match selects no device, exactly one match selects that
device, and multiple matches produce an ambiguity error rather than letting
catalog order decide. HidBatteryDevice::matches defaults to the selected backend’s
supports_interface method. The uniquely selected device supplies the backend
used for the query; Volty does not try every protocol or choose a backend
from diagnostic input.
SUPPORTED_HID_DEVICES currently has two entries. The Logitech HID++ 2.0
device matcher selects vendor ID 0x046d, usage page 0xff00, and usage
0x0002; after selection, runtime feature discovery and battery decoding
determine whether a supported battery feature is available. The generic HID
battery device matcher accepts only non-vendor usage pages whose report
descriptor declares a usable battery field; it probes and caches the parsed
layout per interface, and an interface without a readable battery level does
not match. The two matchers are non-overlapping: the generic matcher never
accepts usage page 0xff00 or any other vendor-defined page, while the
Logitech matcher requires the Logitech vendor ID and usage page 0xff00.
Polling keeps every successful interface/device-index reading, so one matching interface is a candidate rather than proof that it reports a supported battery. The tray lists each readable device and uses the lowest reported percentage for its battery icon.
Successful readings are forwarded to notifications immediately instead of
waiting for every candidate slot to finish scanning. Each HID device is
identified as hid:{stable key}#{slot}. The stable key comes from
stable_interface_keys (VID/PID, interface number, usage page/usage and a hash
of the serial number when the device reports one) and deliberately excludes the
Windows interface path, which changes when USB re-enumerates. Interfaces that
are indistinguishable (for example two identical receivers without serials) get
a deterministic ~N suffix ordered by path. Volty stores the last successful
device-index order per stable key in
%LOCALAPPDATA%\Volty\device-state.sqlite3 and tries those indexes first on
later polls. This cache only changes discovery order: unreadable or missing
state falls back to the full index scan, and successful readings remain usable
while the rest of discovery continues. Monitor flags and low-battery alert
de-duplication use the same id, so they survive re-enumeration.
Protocol names are queried only after the battery scan has delivered its readings. Each interface/device-index name (including a missing name or lookup failure) is cached for the active connection, so repeated polls do not issue name requests. A failed battery query invalidates that entry to allow a replacement device in the same receiver slot to be named.
To get a device into tray polling:
- If the interface declares a standard HID battery usage, the generic matcher already selects it; no new trait implementation is needed as long as its report descriptor exposes a readable level.
- If the existing Logitech matcher selects it and the existing backend already decodes its battery feature, no new trait implementation is needed.
- If the wire protocol is already supported but the device/family needs its
own interface match, implement
HidBatteryDevice. Return the existing backend fromprotocol()and add the device toSUPPORTED_HID_DEVICES. - If a new battery feature or encoding uses an existing wire protocol,
extend that backend’s
query_batteryand decoding path. Add or update aHidBatteryDeviceonly if the interface does not already resolve to that backend. - For a new wire protocol, implement
BatteryProtocol: identify compatible interfaces, list addresses to try, perform the protocol-specific query, and return a normalizedBatteryStatus. Then implementHidBatteryDeviceto select that backend and register it inSUPPORTED_HID_DEVICES. - Keep device matchers mutually exclusive for every interface. If multiple devices match, resolution fails with an ambiguity error rather than choosing a backend based on catalog order.
- Preserve percentage provenance in
BatteryStatus::percentage. Use a reported percentage for ETA, discharge-rate learning, exact-percentage history, and numeric low-battery thresholds. An estimate can drive the tray icon/color and must be marked with~in percentage text, but must not be treated as a reported percentage for those calculations. Leave the percentage unavailable when the device reports only a raw level or unknown value; the normalized coarse level and state remain available for status text.
No additional tray/UI trait is required: once query_battery returns a valid
BatteryStatus, the existing app updates status text and the tray icon. Its
percentage provenance determines which calculations and alerts are eligible.
logitech_hidpp.rs and generic_battery.rs are the two protocol backends. The
generic backend reads one directly addressed interface (0xff) through the
Windows HID parser and does not enumerate paired-device slots. The tray consumes
the protocol and device traits rather than selecting a vendor directly.
Battery Voltage fallback (0x1001)
When a device exposes neither Unified Battery (0x1004) nor legacy Battery
Status (0x1000), the HID++ backend probes Battery Voltage (0x1001) and
sends its getBatteryVoltage function (0x00). The response carries a 16-bit
big-endian millivolt value in params[0..2]; zero millivolts is rejected as an
invalid response.
This feature reports voltage only: there is no percentage and no charging
state. Volty estimates a percentage by linearly interpolating a documented
single-cell LiPo discharge curve and clamps the result to 0..=100:
| Millivolts | Estimated percent |
|---|---|
| 4200 | 100 |
| 4100 | 95 |
| 4000 | 90 |
| 3900 | 80 |
| 3800 | 70 |
| 3700 | 60 |
| 3600 | 50 |
| 3500 | 40 |
| 3400 | 30 |
| 3300 | 20 |
| 3200 | 10 |
| 3000 | 0 |
Values above 4200 mV clamp to 100 and values below 3000 mV clamp to 0. The
derived level bucket uses the estimated percentage: critical at 10 or below,
low at 20 or below, good below 100, and full at 100. The reading sets
raw_level to 0, raw_status to None, state to Unknown, source to
"HID++ Battery Voltage (estimated)", and keeps the measured value in
BatteryStatus::voltage_mv.
This path is implemented but NOT hardware-validated. No device exposing
only 0x1001 was available, so it is exercised only by unit tests and synthetic
HID++ frames. The percentage is an estimate from voltage and may differ from
the vendor’s own curve, especially under load, at temperature extremes, or on
non-LiPo cells. Treat it as a best-effort indicator; the raw millivolts in
BatteryStatus are the unmodified device reading. Captures retain that raw
voltage in BatterySnapshot::voltage_mv, and capture replay decodes 0x1001
exchanges with the same decoder as the tray. All 0x1001 evidence remains
synthetic and hardware-unvalidated.
How HID diagnostics fit
The volty hid CLI (the profile feature of volty-hid, enabled by the tray)
provides on-demand HID++ inspection and diagnostics. It is
separate from the tray poller: diagnostic commands do not identify a HID
device, register a backend, or change which battery query the tray runs. It
does not inspect standard HID battery descriptors; the generic path is
exercised through tray polling.
See Adding a Logitech mouse for the Logitech backend’s worked example: interface selection, feature discovery, tray validation, and the Rust changes required for a new battery encoding.
Adding another HID battery source
For a new device, keep these responsibilities explicit:
- Discover the interface. Identify the HID interface and how the target device is addressed. USB interface selection and a device’s own paired-slot address are different concepts.
- Identify the protocol and battery fields. Determine the request, response layout, units, valid range, charging states, and whether a reported level is actually a percentage. Do not infer percentages from arbitrary level codes.
- Add or reuse a protocol adapter. The adapter owns framing, timeouts, response matching, and protocol errors. If the device uses a different protocol, implement its transport rather than relying on diagnostic input.
- Normalize the result. Convert the response to the shared battery model while retaining unknown/sentinel values instead of guessing.
- Connect it to the tray deliberately. Add a
HidBatteryDevicetoSUPPORTED_HID_DEVICESthat selects the backend. A successful standalone diagnostic query alone does not add tray support. - Test both layers. Unit-test parsing with recorded frames, then validate with read-only hardware queries and the actual tray path.
The generic matcher already covers devices that declare a standard battery usage, so those need no new protocol adapter. A device whose battery appears only in a vendor-defined or otherwise non-standard report still needs its own discovery and report/protocol adapter before the tray can monitor it. The generic path scales a declared level from its logical range or uses remaining and full-charge capacity; it does not guess a percentage from an opaque vendor value.
Validation
Frame construction, response correlation, and battery decoders can be tested
without hardware. Generic layout selection, logical-range scaling, capacity
ratios, and charging/discharging mapping are unit-tested in generic_battery.rs;
descriptor probing and report reads require Windows HID APIs and hardware.
HID++ Unified Battery, legacy Battery Status, and the Battery Voltage fallback
are unit-tested with synthetic frames, including the voltage curve, clamping,
and monotonicity. Battery Voltage support has not been validated on hardware
because no device exposing only 0x1001 was available; its synthetic capture
fixture exercises Root discovery, the voltage read, feature-table verification,
and reading comparison including raw voltage during offline replay. On-demand
diagnostics can verify a HID++ interface or feature mapping, but tray support
must be tested through its own polling path; a successful standalone diagnostic
query does not prove that the tray consumes that reading. The current primary
use case is the Logitech G502 X Superlight RGB, and hardware validation remains
limited; do not infer support for every model from one working device.
First backend example: Logitech HID++ mouse
This is Volty’s first backend example: it covers diagnosing a Logitech HID++ mouse and deciding whether it already works or needs a code change. The project owner’s current primary use case is the Logitech G502 X Superlight RGB; this worked example is guided by that setup, but device support remains best-effort and hardware validation is limited. For the generic HID battery-level goal and backend architecture, see Generic HID battery-level support.
What is supported today
The tray polls Logitech HID++ interfaces and reads these standard battery features:
| Feature ID | Feature | Tray behavior |
|---|---|---|
0x1004 | Unified Battery | Reads capabilities and battery status; uses the reported percentage only when the device advertises percentage support. |
0x1000 | Legacy Battery Status | Used when Unified Battery is not present. Its level is treated as device-specific, not as a percentage. |
Feature runtime indexes are discovered from Root for each query; do not hard-code an index shown by one device. If a mouse implements either standard feature correctly, it should work without model-specific code.
Standard HID battery reports are handled separately by the generic HID battery
matcher and do not require this HID++ flow. A Logitech mouse that exposes its
battery only through HID++ still needs 0x1004 or 0x1000 as described here.
The tray queries its built-in battery features directly. It keeps each successful interface/device-index reading and shows every readable device; on-demand diagnostics do not select or register tray backends.
1. Identify the HID++ interface and device index
From the repository root, list candidate interfaces:
cargo run --release --package volty-tray -- hid inspect --vid 0x046d
Choose an interface that exposes a Logitech vendor HID collection. Use its VID, PID, usage page, usage, and interface number to narrow later commands to exactly one interface. Do not confuse the USB interface number with the HID++ device index:
0xFFaddresses a directly connected device or receiver endpoint.1–6address paired-device slots behind a receiver.
The tray backend currently selects only usage page 0xFF00 with usage
0x0002, which carries the long HID++ reports it sends. It skips usage
0x0001, the short-report collection, rather than sending long reports to an
interface that does not support them.
Discover the features for the mouse’s device index (replace the example PID, usage, and index with the values for your setup):
cargo run --release --package volty-tray -- hid features --device-index 0x01 --vid 0x046d --pid 0xc547 --usage-page 0xff00 --usage 0x0002
If the selector matches more than one interface, add --interface using the
interface number reported by inspect. Look for logical feature 0x1004 or
0x1000; the output also shows the runtime index and version assigned on that
device.
2. Inspect feature mappings and verify tray behavior
Use the on-demand feature command to check which battery features the device advertises and what runtime indexes Root assigns:
cargo run --release --package volty-tray -- hid features --device-index 0x01 --vid 0x046d --pid 0xc547 --usage-page 0xff00 --usage 0x0002
Look for logical feature 0x1004 or 0x1000. The tray already queries both:
Unified Battery uses its reported percentage only when supported, while the
legacy raw level is never treated as a percentage. Then verify the reading in
the tray; the diagnostic feature listing alone does not read battery status.
3. Decide whether code changes are needed
The mouse has 0x1004 or 0x1000
No model-specific battery change should be necessary. Connect the mouse or its
receiver and let the tray poll. It scans detected Logitech interfaces and
device indexes 0xFF, 1–6, retaining each successful battery response.
If the tray does not show a reading, check that it is using the same interface and paired-device slot shown by the feature listing. Diagnostic output can contain local device details; review it before sharing.
The battery is exposed through another feature or encoding
Use feature discovery and a read-only hardware query to establish the logical feature ID, function, request parameters, response fields, and whether the level is genuinely a percentage. Record observed request/response frames for tests, but do not treat a standalone diagnostic result as tray support.
To make a non-standard battery feature available in the tray:
- If the mouse uses a new transport or wire protocol, add a separate protocol
implementation module and implement
BatteryProtocol. Do not put a second vendor’s framing inlogitech_hidpp.rs. - Implement
HidBatteryDevicefor the device family, returning the appropriate protocol. Add it toSUPPORTED_HID_DEVICESincrates/volty-hid/src/protocol.rs; this is the dispatch catalog. There is no separate protocol registry. Keep matchers specific because overlapping matches are reported as ambiguous and are not polled. - For another Logitech HID++ feature, keep feature discovery and request
handling within the HID++ backend, then parse its response into the shared
BatteryStatusrepresentation incrates/volty-hid/src/battery.rs. UseBatteryPercentage::Exactonly when the device reports a percentage. Mark derived values asBatteryPercentage::Estimatedwith an explicit method, preserve the raw value and coarseBatteryLevel, and retain unknown status values rather than guessing. - Add unit tests using captured response frames, including malformed and unknown values. Keep hardware tests read-only.
- Run the workspace checks below and verify the new mouse in the tray.
BatteryProtocol returns the normalized battery model consumed by the tray;
protocol-specific frames and errors should not leak into tray code. The
registry is the only place that should make a new mouse family available to
polling.
Implement query_device_name only when the protocol exposes a useful display
name. The poller requests it after delivering the first battery reading and
caches the result, including a missing name or lookup failure, for that active
device connection. A battery-query failure invalidates the cache so a
replacement device in the same receiver slot can be named.
Validate changes
From the repository root:
cargo fmt --check
cargo test --workspace --locked
cargo check --workspace --all-targets --locked
Then build and verify the tray with the target mouse or receiver on Windows:
cargo build --release --package volty-tray --locked
Use read-only diagnostics when inspecting hardware. Avoid arbitrary hid raw
requests and state-changing commands when testing battery support.
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.
References
- Windows GATT client documentation
- Windows Battery Level characteristic UUID
- Bluetooth SIG Battery Service specification
Build and test
Requirements
Build the tray application on Windows 10/11 with the x86_64-pc-windows-msvc
Rust toolchain. The minimum supported Rust version is 1.99; stable Rust is
recommended. The release executable is written to target\release\volty.exe.
Build and validate
From the workspace root:
cargo build --release --package volty-tray --locked
cargo test --workspace --all-features --locked
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
CI also builds the tray with its default Bluetooth feature, checks the workspace on Rust 1.99.0, and audits dependencies:
cargo build -p volty-tray --locked
cargo check --workspace --all-features --locked
cargo audit
The audit reports informational notices such as unmaintained crates and
unsound APIs, but only known vulnerabilities fail the check. Some
platform-specific dependencies are included in Cargo.lock even when they
are not built for Windows.
Protocol frame tests use mock byte buffers and do not need connected hardware. Device communication, Windows HID parsing, Bluetooth discovery, and tray behavior need Windows and compatible hardware. A successful diagnostic command alone does not establish that the tray polls a device; verify it through the normal tray path.
Hardware reports and contributions
When reporting a device, attach a reviewed .volty.jsonl capture to the
Hardware report issue.
The capture guide explains what is recorded and how to
review it. Device support is added to the documented support table only after a
real reading and offline replay are confirmed.
Use read-only diagnostics when inspecting hardware. Avoid arbitrary
volty hid raw requests and state-changing commands when validating battery
support. For passive report-level protocol discovery, use the finite,
explicitly opted-in volty hid sniff trace; it records raw input data and
cannot observe traffic sent by another process. For implementation details,
start with the
architecture guide and the relevant HID or Bluetooth page.
Volty sustained-run performance assessment
Assessment date: 2026-10-26
Scope: One observed Windows release process, sampled for 60.2 seconds, plus a source review of polling and Bluetooth discovery refreshed on 2026-10-31 for the event-driven tray loop. The 10-second polling assessment below is a source-based estimate, not a controlled device-hardware benchmark.
Summary
The observed process had a small, stable footprint over the one-minute sample: working set varied by 0.05 MB, private bytes by 0.08 MB, and CPU time did not measurably increase at the sampling precision used. This is consistent with an idle tray application, but it is not a general benchmark or proof of long-term stability.
Source inspection shows that the device poller waits between scans rather than continuously looping. At a 10-second successful-poll interval, it can run up to six scans per minute when scans complete quickly, versus at most one every two minutes with the default interval. This increases per-device query activity, but does not imply a twelve-fold increase in total process CPU. Since v0.7.0, Bluetooth is enabled in the default Cargo feature set. Its classic Bluetooth property, SetupAPI, and paired-device GATT phases share a 30-second overall scan budget, with individual Windows Runtime operations still capped at five seconds. Slow or unusually large device inventories may therefore return partial results with a budget warning.
Observed process
The measurements below are from a process already running as target/release/volty.exe. Its configuration, connected devices, and compiled feature set were not inspected or changed, so the results should be treated as an observation of this machine and run only.
| Metric | Result |
|---|---|
| Sampling period | 60.2 seconds, 13 samples at approximately 5-second intervals |
| CPU time | No measurable increase; 0.000 seconds at 3-decimal-second display precision |
| Working set | 17.27-17.32 MB |
| Private bytes | 2.74-2.82 MB |
| Handles | 207-208 |
| Threads | 5-8 |
The process had been running for about 2 minutes 18 seconds at the end of sampling. The sample therefore crossed the default 120-second poll point if the default configuration was active, but the runtime configuration was not verified, so completion of a scheduled poll during the sample cannot be confirmed. CPU values below the measurement precision are also possible.
Source-based runtime assessment
Polling and idle behavior
- The default successful-poll wait is 120 seconds, with a configurable range of 10-3600 seconds; the default retry wait is 15 seconds. The worker waits after each query completes, so query duration adds to the start-to-start interval. (config.rs, app/poll.rs)
- Device discovery runs on a dedicated worker thread. The tray loop is event-driven: it blocks in
MsgWaitForMultipleObjectsand is woken by window messages, the poll worker, and the config watcher, so it uses no CPU while idle and processes queued work immediately. (app.rs, wake.rs) - The in-memory diagnostic log is capped at 200 entries. The file logger appends and flushes each accepted entry and rotates at 5 MiB, keeping one
.1backup, so sustained trace logging is bounded to roughly 10 MiB of disk. (app.rs, logging.rs)
10-second successful-poll scenario
Set poll_interval_seconds = 10 to use this cadence; the application default remains 120 seconds. Because the worker waits after each completed query, the approximate scan start-to-start interval is query duration + 10 seconds. A quick successful query therefore yields about six scans per minute (roughly 12 times the default scan frequency). Failed queries use the retry interval instead; with the default 15-second retry, a fast failure results in about four attempts per minute.
Each scan repeats the same HID discovery and battery-query work, so average device activity and scan-related CPU/I/O will rise with scan frequency. The existing one-minute process sample did not use a verified 10-second configuration and cannot quantify that increase. Exact impact depends on connected devices and read latency; no device-specific CPU or battery-life claim is made here.
The UI loop no longer polls on a timer; it blocks until a window message, poll result, or config change arrives, so idle wakeups are zero and queued tray actions or completed readings are processed immediately. Device polling itself remains on its worker thread.
The battery ETA uses a per-device discharge history, defaulting to a 10-minute window (configurable from 1 to 180 minutes). With 10-second polling, this retains about 60 samples per device. The estimate needs a full window of usable readings, resets while charging or after a level increase, and requires no additional device I/O.
Bluetooth path
Bluetooth is enabled by default in the tray crate. Build with
--no-default-features to omit it. (Cargo.toml)
Each device query performs classic Bluetooth property discovery, SetupAPI device-property discovery, and paired BLE discovery sequentially under one shared 30-second overall scan budget. Individual Windows Runtime operations still time out after five seconds. If the budget is exhausted, phases stop and return results gathered so far with a warning. The work is on the device-poller thread, not the tray UI thread, but it can delay the next battery update. (budget.rs, budget.rs, ble.rs)
Conclusion and follow-up
No CPU hot loop or growing memory footprint was evident in this one-minute observation. The default polling design is event-driven: the tray loop blocks in MsgWaitForMultipleObjects while idle, and the device worker waits between completed scans instead of spinning. The main performance uncertainty is scan latency, especially with the experimental Bluetooth feature enabled; the observed process did not provide a controlled comparison of Bluetooth or hardware configurations.
A representative follow-up should record the effective configuration and feature set, then sample a clean run for at least 10 minutes with the relevant devices connected. Include per-scan duration, CPU time, working/private memory, and log-file size so the test covers multiple polls and any retry behavior.
Releases and changelog
User-facing changes are tracked in the root CHANGELOG.md.
To prepare a release:
- Bump the version in
[workspace.package]in the rootCargo.toml. - Move the
[Unreleased]changelog entries under a heading with that version and release date. - Commit and push a matching tag such as
v0.4.0.
The Windows release workflow checks that the tag matches the volty-tray
package version, runs formatting, Clippy, and tests, then publishes a standalone
x86_64-pc-windows-msvc executable and SHA-256 checksum to the GitHub Release.
The tray app’s update flow expects those versioned executable and .sha256
asset names. No installer or sidecar runtime files are required.