Skip to content

Tray: device type switches (#96), per-device alert level, time left, percentage icon, quiet while gaming (#76), status file; Audeze stuck dongle and 0% at power-on, unit tests - #107

Merged
HeyOkay merged 7 commits into
HeyOkay:mainfrom
Waturus:feature/provider-toggles-thresholds-eta
Sep 29, 2026

Conversation

@Waturus

@Waturus Waturus commented Sep 27, 2026

Copy link
Copy Markdown

Six tray features from the feature requests and code reviews, plus two Audeze Maxwell fixes found while testing them on an Xbox Maxwell. Every feature is a switch in Preferences; the only one on by default that changes existing behaviour is Quiet while gaming (see 5).

Base and other PRs

  • Base: main at 11c28fb (1.12.0), merged in today; the four feature commits were written on 1.11.0 and the merge commit resolves the overlap (provider list, tray-icon helpers, CHANGELOG).
  • Conflicts (a trial merge with every open PR):
  • Files:
    • history.py (new): drain rate and time left
    • halo_battery.pyw: menus, settings, polling, notifications, status file; the provider list is now one make_providers() shared by the app and --probe
    • icons.py: optional percentage text in the ring
    • providers/playstation.py: read_bluetooth switch
    • providers/audeze.py: stuck dongle, 0% at power-on
    • README.md, CHANGELOG.md (all under [Unreleased])
    • tests: test_new_features.py, test_more_features.py, test_audeze_stuck.py (new); test_hide_rename.py (harness and the two pinned menu lists)

1. Device types (Preferences)

A checkbox per provider (all 21, LAMZU included; test_every_provider_has_a_label fails if a new provider has no label). A type that is off is not polled at all, so its devices are never opened, and its icons go away at once rather than after two missed polls. Its pending flag and XInput slots no longer wake the poll loop either.

PlayStation over Bluetooth is a switch of its own, for #96: reading a DualSense / DualShock 4 over Bluetooth needs the feature report that switches it into the full report mode, and some games do not see the controller in that mode until it is power-cycled. Off, a Bluetooth controller is left alone; over USB it is still read.

2. Low battery alert per device

"Low battery alert at" in each device's menu: Default (follows Preferences) or Off / 10-30%. The red ring follows the device's own level. Stored in cfg["lows"] like names and icons; a damaged value is ignored.

3. Estimated time left

"Viper: 67%, about 14 h of use left" in the tooltip. history.py fits the level against the time the device was awake and on battery since its last charge (least squares), so a mouse asleep overnight or a suspended PC does not stretch the estimate; one gap counts as 10 minutes at most. Charging, or a rise of 3+ points without "charging", starts it over. No estimate before 30 minutes of use and a 3-point drop; coarse devices (approx) are skipped. Kept in %APPDATA%\HaloBattery\history.json (written at most every 5 minutes and on exit, atomically), devices unseen for 60 days are dropped. Can be turned off.

4. Percentage in the icon

The level as a number in the ring instead of the pictogram, off by default. Segoe UI Bold (Arial Bold, then Pillow's default as fallbacks), shrunk to fit so "100" stays inside the ring (a test checks every changed pixel is inside the inner radius); amber/red like the arc when low, translucent when asleep. approx devices keep their pictogram rather than show an exact-looking number.

5. Quiet while gaming (on by default)

While SHQueryUserNotificationState reports a full-screen app (2, borderless games included), a D3D full-screen game (3) or presentation mode (4):

  • notifications are held, one per device and kind, and shown when the game closes; a held low battery alert is dropped if the device is on the charger by then;
  • the poll interval becomes 5 minutes, since every poll writes to the devices (Controller Settings Changed / Input Lag? #76). A plug-in still polls at once, and the loop wakes as soon as the game closes.

This delays alerts during a game for everyone, which is why it is a visible switch; flipping the default is one line if you prefer it opt-in.

6. Status file for other apps (off by default)

%APPDATA%\HaloBattery\status.json after every poll, written atomically, for Rainmeter / Stream Deck / scripts: each shown device's name (the user's), level, charging, online, kind, approx, low_alert_at, seconds_left and the tooltip text, plus updated_unix. running: false on exit; turning it off deletes the file. A file rather than a local HTTP endpoint, so nothing listens on a port.

7. Audeze Maxwell: stuck dongle

Measured on an Xbox dongle (3329:4B18) in PC mode, headset on and playing audio, product string "Audeze Maxwell HID": all 22 replies were empty echoes of the request header (07 00 80 00 ..., 07 00 00 00 ... for the one request with 00 there), over several polls - no battery, so no icon. Unplugging the dongle and plugging it back in made it answer 80% at once. With the headset switched off the same dongle does not echo (it returns data with the firmware string v1.0.1.633, no marker), so the two states are distinguishable.

After two such polls in a row the headset gets a greyed icon, under its own key, saying "no answer from the headset - unplug the dongle and plug it back in"; the diagnostics say what was seen. HeadsetControl has the same symptom open for this dongle: Sapd/HeadsetControl#460.

The Audeze sequence here already matches HeadsetControl after Sapd/HeadsetControl#577 (the 09 25 00 7A write that shifted the L/R balance is not in it).

8. Audeze Maxwell: 0% right after power-on

Switching the headset on gave "Low battery, 0% left": the first reading after power-on was 0%, the next one a poll later 80%. A 0% within 90 s of the headset appearing is now "battery level not reported yet" with pending set, so the app re-checks every 3 s instead of a minute later. After 90 s a 0% is believed (a flat headset on the cable). The grace period restarts after the headset was off or the dongle unplugged.

Testing

  • Unit tests: python -m unittest discover -s tests - 276 pass (58 new).
  • On hardware (Windows 11, Audeze Maxwell on the Xbox dongle 4B18, a wired Xbox 360 controller over XInput):
    • all new menus and submenus render, with the expected defaults;
    • Maxwell: stuck state seen and recovered by a replug; switched off, the icon leaves after a minute and no "stuck" icon appears; switched on, the level comes back; the 0% at power-on was seen (fixed in 8);
    • history.json fills; the percentage icons rendered and checked at 64, 24 and 16 px on dark and light bars.
  • Not verified on hardware: Quiet while gaming in a real game, the status file with an actual Rainmeter skin, PlayStation over Bluetooth (no PS controller here), and a time-left estimate after a real discharge (unit-tested with synthetic drains). Reports welcome.

🤖 Generated with Claude Code

DW and others added 6 commits September 27, 2026 20:02
- Preferences > Device types: turn off any provider; its devices are not
  opened and their icons go away at once. "PlayStation over Bluetooth" is
  a switch of its own for HeyOkay#96 (USB is still read).
- "Low battery alert at" in each device's menu, with Default following
  Preferences; the red ring follows it.
- Estimated time left in the tooltip (history.py): least-squares drain
  rate over the time the device was awake and on battery since its last
  charge, kept in %APPDATA%\HaloBattery\history.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- "Percentage in the icon": the level as a number in the ring instead of
  the pictogram, fitted inside the ring and coloured like the arc when low.
- "Quiet while gaming": while a full-screen app is in front
  (SHQueryUserNotificationState), alerts are held until it closes (one per
  device and kind, stale low alerts dropped) and devices are polled every
  5 minutes instead of every poll interval.
- "Status file for other apps": %APPDATA%\HaloBattery\status.json after
  every poll for Rainmeter, Stream Deck or scripts; removed when turned off,
  "running": false on exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Xbox Maxwell dongle (3329:4B18) that reported a linked headset, in PC
mode with the headset on and playing audio, answered all 22 packets with
an empty echo of the request header (07 00 80 00 ...). No battery, no
icon, although a replug fixed it at once. After two such polls in a row
the headset now gets a greyed icon saying to replug the dongle, under its
own key so the normal icon takes over when it answers again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Switching an Xbox Maxwell on gave a "Low battery, 0% left" alert: the
first reading after power-on was 0%, the next one a minute later the real
80%. A 0% within 90 s of the headset appearing is now "battery level not
reported yet" with pending set, so the app re-checks every 3 s until the
real level arrives. After 90 s a 0% is believed (a flat headset on the
cable). The grace period starts over after the headset was off or the
dongle unplugged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: the provider lists (upstream added LamzuProvider; kept
make_providers() and added LAMZU to it, with a Device types label), the
new tray-icon helpers next to the full-screen detection (both kept), and
CHANGELOG.md (our entries moved to [Unreleased] above 1.12.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows menus do not indent; the spaces only looked like a misaligned item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@casparas123

Copy link
Copy Markdown
Contributor

Reviewed this against an Audeze Maxwell on the PC dongle (3329:4B19) - ran your branch live plus the suite. Mostly confirmations, a few notes.

What I verified

  • Full suite: 276 OK here too; test_audeze_stuck.py: 10 OK.
  • Spot-mutants on the new Audeze logic: STUCK_POLLS 2 -> 1, ZERO_GRACE 90 -> 0 and the grace < -> <= are all killed by your tests. One survivor: changing echo_only's len(f) > 3 to > 5 passes everything - the boundary is untested. Your measured stuck frame (07 00 80 00) is exactly 4 bytes, so one fixture with a 4-byte echo would pin it.
  • Normal path on real hardware: main and your branch both read 90% from the Maxwell across an off/on cycle - no regression, and no false "stuck" icon at any point (three probe runs plus continuous polling every 4 s through a power cycle). The echo_only rule only firing when every frame is a zero-filled echo is the right conservative shape.
  • §8 caveat: on our unit (PC dongle 4B19, not your Xbox 4B18) the first reading after a fresh power-on was 90%, not 0% - so the 0% didn't reproduce for us. Different dongle, so I don't read it as a contradiction. What we did see right after the cycle was the single battery packet coming back without a marker - which main's existing fallback already catches - so on our hardware the only fresh-on path that fires is the old one. Your 90 s grace reads as a sound safety net regardless (same audeze:{serial} key on all three statuses, reset on off/unplug - checked in the code and pinned by your tests).

The thing worth rebasing for

main has moved twice past your base, and the second one matters for §1: commit e80dfd1, "PlayStation over Bluetooth: listen only by default (#96)". The maintainer has already solved #96 - with listen-only rather than a switch - so the Bluetooth toggle and providers/playstation.py need reconciling with that design rather than merged against it. Conflicting files on a trial merge with current main: CHANGELOG.md, README.md, halo_battery.pyw, providers/playstation.py, tests/test_hide_rename.py (the tray-menu overlap is the Fluent menu commit).

One landmine, unrelated to your code: test_every_provider_has_a_label means every provider-adding PR merged after yours must add a label to make_providers() - with ~10 of them open right now, whoever lands second needs to know.

Nice work on the suite, and on pinning the stuck-dongle evidence to HSC's thread - the conservative detector plus the honest "no downside until it happens" framing is how I'd want a quirk like this to land.

@casparas123

Copy link
Copy Markdown
Contributor

Heads-up: I've opened #126, which shortens the README's support table and moves the per-device detail to a new docs/protocols.md.

That adds README.md to this PR's conflict list, alongside the files it already conflicts on against main today (CHANGELOG.md, halo_battery.pyw, providers/playstation.py, tests/test_hide_rename.py) - those come from the Fluent menu and the listen-only PlayStation change on main, not from this PR's work. When you're ready to rebase, I'm happy to send our resolution for the README side (the tray-menu wording carries over as-is; only the table area changes).

@HeyOkay
HeyOkay merged commit e1af8bd into HeyOkay:main Sep 29, 2026
@HeyOkay

HeyOkay commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Thank you so much @Waturus — six genuinely useful features with thorough tests! Merged together with main. One change during the merge: the "PlayStation over Bluetooth" switch in Device types was left out, because main now fixes #96 differently (e80dfd1): over Bluetooth the app only listens by default, and "PlayStation full mode (Bluetooth)" in Preferences opts into switching. Everything else is in as you wrote it, and the new 8BitDo provider is in Device types too. It ships in 1.13.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants