Skip to content

Show controller batteries under the game menu, and let the guide LED follow them - #66

Draft
trexx wants to merge 2 commits into
masterfrom
feature/controller-battery-led
Draft

trexx wants to merge 2 commits into
masterfrom
feature/controller-battery-led

Conversation

@trexx

@trexx trexx commented Sep 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

Battery state was already collected for every supported pad but only ever forwarded to the host. Two additions, plus two pre-existing faults fixed on the way.

Battery label. While any level of the in-game menu is up, a small end-aligned line along the bottom of the screen lists every controller's battery by host player number:

Controller 1: 75%  ·  Controller 2: 50%, charging  ·  Controller 3: wired
  • New Android-free ControllerBatteries store (one slot per player number, synchronized), fed by both existing battery paths in ControllerHandler; Game formats it.
  • The menu is a chain of AlertDialogs whose onDismiss fires for a parent after its child has opened, so a DialogChain token decides which dismissal is the last one and hides the label.
  • The dialog's dim stays; the text uses the primary colour so it reads through it.

"Battery level" guide LED. A fifth value of the Xbox button brightness setting. The guide LED is monochrome, so it steps through the presets — full = Bright, medium = Normal, low = Dim — and slow-blinks at critical. A battery-less cabled pad goes Bright.

  • GuideButtonLed holds every LED constant and the ladder on the Java side, where it is tested; the native side only applies what it is handed and refuses a pattern outside enum LedMode.
  • Native: Controller::setLed() stores the command and sends it once the device has started; startDevice() applies whatever is stored through the same applyLed(), so the frame is built in one place. ledBrightness loses its const (and the comment that justified it) and gains a pattern, both std::atomic against the startThread fallback. New JNI entry setLedNative (JII)Z.

Fixed on the way:

  • A GIP pad's first status packet usually lands before its first input report — which is what assigns its player number — so its first battery report went to the host as player 0. Now held on the context until the number is assigned.
  • On the cabled path, a status arriving before the Java object existed armed the driver's change filter with nobody listening, so Java heard nothing until the battery actually moved. The filter now stays unarmed until a report is delivered; MS-GIPUSB 2.2.9 has the pad repeat status every second for its first ten seconds.

Pad-audio menu numbering. The submenu counted GIP pads by their position in the driver's list, which disagreed with the label and the host whenever the Shield controller or a Bluetooth pad held a slot. It now names pads by the host's player number (GameMenuLayout.padNumber(), tested), reading "Unused controller" for a pad that has not sent input yet, and keeping positional numbers only when multi-controller is off (every pad is player 0 there). Numbering is still assigned on first input, deliberately: assigning at deviceAdded() was considered and dropped because LiSendControllerArrivalEvent() returns -2 until the stream's input channel exists and nothing re-sends it, so a pad attached at launch would be announced into the void.

Latency and performance impact

None on any per-frame path. Battery writes happen at battery-change frequency on driver threads; the label is built once per menu level on the UI thread while the decoder is already stalled behind the dialog, and is a child View of the window rather than a layer (§14); the LED sends one GIP frame per level change on the driver's read thread, deduplicated against charge-only changes.

Applies to

Label: all pads on both boxes (Shield controller via sceManager; Bluetooth pads via API-31 BatteryState, so Homatics only; GIP pads on both). LED: GIP pads this app drives, on both boxes.

Test plan

  • ./gradlew testDebugUnitTest — 366 tests, 0 failures (62 new/updated: ControllerBatteriesTest, DialogChainTest, GuideButtonLedTest, PreferenceConfigurationTest, GameMenuLayoutTest)
  • ./gradlew assembleRelease lintRelease — both ABIs build; the 2 lint warnings are pre-existing (UsbDriverService, Profiler)
  • On device: HARDWARE_TESTING.md §31 — in particular whether a pad honours LED_BLINK_SLOW at all, which Table 42 leaves open, and that the label is legible under the dialog's dim

🤖 Generated with Claude Code

trexx and others added 2 commits September 16, 2026 21:54
…follow them

Battery state was already collected for every supported pad - GIP pads through
Controller::statusReceived, Shield and Bluetooth pads through the 120 s poll -
but only ever forwarded to the host, so nothing on the box could show it.

The label: while any level of the in-game menu is up, a small end-aligned line
along the bottom of the screen lists every controller's battery by host player
number - "Controller 1: 75%  ·  Controller 2: 50%, charging  ·  Controller 3:
wired". A new Android-free ControllerBatteries store, fed by both battery
paths, keeps one reading per number; Game formats it. The menu is a chain of
AlertDialogs whose onDismiss fires for a parent after its child has opened, so
a DialogChain token decides which dismissal is the last one and hides the label.
The dialog's dim stays; the text is the primary colour so it reads through it.

The LED: "Battery level" is a fifth value of the Xbox button brightness
setting. The guide LED is monochrome, so it steps through the presets - full
Bright, medium Normal, low Dim - and slow-blinks at critical, since a warning
has to look like one. GuideButtonLed holds every LED constant and the ladder
on the Java side, where it is tested; the native side gains Controller::setLed,
which stores the command and sends it once the device has started, and
startDevice() applies whatever is stored through the same applyLed() so the
frame is built in one place. ledBrightness loses its const and gains a
pattern, as std::atomic against the startThread fallback.

Two pre-existing faults fixed on the way, both found while wiring this up:

- A GIP pad's first status packet usually lands before its first input
  report, which is what assigns its player number, so its first battery report
  went to the host as player 0. It is now held on the context until the number
  is assigned.
- On the cabled path the read thread runs before the Java object exists, and a
  status in that window armed the driver's change filter with nobody
  listening, so Java heard nothing until the battery actually moved. The
  filter now stays unarmed until a report is delivered; MS-GIPUSB 2.2.9 has the
  pad repeat status every second for its first ten seconds.

Latency: nothing on a per-frame path. Battery writes happen at battery-change
frequency on driver threads; the label is built once per menu level on the UI
thread while the decoder is already stalled behind the dialog, and is a child
View of the window rather than a layer; the LED sends one GIP frame per level
change on the driver's read thread, deduplicated against charge-only changes.

Tests: ControllerBatteriesTest, DialogChainTest, GuideButtonLedTest, and the
guide LED cases in PreferenceConfigurationTest. HARDWARE_TESTING.md 31 lists
what needs a device - in particular whether a pad honours LED_BLINK_SLOW at
all, which Table 42 leaves open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pad-audio submenu counted GIP pads by their position in the driver's
list, while the battery label - and the game on the host - use the player
number ControllerHandler assigns. The two disagreed whenever the Shield
controller or a Bluetooth pad held a slot: the first GIP pad read "Controller
1" in the menu and was player 2 everywhere else.

The number now lives on the AbstractController too, written when the handler
assigns it and cleared when the pad goes, so the menu - which holds the
controller and nothing else - can read it without reaching into the handler's
maps from the UI thread. GameMenuLayout.padNumber() is the rule, tested: the
player number, or "Unused controller" for a pad that has not sent input yet,
or the position when multi-controller is off and every pad is player 0.

Numbering is still assigned on a pad's first input, deliberately. Assigning
at deviceAdded() was considered and dropped: the USB service binds in
onCreate(), and LiSendControllerArrivalEvent() returns -2 until the stream's
input channel exists with nothing re-sending it, so a pad attached at launch
would have been announced into the void and the host would never learn its
type or capabilities.

Latency: none; the menu builds its rows once per open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@trexx trexx mentioned this pull request Sep 16, 2026
2 of 3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant