Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

VOLTY-RS

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

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 familyConnectionBattery backendTested functionalityStatus
Logitech G502 X Superlight RGBUSB cable or Lightspeed receiverLogitech HID++ 2.0 Unified Battery (0x1004)Percentage, charging/discharging state, HID++ capture and offline replayVerified
Logitech G502 X PLUS (slot 1)Not recordedHID battery provider (specific report not recorded)43%; estimated ETA of 1d 16h shown in trayVerified; live tray observation
Logitech HID++ devices exposing only Battery Voltage (0x1001)Any HID++ 2.0 transportBattery Voltage fallbackEstimated percentage from millivolts; raw voltage retained and replayedExperimental; synthetic tests only, not hardware-validated
Baseus Bass BS2 LiteBluetooth Classic (HFP/A2DP)Windows PnP battery property (DEVPKEY_Device_BatteryLevel)Percentage, property capture and offline replay; charging state unavailableVerified; Bluetooth enabled by default
Xiaomi J18 headphonesBluetooth (profile not recorded)Windows Bluetooth battery discovery (exact property not recorded)Battery percentage observed; capture/replay not recordedVerified; Bluetooth enabled by default
Keychron K8 ProUSB cable or BluetoothNone exposed to WindowsNo standard HID battery, BLE GATT Battery Service, or usable vendor commandNot supported
Flydigi controllers on the vendor protocol (VID 0x37d7, e.g. Vader 5 Pro)USB cable, vendor HID interfaceFlydigi 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 stateExperimental; synthetic tests only, not hardware-validated
Flydigi Vader 4 Pro (DInput mode)USB cable or 2.4 GHz receiverCandidate battery byte in the 05 EC device-info reply on interface 2 (0xffa0)Reply observed, but its battery byte was not correlated with charge stateInvestigating; do not treat the field as a reading yet
Flydigi Vader 4 Pro (XInput mode)USB cableFlydigi XInput vendor reply A5 1032-byte report observed on USB; battery byte 0x02 is approximately 40%, dischargingInvestigated; no live Volty transport; integration work stopped
Xbox One wireless controllerXbox Wireless AdapterWindows XInput XInputGetBatteryInformationAlkaline/NiMH buckets mapped to marked percentage estimates (empty 0%, low 20%, medium 50%, full 90%); no charging stateVerified by XInput provider hardware smoke test; estimates drive the tray icon/color but not ETA, history, or numeric low-battery alerts
Generic HID battery devicesAny HID transportStandard HID battery usagesPercentage, charging state when available, layout capture and offline replayExperimental; 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_00 path yielded 14-byte reports.
  • XInput and the xusb22 IOCTL_XUSB_GET_STATE exposed standard gamepad state, not the bytes after the XInput payload.
  • Static inspection of Flydigi’s XboxComm64.dll identified a filter-manager port (\XboxFilterPort) for receiving messages and DeviceIoControl calls on \\.\XboxFilter for writes/readiness.
  • From an unelevated process, \\.\XboxFilter could be opened, but its tested IOCTLs failed with ERROR_GEN_FAILURE; connecting to \XboxFilterPort failed with E_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 no A5 replies.

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-N identifier, 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

  1. 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.
  2. Volty saves the timestamped .volty.jsonl hardware 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\reports and shows both exact paths. Bluetooth evidence is included in default tray builds; building with --no-default-features disables Bluetooth recording.
  3. 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 capture writes a timestamped file under %APPDATA%\Volty\captures and prints its path plus a summary.
  • --output <path> writes to a specific file instead.
  • --strict omits 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 bluetooth feature. 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
  • inspect validates the schema and prints the same bounded summary as a fresh snapshot.
  • replay decodes 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 ~.

RecordPurpose
headerCapture metadata and schema version.
interfaceHID interface metadata, without paths or serial numbers.
matchDevice and backend match result for the interface.
bluetooth_deviceCapture-local Bluetooth device ID, kind, and filtered name.
bluetooth_propertyRaw Windows battery-property value.
bluetooth_gattRaw GATT Battery Level payload and UUIDs.
hidpp_featuresHID++ feature table.
hid_layoutGeneric HID battery descriptor capabilities.
hidpp_exchangeOne allowlisted read-only HID++ request/response exchange.
hid_sampleExtracted generic HID battery sample and state bits.
readingOne normalized device reading.
eventCapture lifecycle, discovery, or read error.
markerAnnotation or phase marker.
footerEnd-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:

StateMeaning
observedRecorded from hardware, but not independently confirmed or encoded as an expected result.
regressionExpected behavior is covered by a test.
verifiedCorroborated by independent evidence, such as vendor software or repeated measurements.
brokenA 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:

PathWhat it doesCurrent scope
Tray pollingQueries 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 CLIInspects 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:

  • HidBatteryDevice supplies a family name and returns its BatteryProtocol. Override matches when the default protocol-level interface match is too broad.
  • BatteryProtocol supplies its name, interface matcher, ordered device_indices, and query_battery, which owns transport, response decoding, and conversion to BatteryStatus. Optional query_device_name metadata is a separate operation and must not delay a battery reading.
  • SUPPORTED_HID_DEVICES is 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:

  1. 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.
  2. If the existing Logitech matcher selects it and the existing backend already decodes its battery feature, no new trait implementation is needed.
  3. If the wire protocol is already supported but the device/family needs its own interface match, implement HidBatteryDevice. Return the existing backend from protocol() and add the device to SUPPORTED_HID_DEVICES.
  4. If a new battery feature or encoding uses an existing wire protocol, extend that backend’s query_battery and decoding path. Add or update a HidBatteryDevice only if the interface does not already resolve to that backend.
  5. For a new wire protocol, implement BatteryProtocol: identify compatible interfaces, list addresses to try, perform the protocol-specific query, and return a normalized BatteryStatus. Then implement HidBatteryDevice to select that backend and register it in SUPPORTED_HID_DEVICES.
  6. 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.
  7. 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:

MillivoltsEstimated percent
4200100
410095
400090
390080
380070
370060
360050
350040
340030
330020
320010
30000

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:

  1. 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.
  2. 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.
  3. 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.
  4. Normalize the result. Convert the response to the shared battery model while retaining unknown/sentinel values instead of guessing.
  5. Connect it to the tray deliberately. Add a HidBatteryDevice to SUPPORTED_HID_DEVICES that selects the backend. A successful standalone diagnostic query alone does not add tray support.
  6. 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 IDFeatureTray behavior
0x1004Unified BatteryReads capabilities and battery status; uses the reported percentage only when the device advertises percentage support.
0x1000Legacy Battery StatusUsed 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:

  • 0xFF addresses a directly connected device or receiver endpoint.
  • 1–6 address 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:

  1. 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 in logitech_hidpp.rs.
  2. Implement HidBatteryDevice for the device family, returning the appropriate protocol. Add it to SUPPORTED_HID_DEVICES in crates/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.
  3. For another Logitech HID++ feature, keep feature discovery and request handling within the HID++ backend, then parse its response into the shared BatteryStatus representation in crates/volty-hid/src/battery.rs. Use BatteryPercentage::Exact only when the device reports a percentage. Mark derived values as BatteryPercentage::Estimated with an explicit method, preserve the raw value and coarse BatteryLevel, and retain unknown status values rather than guessing.
  4. Add unit tests using captured response frames, including malformed and unknown values. Keep hardware tests read-only.
  5. 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

DeviceTransportResult
Baseus Bass BS2 LiteBluetooth Classic (HFP/A2DP)100%, Windows PnP battery property
Xiaomi J18 headphonesBluetooth (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:

  1. Keep BatteryProtocol, HidBatteryDevice, and SUPPORTED_HID_DEVICES HID-specific.
  2. volty-battery owns the protocol-neutral normalized reading types and the provider trait. volty-hid retains HID++ decoding and returns the shared model.
  3. volty-bluetooth reads the Windows battery-life property and owns GATT discovery and battery reads, exposed as BluetoothProvider. Keep Windows Bluetooth transport out of volty-hid and tray rendering. hidapi only handles HID and is not a GATT transport.
  4. 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 0x180F and Battery Level characteristic 0x2A19; classic HFP and vendor-specific battery reports are not read.

Battery model

  • The protocol-neutral reading types live in volty-battery; HID++ decoding remains in volty-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-bluetooth uses 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 bluetooth feature 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-features poll 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_device identifies each observed device with a capture-local bt-N id and records whether Windows exposed it as classic, PnP, or BLE.
  • bluetooth_property keeps the raw Windows property byte, property name, and type before validation.
  • bluetooth_gatt keeps the raw Battery Level payload plus the service and characteristic UUIDs.
  • reading records the normalized result with device_index set to null.

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-N ids, 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-1 with a raw bluetooth_property value and a reading whose device_index is null. volty capture replay reproduces 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-1 id 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

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.

MetricResult
Sampling period60.2 seconds, 13 samples at approximately 5-second intervals
CPU timeNo measurable increase; 0.000 seconds at 3-decimal-second display precision
Working set17.27-17.32 MB
Private bytes2.74-2.82 MB
Handles207-208
Threads5-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 MsgWaitForMultipleObjects and 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 .1 backup, 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:

  1. Bump the version in [workspace.package] in the root Cargo.toml.
  2. Move the [Unreleased] changelog entries under a heading with that version and release date.
  3. 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.