Skip to content

Logitech G PRO X 2 LIGHTSPEED headset over its receiver (Centurion, #103), unit tests - #124

Merged
HeyOkay merged 3 commits into
HeyOkay:mainfrom
ahmedkhursheed23:logitech-prox2
Oct 5, 2026
Merged

HeyOkay merged 3 commits into
HeyOkay:mainfrom
ahmedkhursheed23:logitech-prox2

Conversation

@ahmedkhursheed23

@ahmedkhursheed23 ahmedkhursheed23 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Confirmed on hardware (#103, test build 1.12.0.2): 75% not charging, and the charging state, both equal to G HUB.

This PR adds the Logitech G PRO X 2 LIGHTSPEED headset on its receiver (046D:0AF7, #103). The diagnostics in #103 list the receiver with three collections on interface 3: ff13:0001, ffa0:0001 and 000c:0001. This headset does not use HID++ on an ff43 collection, so logitech.py does not see it. It uses Logitech's "Centurion" transport on ffa0:0001. That is a new provider, providers/logitech_centurion.py.

Base and other PRs

Sources

  1. Solaar at e7304c4, which supports this headset:
    • lib/logitech_receiver/base.py: the frame, 51 <len> <flags> <payload>, len = payload length + 1;
    • device.py, centurion_bridge_request(): the bridge message, the acknowledgement, the answer, and the check on the echoed function byte;
    • centurion.py, decipher_battery_centurion(): byte 0 = percent, byte 2 = charging state (0 discharging, 1 charging, 2 charging over USB, 3 charge complete);
    • docs/devices/PRO X 2 LIGHTSPEED 0AF7.text: a dump of a real headset. Receiver features: 0 Root, 1 FeatureSet, 2 0100, 3 CentPP bridge, 4 010A. Headset features: ... 4 0104 battery.
  2. HeadsetControl at 25dadae: lib/devices/logitech_gpro_x2_lightspeed.hpp and lib/devices/protocols/logitech_centurion_protocol.hpp. It uses the same discovery and bridge. For firmware without 0104 it has a fixed request, 51 08 00 03 1a 00 03 00 04 0a. That request is a bridge message to bridge index 3, headset feature 4, with software id 0x0A, which agrees with the Solaar dump.

Where the two sources are different: HeadsetControl frames its direct (receiver) requests from a zero-padded 64-byte buffer, so the length byte is 0x41. Solaar uses the real length (for example 51 05 00 00 01 00 01 for "where is FeatureSet"). The HeadsetControl PR (#512) says the code was written with an AI model from the Solaar-NG notes. Solaar has a dump from real hardware. This provider uses Solaar's framing. The bridge frames are the same in both.

What the provider sends

All requests read. No function that changes a setting is sent (a test checks each write).

  1. Receiver: Root fn 0 "where is 0001" → FeatureSet index; FeatureSet fn 0 → count; FeatureSet fn 1 for each index → feature id, until the bridge (0003).
  2. Headset, through the bridge: the same three steps → the headset's feature table.
  3. Battery: bridge request to the index of 0104, fn 0.

The feature tables are found once and remembered. After that, each poll sends one request. After an error the tables are found again on the next poll.

Replies:

  • A direct reply must echo the feature index and the function byte. An error reply (ff <index> <function> <code>) ends the attempt.
  • For the bridge, the receiver's acknowledgement is skipped. The answer must have sub_cpl = 00 (ff = a notification from the headset), the requested feature index and the echoed function byte. ff <index> = the headset rejected the request.
  • Acknowledged but no answer = the headset is off → no icon (the README: a switched-off headset leaves the tray).
  • A level above 100: no icon. Charging states 1, 2 and 3 = on the cable.
  • Up to 1.5 s for each answer (HeadsetControl waits up to 5 s per read, Solaar 4 s).

Tests

tests/test_logitech_centurion.py (new, 17 tests). The fake receiver has the feature layout of Solaar's dump and answers direct and bridge requests.

  • Device request Logitech Pro X Lightspeed Headset #103: 80%, kind="headset".
  • Exact frames: the direct frame (51 05 00 00 01 00 01); the battery bridge frame is HeadsetControl's fixed frame with software id 1.
  • Only reading functions are written, over two polls.
  • Discovery runs once. After an error, it runs again.
  • Charging states 0-4.
  • A headset that is off (acknowledgement only) gives no icon; later it is found. A headset switched off after a reading leaves the tray.
  • A level above 100 is refused.
  • Legacy firmware: the fixed request, a "power off" report, and other 51 0b events are skipped.
  • Skipped: other frames, a headset notification, a stale reply for another function (bridge and direct).
  • A receiver error reply ends the attempt.
  • Only ffa0:0001 of 0AF7 is opened.

Mutation checks: I changed each rule, and each time at least one test failed. All tests pass. A live --probe on a PC with a G502 on a Lightspeed receiver shows no change.

Closes #103 when confirmed.

Centurion transport on ffa0:0001, report 0x51: receiver feature discovery,
the CentPP bridge (0x0003), headset feature discovery, battery 0x0104. Framing
as Solaar (e7304c4, base.py/device.py/centurion.py; its dump of a real 0AF7
has the bridge at 3 and the battery at 4); HeadsetControl's fixed request for
firmware without 0x0104.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmGsnjgXs8VbKmmxLkPsf6
@casparas123

Copy link
Copy Markdown
Contributor

Heads-up from a fellow PR author: I've opened #126, which shortens the README's support table to Device | Connection | Verified on hardware and moves the per-device protocol detail to a new docs/protocols.md, one section per device.

Our trial merge shows #126 conflicts with this PR in README.md only. The fix is mechanical: the row you added in the old format becomes a short row in the new table, and the detail text from that row becomes a section in docs/protocols.md. If it helps, I can prepare both as a patch you can drop in, or rebase this branch for you - whichever is easier. Just didn't want the future conflict to be a surprise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmGsnjgXs8VbKmmxLkPsf6
@HeyOkay

HeyOkay commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Thanks for this! PR #107 and a batch of other PRs were just merged into main (the listen-only PlayStation handling from e80dfd1 is kept, and make_providers() in halo_battery.pyw is now the single provider list), so this PR now conflicts with main. Could you merge main into it or rebase it? A new provider also needs an entry in make_providers() and PROVIDER_LABELS (the Device types menu) — test_every_provider_has_a_label checks that. Once it is green on top of main I'll merge it for 1.13.0.

@casparas123

Copy link
Copy Markdown
Contributor

I trial-merged this against today's main: conflicts in CHANGELOG.md and halo_battery.pyw, both mechanical.

  • halo_battery.pyw: the new provider class needs entries in both make_providers() and PROVIDER_LABELS (the new test_every_provider_has_a_label requires both), plus the providers/__init__.py import.
  • CHANGELOG.md: append the bullet under [Unreleased] -> Added.

Full suite passes after that (439 tests).

@HeyOkay HeyOkay added this to the v1.14.0 milestone Sep 29, 2026
Resolved CHANGELOG.md (bullet under Unreleased), README.md (short row, detail in
docs/protocols.md) and halo_battery.pyw (LogitechCenturionProvider in
make_providers() and PROVIDER_LABELS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmGsnjgXs8VbKmmxLkPsf6
@ahmedkhursheed23

Copy link
Copy Markdown
Contributor Author

Merged main (1.13.0) into this branch; it is mergeable again. LogitechCenturionProvider is in make_providers() and PROVIDER_LABELS ("Logitech G PRO X 2 LIGHTSPEED"); main's Logitech provider does not claim 0AF7. README row yes (verified in #103), detail in docs/protocols.md. Full suite: OK.

@HeyOkay
HeyOkay merged commit 1d81919 into HeyOkay:main Oct 5, 2026
HeyOkay added a commit that referenced this pull request Oct 5, 2026
casparas123 pushed a commit to casparas123/HaloBattery that referenced this pull request Oct 5, 2026
casparas123 pushed a commit to casparas123/HaloBattery that referenced this pull request Oct 5, 2026
…S 06BE

- The six new device rows from HeyOkay#122, HeyOkay#123, HeyOkay#124, HeyOkay#125 and HeyOkay#141 go to
  docs/devices.md, where HeyOkay#128 moved the table.
- tests/test_steelseries_elite.py: find make_providers( with its new
  parameter from HeyOkay#134.
- Cloud III S: the 06BE revision and the charging state are confirmed in
  HeyOkay#156 (docs/protocols.md, CHANGELOG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011GfhP6tYaD8Rt4qP2YtmJ4
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.

Device request Logitech Pro X Lightspeed Headset

3 participants