Skip to content

feat: recognize Amazon Luna Controller (wired USB) on the verified fast lane - #153

Merged
emir-hasanbegovic merged 3 commits into
TinkerNorth:mainfrom
MichaelWilsonSNC:add-luna-controller-support
Aug 16, 2026
Merged

feat: recognize Amazon Luna Controller (wired USB) on the verified fast lane#153
emir-hasanbegovic merged 3 commits into
TinkerNorth:mainfrom
MichaelWilsonSNC:add-luna-controller-support

Conversation

@MichaelWilsonSNC

Copy link
Copy Markdown
Contributor

Description

Adds the Amazon Luna Controller (wired USB mode) to the verified
fast-lane kKnown table in usb_parsers.cpp, riding the existing
Parser::XINPUT_360 decoder unchanged. Its wired report is a plain
20-byte XInput frame, byte-for-byte identical to a real Xbox 360
controller, and interface 0's descriptor (class 0xFF, subclass 0x5D,
protocol 0x01) is the exact vendor-class triple classifyDevice()
already sniffs generically — so the device was already decodable
before this change, just unnamed and not on the verified fast lane
(requiring a manual "Direct" opt-in instead of auto-claim).

No wire-protocol changes; nothing to coordinate with satellite,
dish-linux, or dish-mac.

Device confirmed: Amazon Luna Controller (Lab126, Inc.), USB VID:PID
1949:041A, wired mode.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (code change that neither fixes a bug nor adds a feature)
  • Documentation update

How Has This Been Tested?

  • Unit tests
  • Instrumented tests
  • Manual testing on device/emulator

Existing usb_parsers_test.cpp host suite (174/174 passing, unchanged —
no new decode logic was added; individual KnownDevice rows aren't
independently unit-tested, only parser families, per existing
convention).

Manual test matrix, wired directly to an Android phone running dish,
Direct mode, verified twice — once via a raw USB capture against the
same byte offsets decodeXInput360/buildRumbleReport expect, and
once end-to-end through the real app into a satellite host's virtual
Xbox 360 controller:

  • Every button (A/B/X/Y, all 4 D-pad directions, LB/RB, both stick
    clicks, Start/Back) — correct on both passes
  • Both analog triggers — correct on both passes (Windows' legacy
    DirectInput test panel shows them combined onto one Z-axis, which is
    standard Xbox-360 behavior, not specific to this change)
  • Both analog sticks, full range on all 4 axes — correct on both passes
  • Rumble (strong + weak motor, independently and together) — confirmed
    via raw USB report injection and via the app's Diagnostics rumble
    tester

Checklist

  • My code follows the project's style guidelines (ktlint, detekt,
    Android lint all green locally; clang-format skipped locally —
    not installed at the pinned 22.1.4 version — but the change is a
    single-line table row matching the exact formatting of every
    neighboring row, and CI's own clang-format gate will catch it if not)
  • I have performed a self-review of my code
  • Any non-obvious code explains why, not what (no comment added —
    every other row in this table is undocumented data, so a
    per-row comment would be inconsistent with the rest of the file)
  • I have made corresponding changes to documentation (CHANGELOG.md)
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my
    feature works — not applicable, see note above; hardware
    verification stood in for a new automated test
  • New and existing unit tests pass locally with my changes
    (174/174 native host tests, JVM unit tests, assembleDebug)

…st lane

Its wired mode reports a plain 20-byte XInput frame, byte-for-byte
identical to a real Xbox 360 pad, and its interface 0 even matches the
generic vendor-class 0xFF/0x5D/0x01 XInput sniff already in classifyDevice.
Confirmed on hardware: every button, both triggers, both sticks, and
rumble all decode/drive correctly via the existing Parser::XINPUT_360
path, end-to-end through the app into a satellite host.
@MichaelWilsonSNC

Copy link
Copy Markdown
Contributor Author

Sorry, noticing problems.... closing.

@MichaelWilsonSNC

Copy link
Copy Markdown
Contributor Author

While testing this on real hardware, I found that the Amazon Luna Controller doesn't send a continuous report stream at rest the way most wired XInput-class pads do — a real Xbox 360 controller emits dozens of reports per second even sitting untouched, but Luna appears to be purely event-driven: it only sends a report when a button, stick, or trigger actually changes. I confirmed this directly: reading from the controller for 8 seconds while leaving it completely untouched produced exactly one report (presumably whatever was already buffered) followed by 40 consecutive timeouts — no further data for the full 8 seconds.

This interacts badly with probeDecodable() in usb_host.cpp, which claims Direct mode by waiting up to ~4 consecutive 80ms timeouts (roughly 320ms) for a decodable report before giving up. Since Luna sends nothing at all while idle, switching to Direct mode fails with "Direct mode unavailable" unless the controller happens to be actively moving something during that exact window. Wiggling a stick or pressing a button while making the switch works reliably — the byte format itself decodes correctly via the existing XINPUT_360 parser once a report actually arrives, so this isn't a decode bug, just a probe-timing mismatch with this specific controller's idle behavior.

TLDR: Direct mode works great with this change, the user just needs to be wiggling a stick while it binds or it will fail.

@MichaelWilsonSNC

Copy link
Copy Markdown
Contributor Author

I'll leave it to you @emir-hasanbegovic if you want to merge this, I'm not sure how many luna users you have on your app.

@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

@MichaelWilsonSNC missed this. Reviewing today!

@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

@MichaelWilsonSNC Reviewed, and your diagnosis is exactly right. Thanks for the careful hardware work, the byte-offset capture pass especially.

I checked what the reference stacks do with this device. The Linux xpad driver has carried 1949:041a since 5.13 as a plain XTYPE_XBOX360 table entry, no quirks, no init packet, and SDL's controller_list.h maps the same ID to the Xbox 360 type. Both are event-driven end to end, so report-on-change-only idle behavior is invisible to them. Our attach probe was the only thing in the pipeline that demanded idle traffic, and there is no documented wake packet for this protocol family that we could send instead.

So the fix belongs on our side, and it's up as #158: the probe is now tri-state. A decoded report claims as before, traffic that never decodes still releases, and pure silence claims only when the model is in the hardware-verified table, which your row makes true for the Luna. Unverified and descriptor-sniffed models still require a decoded report. The steady-state poll loop already tolerates silence after claim (URBs just stay pending), which lines up with what you saw once bound.

Your PR stays as-is apart from two mechanical things I'll push to this branch (thanks for leaving maintainer edits on): a merge with main to resolve the CHANGELOG conflict that grew under it, and moving the new row a few lines down into the third-party vendor block so the Microsoft grouping stays contiguous.

Once #158 is merged and this branch is updated, would you re-verify on your hardware, no wiggle anywhere: (1) plug in while idle and let it auto-claim Direct, (2) toggle Standard to Direct while idle, (3) toggle while moving a stick? If all three bind, I'll merge.

One note for the record from the research: the wired Luna has a second USB personality, 1949:0419, a plain HID "DirectInput mode" (SDL's HIDAPI driver targets it, and it's what a bare Windows box sees before Amazon's driver switches modes). If a unit ever enumerates that way on Android it won't match this row and falls to the generic HID descriptor path, which is the right outcome.

emir-hasanbegovic added a commit that referenced this pull request Aug 15, 2026
…158)

## Description

The USB Direct attach probe treated ~320ms of endpoint silence as proof
the chosen parser was wrong and released the device back to the
framework. That assumption held for every current verified table entry
because wired XInput-class pads stream reports at rest. The Amazon Luna
Controller (wired USB, `1949:041A`, #153) is event-driven: it sends
nothing until a button, stick, or trigger changes, so switching an idle
Luna to Direct failed unless the user happened to be moving a stick
during the probe window, and putting the model on the verified fast lane
would make the plug-in auto-claim fail the same way.

`probeEndpoint` (was `probeDecodable`) now distinguishes three outcomes,
and `probePermitsClaim` in `usb_parsers` decides per trust level:

- A decoded report, or a recognized wireless link event, claims at any
trust level.
- Traffic that never decodes releases at any trust level: bytes in the
wrong shape are positive evidence of a wrong parser.
- Pure silence claims only a model in the hardware-verified `kKnown`
table. Imported and descriptor-sniffed models still require a decoded
report, because for them the probe is the only verification.

This mirrors what the reference stacks do with this device class: the
Linux xpad driver (5.13+) and SDL both classify the Luna by ID alone and
are event-driven end to end, with no idle-traffic requirement. Neither
has a wake or init packet we could send instead; none exists for this
protocol family.

The steady-state poll loop is already silence-tolerant (interrupt URBs
stay pending until data arrives; only ENODEV exits), so a claimed idle
pad is healthy. Behavior for every currently verified model is
unchanged, since they all stream at rest.

Prepares for #153, which puts the Luna on the verified fast lane.

## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Refactor (code change that neither fixes a bug nor adds a feature)
- [ ] Documentation update

## How Has This Been Tested?

- [x] Unit tests
- [ ] Instrumented tests
- [ ] Manual testing on device/emulator

Native host suite 321/321 passing, including three new `ProbeRule` cases
covering the outcome/trust truth table. `:app:externalNativeBuildDebug`
compiles clean on both ABIs. clang-format 22.1.4 clean on all touched
files.

Hardware verification on an actual Luna is requested in #153 from
@MichaelWilsonSNC, who has the device: idle plug-in auto-claim, idle
Standard-to-Direct toggle, and toggle while active.

## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] Any non-obvious code explains why, not what (no new comments;
rationale is in this PR and the commit message)
- [x] I have made corresponding changes to documentation
(`CHANGELOG.md`)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
@emir-hasanbegovic
emir-hasanbegovic merged commit 6128b6c into TinkerNorth:main Aug 16, 2026
10 checks passed
@MichaelWilsonSNC

Copy link
Copy Markdown
Contributor Author

Ah, I think we're online at the same time. I was just verifying it with your changes here, and I'm noticing another odd quirk. When I plug in the controller, dish seems to want to connect to it over bluetooth and not USB. If you go into the setup guide and choose Wired then yes, it does seem like the Direct is working fine now even leaving the controller idle during setup.
image

emir-hasanbegovic added a commit that referenced this pull request Aug 17, 2026
…6.5) (#160)

The 1.1.0 tag run failed in two independent jobs ([run
32032681945](https://github.com/TinkerNorth/dish-android/actions/runs/32032681945)).

## Play metadata gate

`Upload AAB to Google Play` failed the pre-upload lint: every locale was
missing `changelogs/10100.txt`. The notes had been staged as `10002.txt`
for a 1.0.2 that never shipped, then the release went out as 1.1.0
(versionCode 10100).

- Renamed `10002.txt` to `10100.txt` in all 5 locales.
- Reworked the notes to also cover the Amazon Luna support (#153) that
landed in this range; the header and bullets now match the release scope
(Steam Controller, Luna, PDP wired Switch fix) while staying under
Play's 500-char cap.
- Dated the CHANGELOG `[Unreleased]` section as 1.1.0.

Validated locally: `EXPECTED_VERSION_CODE=10100 python
scripts/check_play_metadata.py` reports 0 errors (the 512x512 icon
warnings are the known open store item).

## Harden job: cosign keyless signing

`cosign sign-blob` died with `fetching ambient OIDC credentials: invalid
character 'u' looking for beginning of value`: the pinned cosign 2.4.1
(Nov 2024) failed to parse a non-JSON response from the Actions OIDC
token endpoint. The same job passed on July 25, so this broke on the
service side, not in the workflow.

- Bumped the pinned cosign to 2.6.5, the current release of the
maintained v2 line (Aug 6, 2026, includes the GHSA-fx35-mq7g-6g98 fix).
The `sign-blob --output-signature/--output-certificate` CLI used here is
unchanged in v2; v3 was deliberately avoided because it changes
sign-blob's output format.
- `COSIGN_SHA256` verified by downloading `cosign-linux-amd64` and
matching it against the upstream `cosign_checksums.txt`.

## Re-releasing after merge

The 1.1.0 tag still points at the broken snapshot (`d3006c0`). After
merging:

```
git fetch origin
git tag -f 1.1.0 origin/main
git push origin 1.1.0 --force
```

The tag push reruns the release workflow with these fixes in the tagged
tree.

One remaining risk outside this PR: if the Play app record has never had
a build uploaded manually, the upload step can still fail; the Play API
cannot create the first build of an app (see the note in release.yml).
emir-hasanbegovic pushed a commit that referenced this pull request Aug 18, 2026
…y) (#162)

## Description

`decodeXInput360` (the shared decoder for every `Parser::XINPUT_360` /
`XINPUT_360_WIRELESS` device in the `kKnown` table — genuine Xbox 360
controllers and its many licensed clones, plus the Amazon Luna
Controller) never read byte 3 bit `0x04` of the wired report. That bit
is the standard Guide/Xbox button position in the XInput wired
protocol (the same bit Linux's `xpad` driver treats as `BTN_MODE`); the
other bits in that byte (`0x01`/`0x02` bumpers, `0x10..0x80` face
buttons) were already decoded, but `0x04` was dead, so any pad's
center/Guide button silently produced no input at all instead of
`XUSB_GUIDE`.

Found on real hardware: a live raw-report capture from an Amazon Luna
Controller (USB VID:PID `1949:041A`, wired mode, from #153) showed
byte 3 going to exactly `0x04` and nothing else when its center button
was pressed, with A/B/X/Y independently confirmed against their own
existing bits (`0x10`/`0x20`/`0x40`/`0x80`) in the same capture. Fix is
a single added line, decoding that bit the same way every other button
in the function already is, with no change to the report layout or any
other bit.

No wire-protocol changes; nothing to coordinate with `satellite`,
`dish-linux`, or `dish-mac`.

Scope note for review: this is a generic decoder fix, not a Luna-only
special case, so it affects every device riding `XINPUT_360`/
`XINPUT_360_WIRELESS` in `kKnown` (Mad Catz, PDP, Hori, PowerA,
Afterglow, dance pads, fight sticks, etc.). Pads without a physical
Guide button never assert that bit in hardware, so this is a no-op for
them. The only theoretical residual risk is an unlicensed clone that
reused bit `0x04` for some other custom function instead of leaving it
unused; if one exists, worst case is that button now also raises
Guide/Home, not a crash or corrupted input.

## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Refactor (code change that neither fixes a bug nor adds a feature)
- [ ] Documentation update

## How Has This Been Tested?

- [x] Unit tests
- [ ] Instrumented tests
- [x] Manual testing on device/emulator

Added `Decode.Xbox360WiredGuideButtonSetsGuideBit` to
`usb_parsers_test.cpp`, asserting byte 3 `0x04` sets `XUSB_GUIDE`
through `decodeReport(Parser::XINPUT_360, ...)`. Full native host
suite: 241/241 passing (`ctest` across all `app/src/test/cpp` binaries).

Manual testing: real Amazon Luna Controller, wired directly to an
Android phone running dish, Direct mode. Captured raw USB interrupt
reports for A/B/X/Y and the center button before the fix (center
button produced no bit at all in `wButtons`); after the fix, rebuilt
and reinstalled the app and confirmed the center button now reaches
the Guide/Home action end-to-end through a `satellite` host.

## Checklist
- [x] My code follows the project's style guidelines (ktlint/detekt
      untouched — no Kotlin changed; clang-format skipped locally, not
      installed at the pinned 22.1.4 version, but the change is a
      single line matching the exact formatting of every neighboring
      line in the same `if` chain, and CI's own clang-format gate will
      catch it if not)
- [x] I have performed a self-review of my code
- [x] Any non-obvious code explains why, not what (no comment added —
      the fix reads identically to every other bit check in the same
      function, so a comment here would be inconsistent with the rest
      of the file)
- [x] I have made corresponding changes to documentation
      (`CHANGELOG.md`, under `[Unreleased]`)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
      feature works
- [x] New and existing unit tests pass locally with my changes
      (241/241 native host tests, `assembleDebug`)

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
emir-hasanbegovic added a commit that referenced this pull request Aug 18, 2026
A dual-mode pad already connected over Bluetooth that is then plugged in
over USB is tracked by UsbGamepadManager but never surfaces anywhere: the
attach-time auto path pick deliberately stays silent without permission,
so the entry parks in Routed with no framework id and no synthetic, and
no card offers the wired path (reported on the Amazon Luna Controller in
PR #153 discussion). These tests pin that mechanism at the FSM level so
the fix has the behavior it builds on spelled out; the FSM itself is
intentionally left unchanged.
emir-hasanbegovic added a commit that referenced this pull request Aug 18, 2026
A dual-mode pad connected over Bluetooth and then plugged in over USB
kept streaming over Bluetooth with nothing on its card acknowledging
the cable: transport resolution lets Bluetooth win, Bluetooth cards
hide the whole Standard/Direct section, and the tracked USB entry sits
in Routed with no framework or synthetic device representing it. The
only route to wired was knowing to walk the setup guide's Wired branch
(reported on the Amazon Luna Controller in the PR #153 discussion).

The card now surfaces that tracked-but-unrepresented USB entry: a
"USB available" pill on the connection row plus a "Use wired" action
that opens the wired setup flow, which already owns enumeration, the
permission prompt, and claim recovery. Association is by vendor, since
dual-mode pads may enumerate a different product id per link, and by
exact framework id for pads that report the same id on both links.
Nothing auto-switches: plugging in just to charge while playing over
Bluetooth behaves exactly as before, and the path FSM is untouched.
emir-hasanbegovic added a commit that referenced this pull request Aug 18, 2026
… plugged in (#163)

## Description

A pad connected over Bluetooth and then plugged in over USB showed only
a Bluetooth badge with no way to reach the wired path from the card
(#153 comments, Amazon Luna Controller). The plugged pad is tracked by
UsbGamepadManager but has no framework or synthetic device, so nothing
rendered it.

- New `wiredUsbPresentFor` in `ui/main/WiredUsbPresence.kt`: detects
that tracked-but-unrendered USB entry and ties it to the Bluetooth card
by vendor id, or by exact framework id when both links report the same
VID:PID.
- `PathCard` gains `wiredSwitchAvailable`; the card shows a "USB
available" pill and a "Use wired" button that opens the existing wired
setup flow.
- No auto-switching. Path FSM unchanged. Strings added in all six
locales, CHANGELOG under [Unreleased].

Commit 1 adds characterization tests that pass on `main` and pin the
pre-fix behavior; commit 2 is the fix plus its tests.

## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Refactor (code change that neither fixes a bug nor adds a feature)
- [ ] Documentation update

## How Has This Been Tested?

- [x] Unit tests
- [ ] Instrumented tests
- [ ] Manual testing on device/emulator

28 new tests: `UsbDualPresenceGhostTest` (9, FSM behavior before and
after, unchanged), `WiredUsbPresenceTest` (13, the new signal),
`PathCardMapperTest` (+6, card gating). ktlintCheck and detekt clean
locally; full suite runs in CI.

## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] Any non-obvious code explains why, not what
- [x] I have made corresponding changes to documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

@MichaelWilsonSNC I merged a fix for the bluetooth. I meant to just check it but I merged it by accident. Feel free to test the bluetooth thing off of main

@MichaelWilsonSNC

Copy link
Copy Markdown
Contributor Author

It looks like this with the latest, is this right? It seems like if USB is available, it should just use it.
image

@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

That was the intention, try it out, does it work as expected? any feedback?

@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

Sorry I think I see what you mean now.

If the controller is connected via Bluetooth to the phone then it won't change until you plug it in via usb.

If it is plugged in via usb and still showing up via Bluetooth, that is unexpected. It might be a quirk of that controller, or your phone, or both. I'll test a little more on my end with other devices.

Can you confirm the state of your controller connection to your device before the app (is it connected via bluetooth?)

and when you see that, is it connected via usb ?

Note also that nothing gets "used" until you complete the binding.

@emir-hasanbegovic

emir-hasanbegovic commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

My understanding was that this is a case of the Luna specifically:

The Amazon Luna Controller stays paired, input keeps arriving over BT, and the cable is only power. dish never initiates the BT link either, so there's nothing on our side to tear down.

You need to "claim" the wired before you can confirgure the binding

@MichaelWilsonSNC

Copy link
Copy Markdown
Contributor Author

Yep, you're right, this all makes sense to me. Thanks for the tweaks, appreciate it.

I think you got a really cool project here and I've been happy to submit 2 cents on it. I originally bought the Luna with the hopes that somebody would be able to use the onboard wifi for this exact idea - utilize wifi to make a direct connection to your gaming PC. Your app has realized that, and in a controller agnostic way. 🤘

Cheers!

@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

Super happy to see it being used!

Feel free to recommend features!

@emir-hasanbegovic

Copy link
Copy Markdown
Contributor

Also a review on the app store would be delicious!

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.

2 participants