Compare commits

..

No commits in common. "master" and "1.1.15" have entirely different histories.

151 changed files with 9171 additions and 32334 deletions

View File

@ -8,23 +8,13 @@ assignees: ''
---
**Information**
<!-- Make sure that your issue is not one of the known issues in the
Solaar documentation at https://pwr-solaar.github.io/Solaar/ -->
<!-- Make sure that Solaar's udev rule is running by executing
`ls -l /dev/hidraw*` and looking for + as the last character of the permissions. -->
<!-- Do not bother opening an issue for a version older than 1.1.14.
Upgrade to the current version and see if your issue persists. -->
<!-- If you are not running the current version of Solaar,
strongly consider upgrading to the current version. -->
<!-- Note that some distributions have very old versions of Solaar
as their default version. -->
<!-- Make sure that your issue is not one of the known issues in the Solaar documentation at https://pwr-solaar.github.io/Solaar/ -->
<!-- Do not bother opening an issue for a version older than 1.1.8. Upgrade to the latest version and see if your issue persists. -->
<!-- If you are not running the current version of Solaar, strongly consider upgrading to the newest version. -->
- Solaar version (`solaar --version` or `git describe --tags` if cloned from this repository):
- Distribution:
- Kernel version (ex. `uname -srmo`):
- Kernel version (ex. `uname -srmo`): `KERNEL VERSION HERE`
- Output of `solaar show`:
<!-- To run `solaar show` in 1.1.18 you have to clone Solaar from this repository
and `run bin/solaar show` from the download directory. -->
<details>
@ -44,11 +34,11 @@ CONTENTS HERE
- Errors or warrnings from Solaar:
<!-- Under normal operation Solaar keeps a log of warning and error messages
in ~/.tmp while it is running, as a file starting with 'Solaar'.
<!-- Under normal operation Solaar keeps a log of warning and error messages in ~/.tmp
while it is running as a file starting with 'Solaar'.
If this file is not available or does not have useful information you can
run Solaar as `solaar -ddd`, after killing any running Solaar processes to
have Solaar log debug, informational, warning, and error messages to stdout. -->
run Solaar as `solaar -dd`, after killing any running Solaar processes to
have Solaar log informational, warning, and error messages to stdout. -->
**Describe the bug**

View File

@ -8,7 +8,7 @@ jobs:
strategy:
matrix:
python-version: [3.13]
python-version: [3.8, 3.13]
fail-fast: false
steps:
@ -54,7 +54,7 @@ jobs:
strategy:
matrix:
python-version: [3.13]
python-version: [3.8, 3.13]
fail-fast: false
steps:

View File

@ -1,124 +1,3 @@
# 1.1.20rc2
* Mock libnotify to not perform notifications when doing tests
* Isolate testing from real configuration file
* Update handling of headset RGB controls
* Update equalizer processing
* Hide read-only paramaters from the UI
* Better support for G522 Lightspeed headset
* Use mostly full names for direct-USB codenames
* Use correct icon for CENTURION devices
* rgb_control: honor the off state — don't auto-claim, init, or shutdown LEDs
* base: fix sw_id at 0x0B instead of rotating 0x2..0xF (#3218)
* perkey/canvas: allow rect/gradient anchors in grid gaps
* config_panel: don't show failed-write alert for unreadable settings
* listener: share bluez-watch wiring across Centurion-direct and standard device paths
* rgb_power.perkey_has_paint: gate on IGNORE only, not on != True
* about: add Ken Sanislo to Additional Programming credits
* Add RGB lighting persistence and software LED power management for G515
* ui: Show offline status for receiver-paired device batteries (#3217)
* LEDControl / RGBControl: render as Gtk.Switch instead of a 2-option combo (#3215)
* device: Fix operator precedence bug and end-of-configuration timing in device.changed() (#3173)
* PerKeyLighting: drop misleading live-read output in solaar show
* perkey: label G502 X LEDs by zone id, not letter
* PerKey gradient swatch: align gradient endpoints to visible corners
* PerKey gradient swatch: Tabler "square" outline around the gradient
* PerKey dialog: one window per device, keyed by firmware unit-id
* PerKey dialog: size window from measured natural size
* PerKey icons: read theme fg from style-updated, not Settings notify
* PerKey canvas: symmetric hash stripes for unset cells
* PerKey palette: replace hashed unset swatch with palette-off icon
* PerKeyEditor: rebuild tool icons on GTK theme change
* PerKeyEditor: replace tool button labels with icons
* common: render RGB color values as 0xrrggbb in config and solaar show
* Better display of LED effects for some devices in solaar show.
* Fix bug affecting using solaar config to change range-based settings.
* Add regional keyboard layouts
* Use per-key RGB color painter
* Fix bug in notification flag handling
* Fix bug in HID parser
* Update Swedish, German, Polish, Chinese translations
* Use battery-level-N icons when available
* Document haptic capabilities
* Support per-slot unpair on Lightspeed receivers
* Fix bugs related to integer flags in older versions of Python
* Add mention of Centurion protocol support
* Treat empty hidraw read as device removal (EOF) (#3174)
* fix interface for K845
* support PRO X 2 LIGHTSPEED headphones Centurion features (#3150)
* Fix crash in NotificationFlag.flag_names when flags is None (#3185)
* Add PRO X 2 Superstrike mouse support with HITS tuning settings (#3132)
* Add names for some HID++ 2.0 features and sort by ID (#3153)
* Don't use Logitech for codename
* Put lock around getting device name
* Fix bug when showing device notification flags
* Be defensive about no device features
* Add feature x1b04 flag sent by M510 4004
* Remove incorrect descriptor for WPID 4004
* Better handling of missing devices
* Improve RHEL installation guide and add automated install example (#3162)
* Remove use of XTest and use uinput in all cases
* Add installation guide for Solaar on RHEL 10 (#3158)
* Use console_scripts entry point for pipx compatibility
* Skip Logitech webcams to prevent them from locking up during HID++ checks on Macs
* Downgrade ping no such device to informational log entry
* Recover from guessing the wrong number for direct-connected HID++ 1.0 devices
* Tolerate devices with no unitId
* Correctly handle timeout in Bolt discovery
* Update or add fr, pt_BR, sk, Ukrainian, Finnish, Bulgarian translations
* Handle missing receiver_path more gracefully
* Handle inaccessiable devices when determining protocol
* Be defensive when showing features in solaar show
# 1.1.19
* New Georgian translation
* Remove test that doesn't work in older Pythons
* Update help messages for CLI commands
* Allow solaar config to change LED settings
* Add python3-devel to install-dnf in Makefile
* hidconsole can send an HID command non-interactively
* Add info about new lightspeed receiver
* Update Swedish and zh_TW translation
* Fix bug when showing details about direct-connected device
* Drop testing of Python before 3.13
* Fix crash in solaar show when showing notification flags. (#3070)
# 1.1.18
* Fix crash when showing notification flags
# 1.1.17
* Add dark icons
* Permit onboard profiles version 5
* Update Russian and Polish translations
* Add onboard profiles warning to sensitivity tooltip
* Better error messages for solaar profile
* Remove Solaar name for mice with WPID 4008
* Prevent lock failure when showing debug messages
* Replace color picker (#3028)
* Add setting for HAPTIC feature
* Add setting to adjust force needed for force-sensing buttons
* Expand new settings type
* Add new settings type for structure-backed setting
* Use PATH instead of hardcoded absolute paths (#3014)
* Add scroll ratchet force setting
* Fix debug messages for MouseClick rule
* Improve debug message for rule evaluation
* App wrapper and launch agent scripts for MacOS
* Ignore hidden features
* Don't pop up window in response to ADC changes
* Update bug report template
* Fixed malformed doc file by adding closing tag
* Fix error in low-level request for device with no recevier
* Update documentation files
# 1.1.16
* Add new flags for reprogrammable keys feature
* Correctly handle missing battery feature
# 1.1.15
* Correctly re-raise permissions exception

View File

@ -25,8 +25,8 @@ install_apt_python3.13:
sudo apt install libdbus-1-dev libglib2.0-dev libgtk-3-dev libgirepository-2.0-dev gobject-introspection
install_dnf:
@echo "Installing Solaar dependencies via dnf"
sudo dnf install gtk3 python3-devel python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
@echo "Installing Solaar dependencies via dn"
sudo dnf install gtk3 python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
install_brew:
@echo "Installing Solaar dependencies via brew"

View File

@ -10,8 +10,7 @@ that are otherwise ignored by the Linux input system.
<a href="https://pwr-solaar.github.io/Solaar/usage">Usage</a> -
<a href="https://pwr-solaar.github.io/Solaar/capabilities">Capabilities</a> -
<a href="https://pwr-solaar.github.io/Solaar/rules">Rules</a> -
<a href="https://pwr-solaar.github.io/Solaar/installation">Manual Installation</a> -
<a href="https://pwr-solaar.github.io/Solaar/issues">Known Issues</a>
<a href="https://pwr-solaar.github.io/Solaar/installation">Manual Installation</a>
[![codecov](https://codecov.io/gh/pwr-Solaar/Solaar/graph/badge.svg?token=D7YWFEWID6)](https://codecov.io/gh/pwr-Solaar/Solaar)

View File

@ -11,7 +11,7 @@ Release routine:
- Add release changes to `CHANGELOG.md`
- Add release information to `share/solaar/io.github.pwr_solaar.solaar.metainfo.xml`
- Create a commit that starts with `release VERSION`
- Push commit to Solaar repository and merge it
- Push commit to Solaar repository
- Invoke `./release.sh`
- Git tags are signed so you must have GPG set up
- You are required to have a github token with `public_repo` access

175
RHEL.md
View File

@ -1,175 +0,0 @@
# Solaar installation guide for RHEL, Rocky, AlmaLinux, and CentOS Stream
This guide covers manual installation and an automated install example for
RHEL-family systems using `dnf`.
## Supported distributions
- Red Hat Enterprise Linux (RHEL)
- Rocky Linux
- AlmaLinux
- Oracle Linux
- CentOS Stream
The commands assume a minimal CLI system with `sudo` access.
## 1) Install dependencies
```bash
sudo dnf makecache --refresh
sudo dnf install -y \
git \
gtk3 \
python3 \
python3-devel \
python3-dbus \
python3-gobject \
python3-pip \
python3-psutil \
python3-pyudev \
python3-setuptools \
python3-xlib \
python3-yaml
```
Optional troubleshooting helpers:
```bash
sudo dnf install -y \
evemu \
libinput \
usbutils
```
## 2) Clone Solaar
```bash
git clone https://github.com/pwr-Solaar/Solaar.git
cd Solaar
```
## 3) Install Solaar
Install for the current user:
```bash
python3 -m pip install --user .
```
Or install system-wide:
```bash
sudo python3 -m pip install .
```
## 4) Install udev rules
Install the recommended `uinput` rule:
```bash
sudo make install_udev_uinput
```
Verify rule installation:
```bash
ls -l /etc/udev/rules.d/42-logitech-unify-permissions.rules
```
Rollback udev rule installation:
```bash
sudo make uninstall_udev
```
## 5) Run Solaar
```bash
solaar
```
or:
```bash
python3 -m solaar
```
## 6) Automated install options
Use the guided installer in this repository:
```bash
./tools/install-rhel.sh
```
Minimal non-interactive example script:
```bash
cat > install-rhel-solaar.sh <<'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${EUID}" -eq 0 ]]; then
echo "Run as a regular user with sudo access, not as root."
exit 1
fi
sudo dnf makecache --refresh
sudo dnf install -y \
git \
gtk3 \
python3 \
python3-devel \
python3-dbus \
python3-gobject \
python3-pip \
python3-psutil \
python3-pyudev \
python3-setuptools \
python3-xlib \
python3-yaml
if [[ ! -d Solaar/.git ]]; then
git clone https://github.com/pwr-Solaar/Solaar.git
fi
cd Solaar
python3 -m pip install --user .
sudo make install_udev_uinput
~/.local/bin/solaar --version
SCRIPT
chmod +x install-rhel-solaar.sh
./install-rhel-solaar.sh
```
## 7) Verification
```bash
command -v solaar
solaar --version
python3 -m pip show solaar
```
If installed with `--user`, ensure `~/.local/bin` is on your `PATH`:
```bash
echo "$PATH" | tr ':' '\n' | grep -Fx "$HOME/.local/bin" >/dev/null || \
echo 'Add ~/.local/bin to PATH'
```
## 8) Troubleshooting
Receiver not detected:
```bash
lsusb | grep -Ei 'logitech|046d'
sudo udevadm trigger
```
Check access to hidraw devices:
```bash
ls -l /dev/hidraw*
getfacl /dev/hidraw* 2>/dev/null | sed -n '1,80p'
```

View File

@ -1,30 +1,7 @@
# Notes on Major Changes in Releases
## Version 1.1.20
* Solaar has much better support for the LEDs on some newer devices, such as the G515 Lightspeed TKL.
* Solaar now supports the Centurion protocol, a variation of the HIDP++ protocol that is used by several headsets.
* Solaar now uses uinput for all simulated input, even in X11. As a result there is no need for a separate udev rule for Wayland, and it may be removed in future.
## Version 1.1.18
* Solaar is only guaranteed to work in Python 3.13 or later.
## Version 1.1.17
* Several new features have been added related to the MX Master 4
* The scroll ratchet force can be adjusted
* The force required to click the button under your thumb can be adjusted
* The haptic force can be adjusted
* Haptic feeback can be triggered by commands like `solaar config 'mx master 4' haptic-play 'HAPPY ALER'`
## Version 1.1.16
* Two bugs that were affecting users in 1.1.15 are fixed.
## Version 1.1.15
* Some key names have been changed to match Logitech names. Rules that use removed names will no longer work and will end up with a key of 0.
* Device and Action rule conditions match on device codename and name
* Solaar supports configuration of Bluetooth devices on macOS.

View File

@ -1,132 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations.
Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program.
You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License.
c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program.
In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License.
3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable.
If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance.
5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License.
7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances.
It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice.
This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation.
10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
one line to give the program's name and an idea of what it does.
Copyright (C) yyyy name of author
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, see
<https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details
type `show w'. This is free software, and you are welcome
to redistribute it under certain conditions; type `show c'
for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright
interest in the program `Gnomovision'
(which makes passes at compilers) written
by James Hacker.
signature of Moe Ghoul, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License.

View File

@ -1,374 +0,0 @@
# Logitech PRO X 2 Superstrike - Solaar CLI Reference
This document describes all available settings for the Logitech PRO X 2 Superstrike mouse via the Solaar CLI.
## Device Identification
| Property | Value |
|----------|-------|
| Name | PRO X 2 Superstrike |
| WPID | 40BD |
| Protocol | HID++ 4.2 |
| Kind | mouse |
## General CLI Syntax
```bash
# List all settings for device
solaar config <device>
# Read a specific setting
solaar config <device> <setting-name>
# Write a specific setting
solaar config <device> <setting-name> <value>
```
The `<device>` can be:
- Device number (e.g., `1`)
- Device name (e.g., `"PRO X 2 Superstrike"`)
- Serial number (e.g., `A1C55DB2`)
---
## Available Settings
### 1. Onboard Profiles
Controls whether the device uses its onboard profile or host-controlled settings.
| Property | Value |
|----------|-------|
| Setting Name | `onboard_profiles` |
| Type | Choice |
| Possible Values | `Disabled`, `Profile 1` |
**Commands:**
```bash
# Read current value
solaar config 1 onboard_profiles
# Set to disabled (allows host control of DPI, report rate, etc.)
solaar config 1 onboard_profiles Disabled
# Set to Profile 1 (use onboard profile)
solaar config 1 onboard_profiles "Profile 1"
```
**Note:** Many settings require `onboard_profiles` to be set to `Disabled` to be effective.
---
### 2. Report Rate
Controls the frequency of device movement reports.
| Property | Value |
|----------|-------|
| Setting Name | `report_rate_extended` |
| Type | Choice |
| Possible Values | `8ms`, `4ms`, `2ms`, `1ms`, `500us`, `250us`, `125us` |
**Commands:**
```bash
# Read current value
solaar config 1 report_rate_extended
# Set to 1ms (1000Hz)
solaar config 1 report_rate_extended 1ms
# Set to 500us (2000Hz)
solaar config 1 report_rate_extended 500us
# Set to 125us (8000Hz)
solaar config 1 report_rate_extended 125us
```
**Polling Rate Reference:**
| Value | Polling Rate |
|-------|--------------|
| `8ms` | 125 Hz |
| `4ms` | 250 Hz |
| `2ms` | 500 Hz |
| `1ms` | 1000 Hz |
| `500us` | 2000 Hz |
| `250us` | 4000 Hz |
| `125us` | 8000 Hz |
---
### 3. Sensitivity (DPI)
Controls mouse movement sensitivity.
| Property | Value |
|----------|-------|
| Setting Name | `dpi_extended` |
| Type | Complex (X, Y, LOD) |
| DPI Range | 100 - 32000 |
| LOD Values | `LOW`, `HIGH` |
**Commands:**
```bash
# Read current value
solaar config 1 dpi_extended
# Set DPI (format: {X:<value>, Y:<value>, LOD:<value>})
solaar config 1 dpi_extended "{X:800, Y:800, LOD:HIGH}"
# Set to 1600 DPI
solaar config 1 dpi_extended "{X:1600, Y:1600, LOD:HIGH}"
# Set different X and Y sensitivity
solaar config 1 dpi_extended "{X:800, Y:1600, LOD:LOW}"
```
---
## HITS Tuning Settings (Hall-Effect Inductive Trigger Switch)
These settings control the advanced click behavior of the PRO X 2 Superstrike's hall-effect switches.
### 4. Actuation Point
Controls how deep the button must be pressed to register a click.
| Property | Value |
|----------|-------|
| Setting Name (Left) | `superstrike-tuning_actuation-0` |
| Setting Name (Right) | `superstrike-tuning_actuation-1` |
| Type | Range |
| Range | 1 - 10 |
| Default | 5 |
**Value Interpretation:**
- `1` = Shallowest (hair trigger, minimal press)
- `10` = Deepest (full press required)
**Commands:**
```bash
# Read left button actuation
solaar config 1 superstrike-tuning_actuation-0
# Read right button actuation
solaar config 1 superstrike-tuning_actuation-1
# Set left button to shallow actuation (hair trigger)
solaar config 1 superstrike-tuning_actuation-0 1
# Set left button to deep actuation
solaar config 1 superstrike-tuning_actuation-0 10
# Set right button to medium actuation
solaar config 1 superstrike-tuning_actuation-1 5
```
---
### 5. Rapid Trigger Level
Controls the rapid trigger sensitivity, which allows the button to re-actuate quickly after partial release.
| Property | Value |
|----------|-------|
| Setting Name (Left) | `superstrike-tuning_rapid-trigger-level-0` |
| Setting Name (Right) | `superstrike-tuning_rapid-trigger-level-1` |
| Type | Range |
| Range | 1 - 5 |
| Default | 3 |
**Value Interpretation:**
- `1` = Fastest (most sensitive, smallest movement to re-trigger)
- `5` = Slowest (least sensitive, larger movement needed)
**Note:** Rapid trigger cannot be disabled on this device. The minimum level is 1.
**Commands:**
```bash
# Read left button rapid trigger level
solaar config 1 superstrike-tuning_rapid-trigger-level-0
# Read right button rapid trigger level
solaar config 1 superstrike-tuning_rapid-trigger-level-1
# Set left button to fastest rapid trigger
solaar config 1 superstrike-tuning_rapid-trigger-level-0 1
# Set left button to slowest rapid trigger
solaar config 1 superstrike-tuning_rapid-trigger-level-0 5
# Set right button to medium rapid trigger
solaar config 1 superstrike-tuning_rapid-trigger-level-1 3
```
---
### 6. Click Haptics
Controls the intensity of the haptic feedback when clicking.
| Property | Value |
|----------|-------|
| Setting Name (Left) | `superstrike-tuning_haptics-0` |
| Setting Name (Right) | `superstrike-tuning_haptics-1` |
| Type | Range |
| Range | 0 - 5 |
| Default | 3 |
**Value Interpretation:**
- `0` = Off (no haptic feedback)
- `1` = Minimal
- `2` = Light
- `3` = Medium
- `4` = Strong
- `5` = Strongest (maximum haptic feedback)
**Commands:**
```bash
# Read left button haptics level
solaar config 1 superstrike-tuning_haptics-0
# Read right button haptics level
solaar config 1 superstrike-tuning_haptics-1
# Disable haptics on left button
solaar config 1 superstrike-tuning_haptics-0 0
# Set left button to maximum haptics
solaar config 1 superstrike-tuning_haptics-0 5
# Set right button to medium haptics
solaar config 1 superstrike-tuning_haptics-1 3
```
---
## Complete Settings Summary
| Setting | CLI Name | Type | Range/Values | Button-Specific |
|---------|----------|------|--------------|-----------------|
| Onboard Profiles | `onboard_profiles` | Choice | `Disabled`, `Profile 1` | No |
| Report Rate | `report_rate_extended` | Choice | `8ms` to `125us` | No |
| Sensitivity | `dpi_extended` | Complex | 100-32000 DPI | No |
| Actuation Point | `superstrike-tuning_actuation-{0,1}` | Range | 1-10 | Yes |
| Rapid Trigger | `superstrike-tuning_rapid-trigger-level-{0,1}` | Range | 1-5 | Yes |
| Click Haptics | `superstrike-tuning_haptics-{0,1}` | Range | 0-5 | Yes |
---
## Batch Configuration Examples
### Gaming Profile (Fast Response)
```bash
#!/bin/bash
# Gaming profile: fast actuation, sensitive rapid trigger, medium haptics
solaar config 1 onboard_profiles Disabled
solaar config 1 report_rate_extended 125us
solaar config 1 dpi_extended "{X:800, Y:800, LOD:HIGH}"
# Left button - hair trigger
solaar config 1 superstrike-tuning_actuation-0 1
solaar config 1 superstrike-tuning_rapid-trigger-level-0 1
solaar config 1 superstrike-tuning_haptics-0 3
# Right button - hair trigger
solaar config 1 superstrike-tuning_actuation-1 1
solaar config 1 superstrike-tuning_rapid-trigger-level-1 1
solaar config 1 superstrike-tuning_haptics-1 3
```
### Productivity Profile (Comfortable)
```bash
#!/bin/bash
# Productivity profile: deeper actuation, slower rapid trigger, strong haptics
solaar config 1 onboard_profiles Disabled
solaar config 1 report_rate_extended 1ms
solaar config 1 dpi_extended "{X:1600, Y:1600, LOD:HIGH}"
# Left button - comfortable click
solaar config 1 superstrike-tuning_actuation-0 7
solaar config 1 superstrike-tuning_rapid-trigger-level-0 4
solaar config 1 superstrike-tuning_haptics-0 5
# Right button - comfortable click
solaar config 1 superstrike-tuning_actuation-1 7
solaar config 1 superstrike-tuning_rapid-trigger-level-1 4
solaar config 1 superstrike-tuning_haptics-1 5
```
### Silent Profile (No Haptics)
```bash
#!/bin/bash
# Silent profile: no haptic feedback
solaar config 1 superstrike-tuning_haptics-0 0
solaar config 1 superstrike-tuning_haptics-1 0
```
---
## Programmatic Usage
### Reading All Settings (JSON-like parsing)
```bash
# Get all settings as output
solaar config 1 2>/dev/null | grep "^[a-z]" | while read line; do
setting=$(echo "$line" | cut -d'=' -f1 | tr -d ' ')
value=$(echo "$line" | cut -d'=' -f2 | tr -d ' ')
echo "{\"setting\": \"$setting\", \"value\": \"$value\"}"
done
```
### Reading a Single Setting Value
```bash
# Extract just the value
solaar config 1 superstrike-tuning_actuation-0 2>/dev/null | grep "^superstrike" | cut -d'=' -f2 | tr -d ' '
```
### Error Handling
```bash
# Check if command succeeded
if solaar config 1 superstrike-tuning_actuation-0 5 2>/dev/null; then
echo "Setting applied successfully"
else
echo "Failed to apply setting"
fi
```
---
## Exit Codes
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Error (device not found, invalid setting, invalid value) |
---
## Notes
1. **Device Discovery**: Use `solaar show` to list all connected devices and their indices.
2. **Persistence**: Settings are saved to `~/.config/solaar/config.yaml` and automatically reapplied when the device reconnects.
3. **Onboard Profiles**: When `onboard_profiles` is set to `Profile 1`, some settings (DPI, report rate) are controlled by the device's onboard memory and cannot be changed via Solaar.
4. **HITS Settings**: The actuation, rapid trigger, and haptics settings are stored in the device and persist across reconnections, regardless of the onboard profile setting.
5. **Button Index**: `0` = Left button, `1` = Right button.

View File

@ -21,9 +21,9 @@ Not all such devices supported in Solaar as information needs to be added to Sol
for each device type that directly connects.
## HID++ and Centurion
## HID++
The devices that Solaar handles use Logitech's HID++ and Centurion protocols.
The devices that Solaar handles use Logitech's HID++ protocol.
HID++ is a Logitech-proprietary protocol that extends the standard HID
protocol for interfacing with receivers, keyboards, mice, and so on. It allows
@ -43,8 +43,6 @@ Contrariwise, two different devices may appear different physically but
actually look the same to software. (For example, some M185 mice look the
same to software as some M310 mice.)
Centurion is closely related to HID++ and is used by some Logitech headsets.
The software identity of a receiver can be determined by its USB product ID
(reported by Solaar and also viewable in Linux using `lsusb`). The software
identity of a device that connects to a receiver can be determined by
@ -58,12 +56,12 @@ Bluetooth product ID.
Solaar is able to pair and unpair devices with
receivers as supported by the device and receiver.
For Unifying and Bolt receivers, pairing adds a new paired device, but
For Unifying receivers, pairing adds a new paired device, but
only if there is an open slot on the receiver. So these receivers need to
be able to unpair devices that they have been paired with or else they will
not have any open slots for pairing. Some Nano and Lightspeed receivers, like the
Nano receiver with USB ID `046d:c534`, can only pair with one keyboard and one mouse
and pairing a new device replaces whatever device of that kind was
not have any open slots for pairing. Some other receivers, like the
Nano receiver with USB ID `046d:c534`, can only pair with particular kinds of
devices and pairing a new device replaces whatever device of that kind was
previously paired to the receiver. These receivers cannot unpair. Further,
some receivers can pair an unlimited number of times but others can only
pair a limited number of times.
@ -71,22 +69,20 @@ pair a limited number of times.
Bolt receivers add an authentication phase to pairing,
where the user has type a passcode or click some buttons to authenticate the device.
Only some connections between receivers and devices are possible. It should
Only some connections between receivers and devices are possible. In should
be possible to connect any device with a Unifying logo on it to any receiver
with a Unifying logo on it and any device with a Bolt logo on it to any receiver
with a Bolt logo on it.
with a Unifying logo on it. Receivers without the Unifying logo probably
can connect only to the kind of devices they were bought with and devices
without the Unifying logo can probably only connect to the kind of receiver
that they were bought with.
Many receivers without the Unifying or Bolt logo
can connect only to the model of devices they were bought with and many devices
without the Unifying or Bolt logo can only connect to a receiver
that matches the one they were bought with.
## Device Settings
Solaar can display quite a few changeable settings of receivers and devices.
For a list of features and their support see [the features page](features.md).
For a list of HID++ features and their support see [the features page](features.md).
Solaar does not do much beyond using the protocols to change the
Solaar does not do much beyond using the HID++ protocol to change the
behavior of receivers and devices via changing their settings.
In particular, Solaar cannot change how
the operating system turns the keycodes that a keyboard produces into
@ -116,7 +112,7 @@ Solaar keeps track of settings independently on each computer.
As a result if a device is switched between different computers
Solaar may apply different settings for it on the different computers.
Querying a device for its current state can require quite a few
Querying a device for its current state can require quite a few HID++
interactions. These interactions can temporarily slow down the device, so
Solaar tries to internally cache information about devices while it is
running. If the device
@ -182,7 +178,7 @@ For more information on Mouse Gestures rule conditions see
Solaar uses the standard Logitech names for keyboard keys. Some Logitech keyboards have different icons on some of their keys and have different functionality than suggested by these names.
Solaar uses the standard US keyboard layout. This currently only matters for the `Per-key Lighting` setting. Users who want to have the key names for this setting reflect the keyboard layout that they use can create and edit `~/.config/solaar/keys.yaml` which contains a YAML dictionary of key names and locations. For example, switching the `Y` and `Z` keys can be done as:
Solaar is uses the standard US keyboard layout. This currently only matters for the `Per-key Lighting` setting. Users who want to have the key names for this setting reflect the keyboard layout that they use can create and edit `~/.config/solaar/keys.yaml` which contains a YAML dictionary of key names and locations. For example, switching the `Y` and `Z` keys can be done as:
Z: 25
Y: 26
@ -190,52 +186,6 @@ Solaar uses the standard US keyboard layout. This currently only matters for th
This is an experimental feature and may be modified or even eliminated.
### HITS Tuning (Hall-Effect Inductive Trigger Switch)
Some gaming mice (such as the PRO X 2 Superstrike) feature hall-effect magnetic switches on their primary buttons instead of traditional mechanical switches. These switches expose tunable parameters via the `SUPERSTRIKE_TUNING` HID++ feature (`0x1B0C`).
Solaar supports three per-button settings for each primary button (left = 0, right = 1):
- **Actuation Point** (`superstrike-tuning_actuation-{0,1}`): How deep the button must be pressed to register a click. Range 110, where 1 is the shallowest (hair trigger) and 10 is the deepest (full press). Default is 5.
- **Rapid Trigger Level** (`superstrike-tuning_rapid-trigger-level-{0,1}`): Sensitivity of rapid re-actuation after partial release. Range 15, where 1 is the most sensitive and 5 is the least. This cannot be fully disabled.
- **Click Haptics** (`superstrike-tuning_haptics-{0,1}`): Intensity of haptic feedback on click. Range 05, where 0 disables haptics and 5 is maximum intensity.
These settings are written directly to the device and persist across reconnections regardless of the onboard profile state.
### Extended DPI
Some gaming mice (such as the PRO X 2 Superstrike) support the `EXTENDED_ADJUSTABLE_DPI` feature (`0x2202`) which allows independent X and Y axis DPI configuration as well as lift-off distance (LOD) control. This is exposed via the `dpi_extended` setting:
```bash
solaar config <device> dpi_extended "{X:1600, Y:1600, LOD:HIGH}"
```
LOD values are `LOW` and `HIGH`. DPI range depends on the device sensor (up to 32000 DPI on the PRO X 2 Superstrike).
### Haptic Feedback
Some devices, such as the MX Master 4 have haptic feeback.
The Solaar CLI can be used to 'play' wave forms, for example
```
solaar config 'mx master 4' haptic-play 'HAPPY ALERT'
```
Solaar rules can also do this using their `Set` action.
### Extended Report Rate
Some gaming mice (such as the PRO X 2 Superstrike) support the `EXTENDED_ADJUSTABLE_REPORT_RATE` feature (`0x8061`) which enables sub-millisecond polling rates beyond the standard 1 ms (1000 Hz). This is exposed via the `report_rate_extended` setting:
| Value | Polling Rate |
|---------|-------------|
| `8ms` | 125 Hz |
| `4ms` | 250 Hz |
| `2ms` | 500 Hz |
| `1ms` | 1000 Hz |
| `500us` | 2000 Hz |
| `250us` | 4000 Hz |
| `125us` | 8000 Hz |
### Onboard Profiles
Some mice store one or more profiles onboard. An onboard profile controls certain aspects of the behavior of the mouse, including the rate at which the mouse reports movement, the resolution of the the movement reports, what the mouse buttons do, LED effects, and maybe more. Solaar has a setting that switches between profiles or disables all profiles.

View File

@ -5,7 +5,7 @@ layout: page
# Supported receivers and devices
Solaar only supports Logitech receivers and devices that use the Logitech proprietary HID++ and Centurion protocols.
Solaar only supports Logitech receivers and devices that use the Logitech proprietary HID++ protocol.
Solaar supports most Logitech Nano, Unifying, and Bolt receivers.
Solaar supports some Lightspeed receivers.
@ -14,7 +14,7 @@ See the receiver table below for the list of currently supported receivers.
Solaar supports all Logitech devices (keyboards, mice, trackballs, touchpads, and headsets)
that can connect to supported receivers.
Solaar supports all Logitech devices that can connect via a USB cable or via Bluetooth,
as long as the device uses the HID++ or Centurion protocol.
as long as the device uses the HID++ protocol.
The best way to determine whether Solaar supports a device is to run Solaar while the device is connected.
If the device is supported, it will show up in the Solaar main window.
@ -211,7 +211,6 @@ so what is important for support is the USB WPID or Bluetooth model ID.
|------------------------------|------|-------|
| G604 Wireless Gaming Mouse | 4085 | 4.2 |
| PRO X Superlight Wireless | 4093 | 4.2 |
| PRO X 2 Superstrike | 40BD | 4.2 |
### Trackballs (Unifying)

View File

@ -1,101 +0,0 @@
solaar version 1.1.19-25-g7520c9cc
1: G502 X PLUS
Device path : /dev/hidraw8
WPID : 4099
Codename : G502 X PLUS
Kind : mouse
Protocol : HID++ 4.2
Report Rate : 1ms
Serial number: C6884511
Model ID: 4099C0950000
Unit ID: C6884511
1: BL1 42.00.B0016
0: MPM 27.00.B0016
3:
3:
3:
3:
3:
3:
3:
3:
3:
3:
The power switch is located on the unknown.
Supports 32 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V4
Firmware: 1 BL1 42.00.B0016 AB0BFBB13A33
Firmware: 0 MPM 27.00.B0016 4099FBB13A33
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Firmware: 3
Unit ID: C6884511 Model ID: 4099C0950000 Transport IDs: {'wpid': '4099', 'usbid': 'C095'}
3: DEVICE NAME {0005} V0
Name: G502 X PLUS
Kind: mouse
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: UNIFIED BATTERY {1004} V3
Battery: 77%, BatteryStatus.DISCHARGING.
7: ADJUSTABLE DPI {2201} V2
Sensitivity (DPI) (saved): 800
Sensitivity (DPI) : 800
8: HIRES WHEEL {2121} V0
Multiplier: 8
Has invert: Normal wheel motion
Has ratchet switch: Normal wheel mode
Low resolution mode
HID notification
Scroll Wheel Direction (saved): False
Scroll Wheel Direction : False
Scroll Wheel Resolution (saved): False
Scroll Wheel Resolution : False
Scroll Wheel Diversion (saved): False
Scroll Wheel Diversion : False
9: RGB EFFECTS {8071} V2
LED Control (saved): Solaar
LED Control : Solaar
LEDs Primary (saved): !LEDEffectSetting {ID: 0, color: 10820909, intensity: 0, period: 100, ramp: 0, speed: 0}
LEDs Primary : !LEDEffectSetting {ID: 0, color: 10820909, intensity: 0, period: 100, ramp: 0, speed: 0}
10: PER KEY LIGHTING V2 {8081} V2
Per-key Lighting (saved): {A:No change, B:No change, C:No change, D:No change, E:No change, F:No change, G:No change, H:No change}
Per-key Lighting : {A:No change, B:No change, C:No change, D:No change, E:No change, F:No change, G:No change, H:No change}
11: ONBOARD PROFILES {8100} V0
Device Mode: On-Board
Onboard Profiles (saved): Profile 1
Onboard Profiles : Profile 1
12: MOUSE BUTTON SPY {8110} V0
13: REPORT RATE {8060} V0
Report Rate: 1ms
Report Rate (saved): 1ms
Report Rate : 1ms
14: FORCE PAIRING {1500} V0
15: DFU {00D0} V3
16: DEVICE RESET {1802} V0
17: unknown:1803 {0318} V0 internal, hidden, unknown:000010
18: CONFIG DEVICE PROPS {1806} V8
19: unknown:1811 {1118} V0 internal, hidden, unknown:000010
20: OOBSTATE {1805} V0
21: unknown:1830 {3018} V0 internal, hidden, unknown:000010
22: unknown:1875 {7518} V0 internal, hidden, unknown:000010
23: unknown:1861 {6118} V0 internal, hidden, unknown:000010
24: unknown:1890 {9018} V0 internal, hidden, unknown:000008
25: unknown:18A1 {A118} V0 internal, hidden, unknown:000010
26: unknown:1801 {0118} V0 internal, hidden, unknown:000010
27: unknown:1E00 {001E} V0 hidden
28: unknown:1E22 {221E} V0 internal, hidden, unknown:000010
29: unknown:1EB0 {B01E} V0 internal, hidden, unknown:000010
30: unknown:18B1 {B118} V0 internal, hidden, unknown:000010
31: unknown:18C0 {C018} V0 internal, hidden, unknown:000010
Battery: 77%, BatteryStatus.DISCHARGING.

View File

@ -1,71 +0,0 @@
solaar version 1.1.13+dfsg-1
1: G515 LS TKL
Device path : None
WPID : 40B4
Codename : G515 LS TKL
Kind : keyboard
Protocol : HID++ 4.2
Report Rate : 8ms
Serial number: 54FEF928
Model ID: B38940B4C355
Unit ID: 54FEF928
1: BL2 19.01.B0011
3:
0: MPK 25.01.B0011
3:
The power switch is located on the top right corner.
Supports 34 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V6
Firmware: Bootloader BL2 19.01.B0011 ABD580558692
Firmware: Other
Firmware: Firmware MPK 25.01.B0011 40B480558692
Firmware: Other
Unit ID: 54FEF928 Model ID: B38940B4C355 Transport IDs: {'btleid': 'B389', 'wpid': '40B4', 'usbid': 'C355'}
3: DEVICE NAME {0005} V3
Name: G515 LS TKL
Kind: keyboard
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: DEVICE FRIENDLY NAME {0007} V0
Friendly Name: G515 LS TKL
7: unknown:0011 {0011} V0
8: UNIFIED BATTERY {1004} V5
Battery: 82%, discharging.
9: RGB EFFECTS {8071} V4
LED Control (saved): Solaar
LED Control : Solaar
LEDs Primary (saved): !LEDEffectSetting {ID: 1, color: 16776960, intensity: 26, period: 2167, ramp: 1, speed: 0}
LEDs Primary : HID++ error {'number': 1, 'request': 2537, 'error': 7, 'params': b'\x00'}
10: PER KEY LIGHTING V2 {8081} V0
Per-key Lighting (saved): {A:indian red, B:indian red, C:indian red, D:indian red, E:indian red, F:indian red, G:indian red, H:indian red, I:indian red, J:indian red, K:indian red, L:indian red, M:indian red, N:indian red, O:indian red, P:indian red, Q:indian red, R:indian red, S:indian red, T:indian red, U:indian red, V:indian red, W:indian red, X:indian red, Y:indian red, Z:indian red, 1:orange, 2:orange, 3:orange, 4:orange, 5:orange, 6:orange, 7:orange, 8:orange, 9:orange, 0:yellow, ENTER:green, ESC:green, BACKSPACE:red, TAB:yellow, SPACE:yellow, -:indian red, =:indian red, [:indian red, \:indian red, KEY 46:white, ~:indian red, ;:indian red, ':indian red, `:indian red, ,:indian red, .:indian red, /:indian red, CAPS LOCK:red, F1:indian red, F2:indian red, F3:indian red, F4:indian red, F5:indian red, F6:indian red, F7:indian red, F8:indian red, F9:indian red, F10:indian red, F11:indian red, F12:indian red, PRINT:red, SCROLL LOCK:orange, PASTE:indian red, INSERT:green, HOME:indian red, PAGE UP:yellow, DELETE:red, END:indian red, PAGE DOWN:yellow, RIGHT:indian red, LEFT:indian red, DOWN:indian red, UP:indian red, KEY 97:indian red, COMPOSE:white, POWER:white, KEY 100:indian red, KEY 101:red, KEY 102:red, KEY 103:red, LEFT CTRL:indian red, LEFT SHIFT:yellow, LEFT ALT:indian red, LEFT WINDOWS:blue, RIGHT CTRL:indian red, RIGHT SHIFT:yellow, RIGHT ALTGR:blue, RIGHT WINDOWS:indian red, KEY 254:white}
Per-key Lighting : {A:No change, B:No change, C:No change, D:No change, E:No change, F:No change, G:No change, H:No change, I:No change, J:No change, K:No change, L:No change, M:No change, N:No change, O:No change, P:No change, Q:No change, R:No change, S:No change, T:No change, U:No change, V:No change, W:No change, X:No change, Y:No change, Z:No change, 1:No change, 2:No change, 3:No change, 4:No change, 5:No change, 6:No change, 7:No change, 8:No change, 9:No change, 0:No change, ENTER:No change, ESC:No change, BACKSPACE:No change, TAB:No change, SPACE:No change, -:No change, =:No change, [:No change, \:No change, KEY 46:No change, ~:No change, ;:No change, ':No change, `:No change, ,:No change, .:No change, /:No change, CAPS LOCK:No change, F1:No change, F2:No change, F3:No change, F4:No change, F5:No change, F6:No change, F7:No change, F8:No change, F9:No change, F10:No change, F11:No change, F12:No change, PRINT:No change, SCROLL LOCK:No change, PASTE:No change, INSERT:No change, HOME:No change, PAGE UP:No change, DELETE:No change, END:No change, PAGE DOWN:No change, RIGHT:No change, LEFT:No change, DOWN:No change, UP:No change, KEY 97:No change, COMPOSE:No change, POWER:No change, KEY 100:No change, KEY 101:No change, KEY 102:No change, KEY 103:No change, LEFT CTRL:No change, LEFT SHIFT:No change, LEFT ALT:No change, LEFT WINDOWS:No change, RIGHT CTRL:No change, RIGHT SHIFT:No change, RIGHT ALTGR:No change, RIGHT WINDOWS:No change, KEY 254:No change}
11: unknown:1B10 {1B10} V0
12: unknown:4523 {4523} V1
13: KEYBOARD LAYOUT 2 {4540} V1
14: BRIGHTNESS CONTROL {8040} V0
Brightness Control (saved): 40
Brightness Control : 40
15: unknown:8101 {8101} V0
16: unknown:1B05 {1B05} V0
17: unknown:8051 {8051} V0
18: DFU {00D0} V3
19: DEVICE RESET {1802} V0 internal, hidden, unknown:000010
20: unknown:1803 {1803} V1 internal, hidden, unknown:000010
21: unknown:1807 {1807} V3 internal, hidden, unknown:000010
22: unknown:1817 {1817} V0 internal, hidden, unknown:000010
23: OOBSTATE {1805} V0 internal, hidden
24: unknown:1830 {1830} V0 internal, hidden, unknown:000010
25: unknown:1890 {1890} V9 internal, hidden, unknown:000008
26: unknown:1891 {1891} V9 internal, hidden, unknown:000008
27: unknown:1E00 {1E00} V0 hidden
28: unknown:1E02 {1E02} V0 internal, hidden
29: unknown:1602 {1602} V0
30: unknown:1EB0 {1EB0} V0 internal, hidden, unknown:000010
31: unknown:1861 {1861} V1 internal, hidden, unknown:000010
32: unknown:18B0 {18B0} V1 internal, hidden, unknown:000010
33: unknown:1801 {1801} V0 internal, hidden, unknown:000010
Battery: 82%, discharging.

View File

@ -1,4 +1,4 @@
solaar version 1.1.19-25-g7520c9cc
Solaar version 1.1.3
1: G613 Wireless Mechanical Gaming Keyboard
Device path : None
@ -6,71 +6,69 @@ solaar version 1.1.19-25-g7520c9cc
Codename : G613
Kind : keyboard
Protocol : HID++ 4.2
Report Rate : 1ms
Serial number: 710EC3A3
Polling rate : 1 ms (1000Hz)
Serial number: 0DBC5FF6
Model ID: B34F40650000
Unit ID: 2A923B25
1: BOT 46.00.B0006
0: MPK 05.02.B0021
3:
The power switch is located on the unknown.
Unit ID: 9AAB3225
Bootloader: BOT 46.00.B0006
Firmware: MPK 05.02.B0021
Other:
Supports 32 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V2
Firmware: 1 BOT 46.00.B0006 00006E86A7BD
Firmware: 0 MPK 05.02.B0021 40656E86A7BD
Firmware: 3
Unit ID: 2A923B25 Model ID: B34F40650000 Transport IDs: {'btleid': 'B34F', 'wpid': '4065'}
3: DEVICE NAME {0005} V0
0: ROOT {0000}
1: FEATURE SET {0001}
2: DEVICE FW VERSION {0003}
Firmware: Bootloader BOT 46.00.B0006 00006E86A7BD
Firmware: Firmware MPK 05.02.B0021 40656E86A7BD
Firmware: Other
Unit ID: 9AAB3225 Model ID: B34F40650000 Transport IDs: {'btleid': 'B34F', 'wpid': '4065'}
3: DEVICE NAME {0005}
Name: G613 Wireless Mechanical Gaming Keyboard
Kind: keyboard
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: DEVICE FRIENDLY NAME {0007} V0
4: WIRELESS DEVICE STATUS {1D4B}
5: RESET {0020}
6: DEVICE FRIENDLY NAME {0007}
Friendly Name: G613
7: BATTERY STATUS {1000} V0
Battery: 80%, BatteryStatus.DISCHARGING, next level 50%.
8: CHANGE HOST {1814} V1
Change Host : 1:cosmo
9: HOSTS INFO {1815} V1
Host 0 (paired): cosmo
Host 1 (paired): Mi 11
10: REPROG CONTROLS V4 {1B04} V3
7: BATTERY STATUS {1000}
Battery: 50%, discharging, next level 20%.
8: CHANGE HOST {1814}
Change Host : 1:video2
9: HOSTS INFO {1815}
Host 0 (paired): video2
Host 1 (paired): CB73CG2
10: REPROG CONTROLS V4 {1B04}
Key/Button Diversion (saved): {Host Switch Channel 1:Regular, Host Switch Channel 2:Regular}
Key/Button Diversion : {Host Switch Channel 1:Regular, Host Switch Channel 2:Regular}
11: REPORT HID USAGE {1BC0} V1
12: ENCRYPTION {4100} V0
13: KEYBOARD DISABLE BY USAGE {4522} V0
14: KEYBOARD LAYOUT 2 {4540} V0
15: GKEY {8010} V0
Divert G and M Keys (saved): False
Divert G and M Keys : False
16: REPORT RATE {8060} V0
Report Rate: 1ms
Report Rate (saved): 1ms
Report Rate : 1ms
17: DFUCONTROL SIGNED {00C2} V0
18: DEVICE RESET {1802} V0
19: unknown:1803 {0318} V0 internal, hidden
20: CONFIG DEVICE PROPS {1806} V3
21: unknown:1813 {1318} V0 internal, hidden
22: OOBSTATE {1805} V0
23: unknown:1830 {3018} V0 internal, hidden
24: unknown:1890 {9018} V0 internal, hidden
25: unknown:1891 {9118} V0 internal, hidden
26: unknown:18A1 {A118} V0 internal, hidden
27: unknown:1DF3 {F31D} V0 internal, hidden
28: unknown:1E00 {001E} V0 hidden
29: unknown:1EB0 {B01E} V0 internal, hidden
30: unknown:1861 {6118} V0 internal, hidden
31: unknown:18B1 {B118} V0 internal, hidden
11: REPORT HID USAGE {1BC0}
12: ENCRYPTION {4100}
13: KEYBOARD DISABLE BY USAGE {4522}
14: KEYBOARD LAYOUT 2 {4540}
15: GKEY {8010}
Divert G Keys (saved): True
Divert G Keys : False
16: REPORT RATE {8060}
Polling Rate (ms): 1
Polling Rate (ms) (saved): 1
Polling Rate (ms) : 1
17: DFUCONTROL SIGNED {00C2}
18: DEVICE RESET {1802} internal, hidden
19: unknown:1803 {1803} internal, hidden
20: CONFIG DEVICE PROPS {1806} internal, hidden
21: unknown:1813 {1813} internal, hidden
22: OOBSTATE {1805} internal, hidden
23: unknown:1830 {1830} internal, hidden
24: unknown:1890 {1890} internal, hidden
25: unknown:1891 {1891} internal, hidden
26: unknown:18A1 {18A1} internal, hidden
27: unknown:1DF3 {1DF3} internal, hidden
28: unknown:1E00 {1E00} hidden
29: unknown:1EB0 {1EB0} internal, hidden
30: unknown:1861 {1861} internal, hidden
31: unknown:18B1 {18B1} internal, hidden
Has 2 reprogrammable keys:
0: Host Switch Channel 1 , default: Hostswitch Channel 1 => Hostswitch Channel 1
persistently_divertable, divertable, pos:1, group:0, group mask:empty
0: Host Switch Channel 1 , default: HostSwitch Channel 1 => HostSwitch Channel 1
divertable, persistently divertable, pos:1, group:0, group mask:empty
reporting: default
1: Host Switch Channel 2 , default: Hostswitch Channel 2 => Hostswitch Channel 2
persistently_divertable, divertable, pos:2, group:0, group mask:empty
1: Host Switch Channel 2 , default: HostSwitch Channel 2 => HostSwitch Channel 2
divertable, persistently divertable, pos:2, group:0, group mask:empty
reporting: default
Battery: 80%, BatteryStatus.DISCHARGING, next level 50%.
Battery: 50%, discharging, next level 20%.

View File

@ -1,7 +1,7 @@
Solaar version 1.1.19
Solaar version 1.1.5
G733 Gaming Headset
Device path : /dev/hidraw0
2: G733 Gaming Headset
Device path : /dev/hidraw2
USB id : 046d:0AB5
Codename : G733 Headset
Kind : headset
@ -9,34 +9,24 @@ G733 Gaming Headset
Serial number:
Model ID: 0AB500000000
Unit ID: FFFFFFFF
0: U1 37.00.B0131
Firmware: U1 37.00.B0131
Supports 9 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V2
Firmware: 0 U1 37.00.B0131 0AB5
Firmware: Firmware U1 37.00.B0131 0AB5
Unit ID: FFFFFFFF Model ID: 0AB500000000 Transport IDs: {'usbid': '0AB5'}
3: DEVICE NAME {0005} V0
Name: G733 Gaming Headset
Kind: None
4: COLOR LED EFFECTS {8070} V3
Sterowanie diodami LED (saved): Device
Sterowanie diodami LED : Device
Diody LED None (saved): !LEDEffectSetting {ID: 0}
Diody LED None : !LEDEffectSetting {ID: 0}
Diody LED None (saved): !LEDEffectSetting {ID: 1, color: 131072, ramp: 0}
Diody LED None : !LEDEffectSetting {ID: 1, color: 66048, ramp: 0}
4: COLOR LED EFFECTS {8070} V0
5: GKEY {8010} V0
Przekieruj klawisze G i M (saved): False
Przekieruj klawisze G i M : False
6: EQUALIZER {8310} V1
Korektor (saved): {0: 5, 1: 4, 2: 3, 3: 5, 4: 5, 5: 5, 6: 4, 7: 3, 8: 4, 9: 5}
Korektor : {0: 5, 1: 4, 2: 3, 3: 5, 4: 5, 5: 5, 6: 4, 7: 3, 8: 4, 9: 5}
Divert G Keys (saved): True
Divert G Keys : False
6: EQUALIZER {8310} V0
7: SIDETONE {8300} V0
Efekt lokalny (saved): 0
Efekt lokalny : 0
8: ADC MEASUREMENT {1F20} V4
Battery: 89% 4058mV , BatteryStatus.DISCHARGING.
Zarządzanie energią (saved): 30
Zarządzanie energią : 30
Battery: 89% 4058mV , BatteryStatus.DISCHARGING.
Sidetone (saved): 65
Sidetone : 65
8: ADC MEASUREMENT {1F20} V0
Battery status unavailable.
Battery status unavailable.

View File

@ -1,41 +0,0 @@
solaar version 1.1.19-25-g7520c9cc
Logitech G933 Gaming Wireless Headset
Device path : /dev/hidraw4
USB id : 046d:0A5B
Codename : Logitech
Kind : ?
Protocol : HID++ 4.2
Serial number:
Model ID: 000000000A5B
Unit ID: FFFFFFFF
0: U 98.03.B0027
Supports 9 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V2
Firmware: 0 U 98.03.B0027 0A5B
Unit ID: FFFFFFFF Model ID: 000000000A5B Transport IDs: {'btid': '0000', 'btleid': '0000'}
3: DEVICE NAME {0005} V0
Name: Logitech G933 Gaming Wireless Headset
Kind: None
4: COLOR LED EFFECTS {8070} V3
LED Control : HID++ error {'number': 255, 'request': 1147, 'error': 7, 'params': b''}
LEDs None (saved): !LEDEffectSetting {ID: 0}
LEDs None : !LEDEffectSetting {ID: 0}
LEDs None (saved): !LEDEffectSetting {ID: 0, color: 9519532, intensity: 0, period: 100, ramp: 0, speed: 0}
LEDs None : !LEDEffectSetting {ID: 1, color: 0, ramp: 0}
5: GKEY {8010} V0
Divert G and M Keys (saved): False
Divert G and M Keys : False
6: EQUALIZER {8310} V1
Equalizer (saved): {0: 8, 1: 8, 2: 4, 3: 2, 4: 1, 5: 4, 6: 7, 7: 10, 8: 5, 9: 11}
Equalizer : {0: 8, 1: 8, 2: 4, 3: 2, 4: 1, 5: 4, 6: 7, 7: 10, 8: 5, 9: 11}
7: SIDETONE {8300} V0
Sidetone (saved): 30
Sidetone : 30
8: ADC MEASUREMENT {1F20} V3
Battery: 100% 4183mV , BatteryStatus.RECHARGING.
Power Management (saved): 30
Power Management : 30
Battery: 100% 4183mV , BatteryStatus.RECHARGING.

Binary file not shown.

View File

@ -1,47 +0,0 @@
solaar version 1.1.19-24-g693159ee
Centurion Receiver
Device path : /dev/hidraw4
USB id : 046d:0AF7
Protocol : Centurion
1 : 0.02
Has 1 device(s) out of a maximum of 1.
Supports 5 dongle features:
0: ROOT {0000}
1: FEATURE SET {0001}
2: CENTURION DEVICE INFO {0100}
Firmware: 1 0.02
Hardware: model 26 rev 255 product 0508
3: CENTPP BRIDGE {0003}
4: CENTURION GENERIC DFU {010A}
1: PRO X 2 LIGHTSPEED
Device path : /dev/hidraw4
USB id : 046d:0AF7
Codename : PRO X 2 LIGHTSPEED
Kind : headset
Protocol : Centurion 2.6
Serial number: <redacted>
Model ID: 0508
Unit ID: <redacted>
1: 0.02
Supports 10 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: CENTURION DEVICE NAME {0101} V0
3: CENTURION DEVICE INFO {0100} V0
Firmware: 1 0.02
Serial: <redacted>
Hardware: model 26 rev 255 product 0508
4: CENTURION BATTERY SOC {0104} V0
Battery: 97%, BatteryStatus.DISCHARGING.
5: CENTURION GENERIC DFU {010A} V0
6: CENTURION AUTO SLEEP {0108} V0
Auto Sleep Timeout: 10
7: HEADSET AUDIO SIDETONE {0604} V0
Headset Sidetone: 55
8: HEADSET MIC SNR {0602} V0
Mic SNR: True
9: HEADSET ONBOARD EQ {0636} V0
EQ: 80Hz:+0dB, 240Hz:+0dB, 750Hz:+0dB, 2200Hz:+0dB, 6600Hz:+0dB
Battery: 97%, BatteryStatus.DISCHARGING.

View File

@ -1,100 +0,0 @@
Solaar version 1.1.19
1: PRO X 2 Superstrike
Device path : None
WPID : 40BD
Codename : PRO X 2 Superstrike
Kind : mouse
Protocol : HID++ 4.2
Report Rate : 1ms
Serial number:
Model ID: 40BDC0A80000
Unit ID:
Bootloader: BL2 73.00.B0011
Firmware: MPM 42.00.B0011
The power switch is located on the base.
Supports 36 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V7
Firmware: Bootloader BL2 73.00.B0011
Firmware: Firmware MPM 42.00.B0011
Unit ID: Model ID: 40BDC0A80000 Transport IDs: {'wpid': '40BD', 'usbid': 'C0A8'}
3: DEVICE NAME {0005} V5
Name: PRO X2 SUPERSTRIKE
Kind: mouse
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
6: UNIFIED BATTERY {1004} V5
7: XY STATS {2250} V1
8: WHEEL STATS {2251} V0
9: EXTENDED ADJUSTABLE DPI {2202} V0
Sensitivity (DPI): {X:800, Y:800, LOD:HIGH}
10: MODE STATUS {8090} V3
11: unknown:80E0 {80E0} V0
12: SUPERSTRIKE TUNING {1B0C} V0
Left Button Actuation Point: 5
Left Button Rapid Trigger Level: 3
Left Button Click Haptics: 3
Right Button Actuation Point: 5
Right Button Rapid Trigger Level: 3
Right Button Click Haptics: 3
13: EXTENDED ADJUSTABLE REPORT RATE {8061} V0
Report Rate: 1ms
14: ONBOARD PROFILES {8100} V0
Device Mode: On-Board
Onboard Profiles: Profile 1
15: MOUSE BUTTON SPY {8110} V0
16: FORCE PAIRING {1500} V0
17: unknown:1801 {1801} V0 internal, hidden
18: DEVICE RESET {1802} V0
19: unknown:1803 {1803} V0 internal, hidden
20: CONFIG DEVICE PROPS {1806} V8
21: unknown:1817 {1817} V0 internal, hidden
22: OOBSTATE {1805} V0
23: unknown:1830 {1830} V0 internal, hidden
24: unknown:1877 {1877} V0 internal, hidden
25: unknown:9403 {9403} V0 internal, hidden
26: unknown:1861 {1861} V0 internal, hidden
27: unknown:1890 {1890} V0 internal, hidden
28: unknown:18A1 {18A1} V0 internal, hidden
29: unknown:1E00 {1E00} V0 hidden
30: unknown:1E02 {1E02} V0 internal, hidden
31: unknown:1E22 {1E22} V0 internal, hidden
32: unknown:1E30 {1E30} V0 internal, hidden
33: unknown:1602 {1602} V0
34: unknown:1EB0 {1EB0} V0 internal, hidden
35: unknown:18B1 {18B1} V0 internal, hidden
Battery: discharging.
SUPERSTRIKE TUNING Feature (0x1B0C)
-----------------------------------
This feature controls the HITS (Hall-Effect Inductive Trigger Switch) settings
for the left and right mouse buttons.
Capabilities (function 0x00):
Byte 0: Flags
Byte 1: Button count (3, but only 0 and 1 are accessible)
Byte 2: Max actuation value (40)
Byte 3: Max rapid trigger value (20)
Byte 4: Max haptics value (20)
Read button settings (function 0x20, param: button_index):
Response: [button_index, actuation, rapid_trigger, haptics, ...]
- actuation: 4-40 (quantized to multiples of 4)
- rapid_trigger: 1-20 (cannot be set to 0)
- haptics: 0-20 (quantized to multiples of 4: 0, 4, 8, 12, 16, 20)
Write button settings (function 0x10):
Params: [button_index, actuation, rapid_trigger, haptics]
Solaar Settings:
- superstrike-tuning_actuation-{0,1}: Range 1-10 (maps to device 4-40)
- superstrike-tuning_rapid-trigger-level-{0,1}: Range 1-5 (maps to device 1-20)
- superstrike-tuning_haptics-{0,1}: Range 0-5 (maps to device 0-20)
Note: Feature 0x80E0 (unknown:80E0) appears to be a non-functional stub for haptics.
Haptics are actually controlled via byte 3 of the SUPERSTRIKE TUNING feature.
Note: Feature 0x9403 (unknown:9403) appears to be a hidden BHOP (bunny hop) feature
that is not accessible via HID++.

View File

@ -1,62 +1,59 @@
Solaar version 1.1.14
Solaar version 1.1.3
2: PRO X Wireless
1: PRO X Wireless
Device path : None
WPID : 4093
Codename : PRO X
Kind : mouse
Protocol : HID++ 4.2
Report Rate : 1ms
Serial number: 8B24D1D1
Polling rate : 8 ms (125Hz)
Serial number: 42F42E12
Model ID: 4093C0940000
Unit ID: 8B24D1D1
1: BL1 25.01.B0018
3:
0: MPM 25.01.B0018
Unit ID: 42F42E12
Bootloader: BL1 25.00.B0013
Other:
Firmware: MPM 25.01.B0018
Supports 28 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V3
Firmware: 1 BL1 25.01.B0018 AB00FE92436C
Firmware: 3
Firmware: 0 MPM 25.01.B0018 4093FE92436C
Unit ID: 8B24D1D1 Model ID: 4093C0940000 Transport IDs: {'wpid': '4093', 'usbid': 'C094'}
3: DEVICE NAME {0005} V0
0: ROOT {0000}
1: FEATURE SET {0001}
2: DEVICE FW VERSION {0003}
Firmware: Bootloader BL1 25.00.B0013 AB00BE657A82
Firmware: Other
Firmware: Firmware MPM 25.01.B0018 4093FE92436C
Unit ID: 42F42E12 Model ID: 4093C0940000 Transport IDs: {'wpid': '4093', 'usbid': 'C094'}
3: DEVICE NAME {0005}
Name: PRO X Wireless
Kind: mouse
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: UNIFIED BATTERY {1004} V1
Battery: 71%, 0.
7: COLOR LED EFFECTS {8070} V4 internal, hidden
LED Control : HID++ error {'number': 2, 'request': 1908, 'error': 5, 'params': b''}
8: ONBOARD PROFILES {8100} V0
Device Mode: On-Board
Onboard Profiles (saved): Profile 1
Onboard Profiles : Profile 1
9: MOUSE BUTTON SPY {8110} V0
10: REPORT RATE {8060} V0
Report Rate: 1ms
Report Rate (saved): 1ms
Report Rate : 1ms
11: ADJUSTABLE DPI {2201} V2
Sensitivity (DPI) (saved): 800
Sensitivity (DPI) : 800
12: FORCE PAIRING {1500} V0
13: DEVICE RESET {1802} V0 internal, hidden
14: unknown:1803 {1803} V0 internal, hidden
15: CONFIG DEVICE PROPS {1806} V4 internal, hidden
16: unknown:1811 {1811} V0 internal, hidden
17: OOBSTATE {1805} V0 internal, hidden
18: unknown:1830 {1830} V0 internal, hidden
19: unknown:1890 {1890} V5 internal, hidden
20: unknown:1891 {1891} V5 internal, hidden
21: unknown:18A1 {18A1} V0 internal, hidden
22: unknown:1801 {1801} V0 internal, hidden
23: unknown:18B1 {18B1} V0 internal, hidden
24: unknown:1E00 {1E00} V0 hidden
25: unknown:1EB0 {1EB0} V0 internal, hidden
26: unknown:1863 {1863} V0 internal, hidden
27: unknown:1E22 {1E22} V0 internal, hidden
Battery: 71%, 0.
4: WIRELESS DEVICE STATUS {1D4B}
5: RESET {0020}
6: UNIFIED BATTERY {1004}
7: COLOR LED EFFECTS {8070} internal, hidden
8: ONBOARD PROFILES {8100}
Device Mode: Host
Onboard Profiles (saved): Disable
Onboard Profiles : Disable
9: MOUSE BUTTON SPY {8110}
10: REPORT RATE {8060}
Polling Rate (ms): 1
Polling Rate (ms) (saved): 1
Polling Rate (ms) : 1
11: ADJUSTABLE DPI {2201}
Sensitivity (DPI) (saved): 1000
Sensitivity (DPI) : 1000
12: unknown:1500 {1500}
13: DEVICE RESET {1802} internal, hidden
14: unknown:1803 {1803} internal, hidden
15: CONFIG DEVICE PROPS {1806} internal, hidden
16: unknown:1811 {1811} internal, hidden
17: OOBSTATE {1805} internal, hidden
18: unknown:1830 {1830} internal, hidden
19: unknown:1890 {1890} internal, hidden
20: unknown:1891 {1891} internal, hidden
21: unknown:18A1 {18A1} internal, hidden
22: unknown:1801 {1801} internal, hidden
23: unknown:18B1 {18B1} internal, hidden
24: unknown:1E00 {1E00} hidden
25: unknown:1EB0 {1EB0} internal, hidden
26: unknown:1863 {1863} internal, hidden
27: unknown:1E22 {1E22} internal, hidden
Battery: 76%, discharging.

View File

@ -1,79 +0,0 @@
solaar version 1.1.19
1: Signature M550
Device path : None
WPID : B02B
Codename : Logi M550
Kind : mouse
Protocol : HID++ 4.5
Serial number: D074434E
Model ID: B02B00000000
Unit ID: D074434E
1: BL1 39.01.B0013
0: RBM 17.01.B0013
3:
The power switch is located on the (unknown).
Supports 30 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V4
Firmware: 1 BL1 39.01.B0013 B02BB0706FCD
Firmware: 0 RBM 17.01.B0013 B02BB0706FCD
Firmware: 3
Unit ID: D074434E Model ID: B02B00000000 Transport IDs: {'btleid': 'B02B'}
3: DEVICE NAME {0005} V0
Name: Signature M550
Kind: mouse
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: DEVICE FRIENDLY NAME {0007} V0
Friendly Name: Logi M550
7: UNIFIED BATTERY {1004} V3
Battery: 35%, BatteryStatus.DISCHARGING.
8: REPROG CONTROLS V4 {1B04} V5
Key/Button Actions (saved): {Middle Button:Mouse Middle Button}
Key/Button Actions : {Middle Button:Mouse Middle Button}
Key/Button Diversion (saved): {Middle Button:Regular}
Key/Button Diversion : {Middle Button:Regular}
9: HOSTS INFO {1815} V2
Host 0 (paired): mst
10: XY STATS {2250} V1
11: LOWRES WHEEL {2130} V0
Wheel Reports: HID
Scroll Wheel Diversion (saved): False
Scroll Wheel Diversion : False
12: ADJUSTABLE DPI {2201} V2
Sensitivity (DPI) (saved): 1000
Sensitivity (DPI) : 1000
13: DFUCONTROL {00C3} V0
14: DEVICE RESET {1802} V0
15: unknown:1803 {0318} V0 internal, hidden, unknown:000010
16: CONFIG DEVICE PROPS {1806} V8
17: unknown:1816 {1618} V0 internal, hidden, unknown:000010
18: OOBSTATE {1805} V0
19: unknown:1830 {3018} V0 internal, hidden, unknown:000010
20: unknown:1891 {9118} V0 internal, hidden, unknown:000008
21: unknown:18A1 {A118} V0 internal, hidden, unknown:000010
22: unknown:1E00 {001E} V0 hidden
23: unknown:1E02 {021E} V0 internal, hidden
24: unknown:1E22 {221E} V0 internal, hidden, unknown:000010
25: unknown:1602 {0216} V0
26: unknown:1EB0 {B01E} V0 internal, hidden, unknown:000010
27: unknown:1861 {6118} V0 internal, hidden, unknown:000010
28: unknown:18B1 {B118} V0 internal, hidden, unknown:000010
29: unknown:920A {0A92} V0 internal, hidden, unknown:000010
Has 4 reprogrammable keys:
0: Left Button , default: Left Click => Left Click
analytics_key_events, mse, pos:0, group:1, group mask:empty
reporting: default
1: Right Button , default: Right Click => Right Click
analytics_key_events, mse, pos:0, group:1, group mask:empty
reporting: default
2: Middle Button , default: Mouse Middle Button => Mouse Middle Button
analytics_key_events, raw_xy, divertable, reprogrammable, mse, pos:0, group:2, group mask:g1,g2
reporting: default
3: Virtual Gesture Button , default: Virtual Gesture Button => Virtual Gesture Button
force_raw_xy, raw_xy, virtual, divertable, pos:0, group:3, group mask:empty
reporting: default
Battery: 35%, BatteryStatus.DISCHARGING.

View File

@ -33,7 +33,6 @@ Feature | ID | Status | Notes
`UNIFIED_BATTERY` | `0x1004` | Supported | `get_battery`, read only
`CHARGING_CONTROL` | `0x1010` | Unsupported |
`LED_CONTROL` | `0x1300` | Unsupported |
`FORCE_PAIRING` | `0x1500` | Unsupported |
`GENERIC_TEST` | `0x1800` | Unsupported |
`DEVICE_RESET` | `0x1802` | Unsupported |
`OOBSTATE` | `0x1805` | Unsupported |
@ -50,7 +49,6 @@ Feature | ID | Status | Notes
`REPROG_CONTROLS_V2_2` | `0x1B02` | Unsupported |
`REPROG_CONTROLS_V3` | `0x1B03` | Unsupported |
`REPROG_CONTROLS_V4` | `0x1B04` | Partial Support | `ReprogrammableKeys`, `DivertKeys`, `MouseGesture`, `get_keys`
`SUPERSTRIKE_TUNING` | `0x1B0C` | Supported | `SuperstrikeTuning` (actuation point, rapid trigger, click haptics)
`REPORT_HID_USAGE` | `0x1BC0` | Unsupported |
`PERSISTENT_REMAPPABLE_ACTION` | `0x1C00` | Supported | `PersistentRemappableAction`
`WIRELESS_DEVICE_STATUS` | `0x1D4B` | Read only | status reporting from device
@ -69,12 +67,9 @@ Feature | ID | Status | Notes
`THUMB_WHEEL` | `0x2150` | Supported | `ThumbMode`, `ThumbInvert`
`MOUSE_POINTER` | `0x2200` | Supported | `get_mouse_pointer_info`, read only
`ADJUSTABLE_DPI` | `0x2201` | Supported | `AdjustableDpi`, `DpiSliding`
`EXTENDED_ADJUSTABLE_DPI` | `0x2202` | Supported | `ExtendedAdjustableDpi` (X/Y DPI + lift-off distance)
`POINTER_SPEED` | `0x2205` | Supported | `PointerSpeed`, `SpeedChange`, `get_pointer_speed_info`
`ANGLE_SNAPPING` | `0x2230` | Unsupported |
`SURFACE_TUNING` | `0x2240` | Unsupported |
`XY_STATS` | `0x2250` | Unsupported |
`WHEEL_STATS` | `0x2251` | Unsupported |
`HYBRID_TRACKING` | `0x2400` | Unsupported |
`FN_INVERSION` | `0x40A0` | Supported | `FnSwap`
`NEW_FN_INVERSION` | `0x40A2` | Supported | `NewFnSwap`, `get_new_fn_inversion
@ -106,7 +101,6 @@ Feature | ID | Status | Notes
`MR` | `0x8030` | Supported | `MRKeyLED`
`BRIGHTNESS_CONTROL` | `0x8040` | Supported | `BrightnessControl`
`REPORT_RATE` | `0x8060` | Supported | `ReportRate`
`EXTENDED_ADJUSTABLE_REPORT_RATE` | `0x8061` | Supported | `report_rate_extended` (sub-millisecond polling up to 8000 Hz)
`COLOR_LED_EFFECTS` | `0x8070` | Supported | `LEDControl`, `LEDZoneSetting`
`RGB_EFFECTS` | `0X8071` | Supported | `RGBControl`, `RGBEffectSetting`
`PER_KEY_LIGHTING` | `0x8080` | Unsupported |

View File

@ -49,7 +49,7 @@ Some of the languages Solaar has been translated to are listed below. A full lis
- Danish: John Erling Blad
- Dutch: Heimen Stoffels
- Français: [Papoteur][papoteur], [David Geiger][david-geiger], [Damien Lallement][damsweb]
- Finnish: Tomi Leppänen, [Niko Savola][nikosavola]
- Finnish: Tomi Leppänen
- German: Daniel Frost
- Greek: Vangelis Skarmoutsos
- Indonesia: [Ferdina Kusumah][feku]
@ -68,7 +68,6 @@ Some of the languages Solaar has been translated to are listed below. A full lis
- Swedish: John Erling Blad, [Daniel Zippert][zipperten], Emelie Snecker, Jonatan Nyberg
- Turkish: Osman Karagöz
- Ukrainian: Олександр Афанасьєв
- Bulgarian Николай Йорданов
[Rongronggg9]: https://github.com/Rongronggg9
[papoteur]: https://github.com/papoteur
@ -84,4 +83,3 @@ Some of the languages Solaar has been translated to are listed below. A full lis
[jeblad]: https://github.com/jeblad
[feku]: https://github.com/FerdinaKusumah
[renatoka]: https://github.com/renatoka
[nikosavola]: https://github.com/nikosavola

View File

@ -15,12 +15,10 @@ using one of the methods described below.
Solaar runs as a regular user process, albeit with direct access to the Linux interface
that lets it directly communicate with the Logitech devices it manages using special
Logitech-proprietary (HID++ and Centurion) commands.
Logitech-proprietary (HID++) commands.
Each Logitech device implements a different subset of these commands.
Solaar is thus only able to make the changes that a particular device supports.
Note: Support for Centurion devices is new and should be considered experimental.
Solaar is not a device driver and does not process normal input from devices.
It is thus unable to fix problems that arise from incorrect handling of
mouse movements or keycodes by Linux drivers or other software.
@ -133,6 +131,62 @@ Solaar uses a standard system tray implementation; solaar-gnome3 is no longer re
See [the installation page](https://pwr-solaar.github.io/Solaar/installation)
for the step-by-step procedure for manual installation.
## Known Issues
- Onboard Profiles, when active, can prevent changes to other settings, such as Polling Rate, DPI, and various LED settings. Which settings are affected depends on the device. To make changes to affected settings, disable Onboard Profiles. If Onboard Profiles are later enabled the affected settings may change to the value in the profile.
- Solaar version 1.1.12 has a bug resulting in devices remaining in their default configuration after a system resume. This is fixed in 1.1.13.
- Bluez 5.73 does not remove Bluetooth devices when they disconnect.
Solaar 1.1.12 processes the DBus disconnection and connection messages from Bluez and does re-initialize devices when they reconnect.
The HID++ driver does not re-initialize devices, which causes problems with smooth scrolling.
Until the problem is resolved having Scroll Wheel Resolution set to true (and not ignored) may be helpful.
- The Linux HID++ driver modifies the Scroll Wheel Resolution setting to
implement smooth scrolling. If Solaar changes this setting, scrolling
can be either very fast or very slow. To fix this problem
click on the icon at the right edge of the setting to set it to
"Ignore this setting", which is the default for new devices.
The mouse has to be reset (e.g., by turning it off and on again) before this fix will take effect.
- Solaar expects that it has exclusive control over settings that are not ignored.
Running other programs that modify these settings, such as logiops,
will likely result in unexpected device behavior.
- The driver also sets the scrolling direction to its normal setting when implementing smooth scrolling.
This can interfere with the Scroll Wheel Direction setting, requiring flipping this setting back and forth
to restore reversed scrolling.
- The driver sends messages to devices that do not conform with the Logitech HID++ specification
resulting in responses being sent back that look like other messages. For some devices this causes
Solaar to report incorrect battery levels.
- Solaar normally uses icon names for its icons, which in some system tray implementations
results in missing or wrong-sized icons.
The `--tray-icon-size` option forces Solaar to use icon files of appropriate size
for tray icons instead, which produces better results in some system tray implementations.
To use icon files close to 32 pixels in size use `--tray-icon-size=32`.
- The icon in the system tray can show up as 'black on black' in dark
themes or as non-symbolic when the theme uses symbolic icons. This is due to problems
in some system tray implementations. Changing to a different theme may help.
The `--battery-icons=symbolic` option can be used to force symbolic icons.
- Solaar will try to use uinput to simulate input from rules under Wayland or if Xtest is not available
but this needs write permission on /dev/uinput.
For more information see [the rules page](https://pwr-solaar.github.io/Solaar/rules).
- Diverted keys remain diverted and so do not have their normal behavior when Solaar terminates
or a device disconnects from a host that is running Solaar. If necessary, their normal behavior
can be reestablished by turning the device off and on again. This is most important to restore
the host switching behavior of a host switch key that was diverted, for example to switch away
from a host that crashed or was turned off.
- When a receiver-connected device changes hosts Solaar remembers which diverted keys were down on it.
When the device changes back the first time any of these diverted keys is depressed Solaar will not
realize that the key was newly depressed. For this reason Solaar rules that can change hosts should
trigger on key releasing.
## License
This software is distributed under the terms of the

View File

@ -7,11 +7,14 @@ layout: page
An easy way to install the most recent release version of Solaar is from the PyPI repository.
First install pip, and then run
`pip install --user solaar` or `pipx install --system-site-packages solaar`.
`pip install --user solaar` or `pipx install --system-site-packages solaar` or
If you are using pipx add the `` flag.
This will not install the Solaar udev rule, which you will need to install manually by copying
`~/.local/lib/udev/rules.d/42-logitech-unify-permissions.rules`
to `/etc/udev/rules.d` as root.
If you want Solaar rules to simulate input you will have to instead install Solaar's uinput udev rule
from the GitHub repository.
## Installing in macOS
@ -24,18 +27,6 @@ brew update
brew install hidapi gtk+3 pygobject3
```
### Optional: Set up macOS launcher
* Option A (recommended): Configure a LaunchAgent to automatically start Solaar and keep it running in the background.
It will also automatically restart Solaar if it crashed or closed.
```
bash <(curl -fsSL https://raw.githubusercontent.com/pwr-Solaar/Solaar/refs/heads/master/tools/create-macos-launchagent.sh)
```
* Option B: Create Solaar.app launcher in /Applications.
It can be added to Login Items to start on login, but it will not automatically recover on crashes.
```
bash <(curl -fsSL https://raw.githubusercontent.com/pwr-Solaar/Solaar/refs/heads/master/tools/create-macos-app.sh)
```
# Installating from GitHub
## Downloading
@ -50,6 +41,8 @@ First, install the needed system packages by `make install_apt`
or `make install_dnf` or `make install_brew`.
These might not install all needed packages in older versions of your distribution.
Next, install the Solaar rule via `make install_udev`.
If you are using Wayland instead of X11 you may want to instead `make install_udev_uinput`
so that Solaar rules can simulate input in Wayland.
Finally, install Solaar via `make install_pip` or `make install_pipx`.
Parts of the installation process require sudo privileges so you may be asked for your password.
@ -125,6 +118,8 @@ For more information see [the rules page](https://pwr-solaar.github.io/Solaar/ru
You can install Solaar's udev rule manually by copying the file
`rules.d/42-logitech-unify-permissions.rules`
as root from the Solaar repository to `/etc/udev/rules.d`.
In Wayland you may want to instead copy
`rules.d-uinput/42-logitech-unify-permissions.rules`.
Let udev reload its rules by running `sudo udevadm control --reload-rules`.
# Solaar in other languages

View File

@ -1,64 +0,0 @@
---
title: Known Issues
layout: page
---
# Known Issues
- Some internal structures in Solaar have been updated to use more standard Python language features.
This has caused some problems and introduced bugs are still being found.
- Some devices, such as the G515 Lightspeed TLK, have multiple ways of controlling their LEDs,
for example one way controls each LED individually and another controls multiple LEDs at once.
For these devices the settings for one way should be set to ignore.
Having multiple ways that are not set to ignore may result in unusual behavior.
- Onboard Profiles, when active, can prevent changes to other settings, such as Polling Rate, DPI,
and various LED settings. Which settings are affected depends on the device. To make changes
to affected settings, disable Onboard Profiles. If Onboard Profiles are later enabled the affected
settings may change to the value in the profile.
- Solaar expects that it has exclusive control over settings that are not ignored.
Running other programs that modify these settings, such as logiops,
will likely result in unexpected device behavior.
- The Linux HID++ driver modifies the Scroll Wheel Resolution setting to
implement smooth scrolling. If Solaar changes this setting, scrolling
can be either very fast or very slow. To fix this problem
click on the icon at the right edge of the setting to set it to
"Ignore this setting", which is the default for new devices.
The mouse has to be reset (e.g., by turning it off and on again) before this fix will take effect.
- The Linux HID++ driver sets the scrolling direction to its normal setting when implementing smooth scrolling.
This can interfere with the Scroll Wheel Direction setting, requiring flipping this setting back and forth
to restore reversed scrolling.
- The Linux HID++ driver sends messages to devices that do not conform with the Logitech HID++ specification
resulting in responses being sent back that look like other messages. For some devices this causes
Solaar to report incorrect battery levels.
- Solaar normally uses icon names for its icons, which in some system tray implementations
results in missing or wrong-sized icons.
The `--tray-icon-size` option forces Solaar to use icon files of appropriate size
for tray icons instead, which produces better results in some system tray implementations.
To use icon files close to 32 pixels in size use `--tray-icon-size=32`.
- The icon in the system tray can show up as 'black on black' in dark
themes or as non-symbolic when the theme uses symbolic icons. This is due to problems
in some system tray implementations. Changing to a different theme may help.
The `--battery-icons=symbolic` option can be used to force symbolic icons.
- Solaar uses uinput to simulate input
but this needs write permission on /dev/uinput.
For more information see [the rules page](https://pwr-solaar.github.io/Solaar/rules).
- Diverted keys remain diverted and so do not have their normal behavior when Solaar terminates
or a device disconnects from a host that is running Solaar. If necessary, their normal behavior
can be reestablished by turning the device off and on again. This is most important to restore
the host switching behavior of a host switch key that was diverted, for example to switch away
from a host that crashed or was turned off.
- When a receiver-connected device changes hosts Solaar remembers which diverted keys were down on it.
When the device changes back the first time any of these diverted keys is depressed Solaar will not
realize that the key was newly depressed. For this reason Solaar rules that can change hosts should
trigger on key releasing.

View File

@ -15,9 +15,9 @@ although on GNOME desktop under Wayland, you can use those with the Solaar Gnome
You can install it from `https://extensions.gnome.org/extension/6162/solaar-extension`.
Under Wayland using keyboard groups may result in incorrect symbols being input for simulated input.
Under Wayland simulating inputs when modifier keys are pressed may result in incorrect symbols being sent.
Simulated input uses uinput to simulate input so the user has to have write access to /dev/uinput.
The easiest way to maintain write access to /dev/uinput is to use Solaar's udev rule by downloading
`https://raw.githubusercontent.com/pwr-Solaar/Solaar/master/rules.d/42-logitech-unify-permissions.rules`
Simulated input uses Xtest if available under X11 or uinput if the user has write access to /dev/uinput.
The easiest way to maintain write access to /dev/uinput is to use Solaar's alternative udev rule by downloading
`https://raw.githubusercontent.com/pwr-Solaar/Solaar/master/rules.d-uinput/42-logitech-unify-permissions.rules`
and copying it as root into the `/etc/udev/rules.d` directory.
You may have to reboot your system for the write permission to be set up.
Another way to get write access to /dev/uinput is to run `sudo setfacl -m u:${USER}:rw /dev/uinput`
@ -245,8 +245,7 @@ If the previous condition in the parent rule returns a number the scroll amounts
### Mouse click
A `MouseClick` action takes a mouse button name (`left`, `middle` or `right`) and a positive number or 'click', 'depress', or 'release'.
The action simulates that number of clicks of the specified button or just one click, depress, or release of the button.
### Execute
A `MouseClick` action takes a mouse button name (`left`, `middle` or `right`) and a positive number, and simulates that number of clicks of the specified button.
An `Execute` action takes a program and arguments and executes it asynchronously.
### Set setting

View File

@ -46,7 +46,7 @@ class _DataMeta(type):
This metaclass also does some verification to prevent duplicated data.
"""
def __new__(mcs, name: str, bases: Tuple[Any], dic: Dict[str, Any]): # type: ignore
def __new__(mcs, name: str, bases: Tuple[Any], dic: Dict[str, Any]): # type: ignore # noqa: C901
dic["_single"] = {}
dic["_range"] = []
@ -215,7 +215,7 @@ class GenericDesktopControls(_Data):
Z = 0x32, "Z", UsageTypes.DV
RX = 0x33, "Rx", UsageTypes.DV
RY = 0x34, "Ry", UsageTypes.DV
RZ = 0x35, "Rz", UsageTypes.DV
RX = 0x35, "Rz", UsageTypes.DV
SLIDER = 0x36, "Slider", UsageTypes.DV
DIAL = 0x37, "Dial", UsageTypes.DV
WHEEL = 0x38, "Wheel", UsageTypes.DV

View File

@ -18,5 +18,3 @@ class DeviceInfo:
isDevice: bool
hidpp_short: str | None
hidpp_long: str | None
centurion: bool = False
centurion_report_id: int | None = None # 0x50 or 0x51 when centurion=True

View File

@ -38,8 +38,6 @@ from typing import Callable
from hidapi.common import DeviceInfo
LOGITECH_VENDOR_ID = 0x046D
if typing.TYPE_CHECKING:
import gi
@ -253,12 +251,6 @@ def _match(
logger.info(f"Skipping unlikely device {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
return None
# Skip Logitech webcams to prevent them from locking up during hidpp checks
# (product IDs range for webcams from docs/usb.ids.txt)
if vid == LOGITECH_VENDOR_ID and 0x0800 <= pid <= 0x09FF:
logger.info(f"Skipping Logitech webcam {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
return None
# Check for hidpp support
device["hidpp_short"] = False
device["hidpp_long"] = False

View File

@ -135,11 +135,10 @@ def _open(args):
if vid == LOGITECH_VENDOR_ID:
return {"vid": vid}
device = args.path
d = None
if not device:
device = args.device
if args.hidpp and not device:
for d in hidapi.enumerate(matchfn):
if (d.hidpp_short or d.hidpp_long) and (args.id is None or args.id.lower() == d.product_id.lower()):
if d.driver == "logitech-djreceiver":
device = d.path
break
if not device:
@ -147,17 +146,13 @@ def _open(args):
if not device:
sys.exit("!! Device path required.")
print(".. Opening device", device)
handle = hidapi.open_path(device)
if not handle:
sys.exit(f"!! Failed to open {device}, aborting.")
print(
".. Opened device %r, vendor %r product %r serial %r."
% (
device,
hidapi.get_manufacturer(handle) or d.vendor_id if d else None,
hidapi.get_product(handle) or d.product_id if d else None,
hidapi.get_serial(handle),
)
".. Opened handle %r, vendor %r product %r serial %r."
% (handle, hidapi.get_manufacturer(handle), hidapi.get_product(handle), hidapi.get_serial(handle))
)
if args.hidpp:
if hidapi.get_manufacturer(handle) is not None and hidapi.get_manufacturer(handle) != b"Logitech":
@ -175,10 +170,12 @@ def _parse_arguments():
arg_parser = argparse.ArgumentParser()
arg_parser.add_argument("--history", help="history file (default ~/.hidconsole-history)")
arg_parser.add_argument("--hidpp", action="store_true", help="ensure input data is a valid HID++ request")
arg_parser.add_argument("command", nargs="?", help="command to send (otherwise get commands from input)")
group = arg_parser.add_mutually_exclusive_group()
group.add_argument("-p", "--path", help="HID raw device to connect to (/dev/hidrawX); ")
group.add_argument("-i", "--id", help="product ID of device to connect to (XXXX)")
arg_parser.add_argument(
"device",
nargs="?",
help="linux device to connect to (/dev/hidrawX); "
"may be omitted if --hidpp is given, in which case it looks for the first Logitech receiver",
)
return arg_parser.parse_args()
@ -186,17 +183,6 @@ def main():
args = _parse_arguments()
handle = _open(args)
if args.command: # send a command
data = _validate_input(args.command, args.hidpp)
if data:
hidapi.write(handle, data)
reply = hidapi.read(handle, 128, 1)
if reply:
hexs = strhex(reply)
s = "[%s %s %s %s] %s" % (hexs[0:2], hexs[2:4], hexs[4:8], hexs[8:], repr(data))
print(s)
exit()
if interactive:
print(".. Press ^C/^D to exit, or type hex bytes to write to the device.")
@ -246,6 +232,7 @@ def main():
time.sleep(1)
finally:
print(f".. Closing handle {handle!r}")
hidapi.close(handle)
if interactive:
readline.write_history_file(args.history)

View File

@ -101,8 +101,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
try: # if report descriptor does not indicate HID++ capabilities then this device is not of interest to Solaar
from hid_parser import ReportDescriptor
hidpp_short = hidpp_long = centurion = False
centurion_report_id = None
hidpp_short = hidpp_long = False
devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor"
with fileopen(devfile, "rb") as fd:
with warnings.catch_warnings():
@ -112,22 +111,11 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
# and _Usage(0xFF00, 0x0001) in rd.get_input_items(0x10)[0].usages # be more permissive
hidpp_long = 0x11 in rd.input_report_ids and 19 * 8 == int(rd.get_input_report_size(0x11))
# and _Usage(0xFF00, 0x0002) in rd.get_input_items(0x11)[0].usages # be more permissive
# Centurion transport: 63-byte reports on usage page 0xFFA0 (both input and output)
# 0x51 = PRO X 2 LIGHTSPEED variant, 0x50 = G522 LIGHTSPEED variant (with device address byte)
if 0x51 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x51)) and 0x51 in rd.output_report_ids:
centurion_report_id = 0x51
elif (
0x50 in rd.input_report_ids and 63 * 8 == int(rd.get_input_report_size(0x50)) and 0x50 in rd.output_report_ids
):
centurion_report_id = 0x50
centurion = centurion_report_id is not None
if not hidpp_short and not hidpp_long and not centurion:
if not hidpp_short and not hidpp_long:
return
except Exception as e: # if can't process report descriptor fall back to old scheme
hidpp_short = None
hidpp_long = None
centurion = False
centurion_report_id = None
logger.info(
"Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s",
device.device_node,
@ -137,7 +125,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
e,
)
filtered_result = filter_func(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short or centurion, hidpp_long or centurion)
filtered_result = filter_func(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short, hidpp_long)
if not filtered_result:
return
interface_number = filtered_result.get("usb_interface")
@ -177,8 +165,6 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
isDevice=isDevice,
hidpp_short=hidpp_short,
hidpp_long=hidpp_long,
centurion=centurion if centurion else False,
centurion_report_id=centurion_report_id,
)
return d_info
@ -417,8 +403,6 @@ def read(device_handle, bytes_count, timeout_ms=-1):
data = os.read(device_handle, bytes_count)
assert data is not None
assert isinstance(data, bytes), (repr(data), type(data))
if not data: # empty read when select() said readable means EOF (device removed)
raise OSError(errno.EIO, f"device disconnected on file descriptor {int(device_handle)}")
return data
else:
return b""

View File

@ -1,456 +0,0 @@
## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""AdvancedParaEQ (0x020D) helpers.
The device handles biquad coefficient computation we transmit only
per-band filter-type + frequency + gain; the DSP does the rest.
V0/V1 wire format: 3-byte band stride [freq_hi, freq_lo, gain_i8],
gain is whole dB; getEQInfos returns 5 bytes [bandCount, dbRange,
caps, dbMin, dbMax].
V2 wire format: 1-byte header [direction_echo], then N × 5-byte band
stride [freq_hi, freq_lo, filter_type, gain_hi, gain_lo], with 0..3
trailer bytes (opaque, ignored). The parser consumes 5-byte chunks
until <5 bytes remain or a freq=0 sentinel is hit. Frequency u16 BE
in Hz; gain u16 BE in **offset-binary**: raw 0..(steps-1) maps
linearly to gain_min..gain_max (so on G522 with steps=241 /
gain=[-6..6], raw=120 = 0 dB). getEQInfos returns 13 bytes with gain
bounds + step count, format enum, XY-support flag, and onboard preset
counts.
(The protocol spec lists a 2-byte header [dir_echo, slot_echo] for
getCustomEQ, but G522 firmware via the centurion bridge omits the
slot_echo and emits a 1-byte header that matches getEQDefaults.
Verified against pcap traces of LGHUB G522 LIGHTSPEED traffic.)
"""
from __future__ import annotations
import logging
import struct
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
DIRECTION_PLAYBACK = 0
DIRECTION_CAPTURE = 1
# V2 filter-type taxonomy (byte [+0] of each band). 0x16 is observed on
# G522 for every band of its factory custom slot at ISO third-octave
# centers, treated as peaking. Other filter kinds (LP, shelf, notch …)
# need a live probe per device firmware to enumerate.
FILTER_TYPE_HP = 0x00
FILTER_TYPE_PEAKING_G522 = 0x16
FILTER_TYPE_PEAKING = 0x78
FILTER_TYPE_NAMES = {
FILTER_TYPE_HP: "HP",
FILTER_TYPE_PEAKING_G522: "peaking",
FILTER_TYPE_PEAKING: "peaking",
}
def _get_version(device) -> int:
return device.features.get_feature_version(SupportedFeature.HEADSET_ADVANCED_PARA_EQ) or 0
def get_advanced_eq_info(device):
"""Query getEQInfos (function 0). Returns a dict or None.
Common fields:
version int feature version (0, 1, 2)
gain_min_db int signed whole-dB min
gain_max_db int signed whole-dB max
step_db float dB per raw LSB (1.0 on V0/V1)
V0/V1 only:
band_count int number of bands (from wire byte 0)
db_range int raw byte 1
capabilities int raw byte 2
V2 only:
gain_steps int discrete gain positions
format int 0=CLASSIC, 1=STYLES
supports_xy bool
onboard_ro_preset_count int factory preset slots
onboard_custom_preset_count int user-writable preset slots
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x00)
if result is None:
logger.debug("AdvancedParaEQ getEQInfos V%d: feature_request returned None", version)
return None
if version >= 2:
if len(result) < 13:
logger.debug("AdvancedParaEQ getEQInfos V2: short response len=%d %s", len(result), result.hex())
return None
gain_min = struct.unpack("b", bytes([result[2]]))[0]
gain_max = struct.unpack("b", bytes([result[3]]))[0]
gain_steps = struct.unpack(">H", result[4:6])[0]
fmt = result[6]
supports_xy = bool(result[7])
ro_presets = result[9]
custom_presets = result[10]
step_db = (gain_max - gain_min) / max(1, gain_steps - 1)
info = {
"version": 2,
"gain_min_db": gain_min,
"gain_max_db": gain_max,
"gain_steps": gain_steps,
"step_db": step_db,
"format": fmt,
"supports_xy": supports_xy,
"onboard_ro_preset_count": ro_presets,
"onboard_custom_preset_count": custom_presets,
}
logger.debug(
"AdvancedParaEQ getEQInfos V2: gain=[%d,%d] steps=%d step_db=%.4f format=%d xy=%s "
"presets_ro=%d presets_custom=%d",
gain_min,
gain_max,
gain_steps,
step_db,
fmt,
supports_xy,
ro_presets,
custom_presets,
)
device._advanced_eq_info = info
return info
# V0 / V1
if len(result) < 5:
logger.debug("AdvancedParaEQ getEQInfos V%d: short response len=%d %s", version, len(result), result.hex())
return None
band_count = result[0]
db_range = result[1]
caps = result[2]
gain_min = struct.unpack("b", bytes([result[3]]))[0]
gain_max = struct.unpack("b", bytes([result[4]]))[0]
info = {
"version": version,
"band_count": band_count,
"db_range": db_range,
"capabilities": caps,
"gain_min_db": gain_min,
"gain_max_db": gain_max,
"step_db": 1.0,
}
logger.debug(
"AdvancedParaEQ getEQInfos V%d: bands=%d dbRange=%d caps=0x%02X gain=[%d,%d]",
version,
band_count,
db_range,
caps,
gain_min,
gain_max,
)
device._advanced_eq_info = info
return info
def get_advanced_eq_active_slot(device, direction=DIRECTION_PLAYBACK):
"""Query getActiveEQ (function 3). Returns the active slot index, or None."""
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x30, direction)
if result is None:
logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): feature_request returned None", direction)
return None
if len(result) < 1:
logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): empty response", direction)
return None
logger.debug("AdvancedParaEQ getActiveEQ(dir=%d): slot=%d", direction, result[0])
return result[0]
def parse_v2_bands(result: bytes, info: dict | None):
"""Parse a V2 getCustomEQ / getEQDefaults response.
Wire layout (see module docstring):
[direction_echo] (1-byte header)
N × [freq_hi, freq_lo, filter_type, gain_hi, gain_lo] (5 bytes)
[trailer ] (0..3 bytes, ignored)
Gain is offset-binary against `info`'s gain bounds:
gain_db = gain_min + (gain_max - gain_min) * raw / (steps - 1)
`info` is the dict returned by `get_advanced_eq_info`. If absent we
fall back to step_db=1.0 (and log via the caller, not here) which is
wrong but won't crash.
Returns list of (filter_type_byte, freq_hz, gain_db) tuples, or None
if the payload is too short to contain a header. Empty payload with
valid header returns []. Bands with freq=0 are treated as the
end-of-bands sentinel (matches V0/V1 behavior at lines below).
"""
if result is None or len(result) < 1:
return None
payload = result[1:] # skip [dir_echo]
band_size = 5
if info:
gain_min = info.get("gain_min_db", -6)
gain_max = info.get("gain_max_db", 6)
steps = info.get("gain_steps", 241)
else:
gain_min, gain_max, steps = 0, 0, 1 # produces gain_db=0 for any raw
bands = []
for i in range(len(payload) // band_size):
e = payload[i * band_size : (i + 1) * band_size]
freq_hz = (e[0] << 8) | e[1]
filter_type = e[2]
gain_raw = (e[3] << 8) | e[4]
if freq_hz == 0:
break # disabled band — end-of-bands sentinel
if steps > 1:
gain_db = gain_min + (gain_max - gain_min) * gain_raw / (steps - 1)
else:
gain_db = 0.0
bands.append((filter_type, freq_hz, float(gain_db)))
return bands
def _band_label(filter_type_byte: int, freq_hz: int) -> str:
kind = FILTER_TYPE_NAMES.get(filter_type_byte, f"type-0x{filter_type_byte:02X}")
if filter_type_byte == FILTER_TYPE_HP:
return f"HP {freq_hz} Hz"
return f"{freq_hz} Hz" if kind == "peaking" else f"{kind} {freq_hz} Hz"
def get_advanced_eq_defaults(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getEQDefaults (function 5). Same per-band layout as getCustomEQ.
Returns list of (filter_type_byte, freq_hz, gain_db) tuples, or None.
V0/V1 callers receive (FILTER_TYPE_PEAKING, freq_hz, gain_db) for
compatibility with the V2 tuple shape.
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x50, direction, slot)
if result is None:
logger.debug(
"AdvancedParaEQ getEQDefaults V%d (dir=%d slot=%d): feature_request returned None",
version,
direction,
slot,
)
return None
if version >= 2:
info = getattr(device, "_advanced_eq_info", None)
bands = parse_v2_bands(result, info)
if bands is None:
logger.debug(
"AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): payload too short raw=%s",
direction,
slot,
result.hex(),
)
return None
logger.debug(
"AdvancedParaEQ getEQDefaults V2 (dir=%d slot=%d): %d band(s) raw=%s %s",
direction,
slot,
len(bands),
result.hex(),
[_band_label(t, f) + f" {round(g, 2)}dB" for t, f, g in bands],
)
return bands
# V0/V1 legacy 3-byte stride.
bands = []
offset = 0
while offset + 3 <= len(result):
freq = struct.unpack(">H", result[offset : offset + 2])[0]
if freq == 0:
break
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0]
bands.append((FILTER_TYPE_PEAKING, freq, float(gain_db)))
offset += 3
logger.debug(
"AdvancedParaEQ getEQDefaults V%d (dir=%d slot=%d): %d band(s)",
version,
direction,
slot,
len(bands),
)
return bands
def get_advanced_eq_friendly_name(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getEQFriendlyName (function 6). Returns the UTF-8 preset name or None."""
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x60, direction, slot)
if result is None or len(result) < 1:
return None
name_len = result[0]
if 1 + name_len > len(result):
name_len = len(result) - 1
try:
name = bytes(result[1 : 1 + name_len]).decode("utf-8", errors="replace")
except Exception:
name = result[1 : 1 + name_len].hex()
return name
def probe_advanced_eq_slots(device, direction=DIRECTION_PLAYBACK, info=None):
"""Probe every advertised EQ slot via getCustomEQ and cache which respond.
Some firmware (G522) advertises N slots via getEQInfos but only honors a
subset for getCustomEQ / setActiveEQ the rest return 0x0B NOT_SUPPORTED.
This iterates 0..total-1 and records which slots actually have data.
Result is cached on `device._advanced_eq_working_slots` as a list of
`(slot_index, name, bands)` tuples. The HeadsetActiveEQPreset selector
builds its choices from this list; the HeadsetAdvancedEQ panel uses it
to skip dead slots in its diagnostic output.
Logs each working slot's bands at INFO and a summary line indicating
how many of the advertised slots are actually accessible.
"""
cached = getattr(device, "_advanced_eq_working_slots", None)
if cached is not None:
return cached
if info is None:
info = getattr(device, "_advanced_eq_info", None) or get_advanced_eq_info(device)
if not info:
return []
ro_count = info.get("onboard_ro_preset_count", 0) or 0
custom_count = info.get("onboard_custom_preset_count", 0) or 0
total = ro_count + custom_count
if total == 0:
return []
def probe(slot):
bands = get_advanced_eq_params(device, direction=direction, slot=slot)
if bands is None:
return None
name = get_advanced_eq_friendly_name(device, direction=direction, slot=slot)
kind = "factory" if slot < ro_count else "custom"
logger.debug(
"AdvancedParaEQ %s preset slot=%d (dir=%d) name=%r: %s",
kind,
slot,
direction,
name,
[f"{_band_label(t, f)} {round(g, 2)}dB" for t, f, g in bands],
)
return (slot, name, bands)
working = []
# Slot 0 is canonical. If it fails the device is unusable; bail.
entry = probe(0)
if entry is None:
device._advanced_eq_working_slots = working
return working
working.append(entry)
# Slot 1 acts as a "multi-slot capable?" canary. G522 firmware
# advertises 16 slots but only honors slot 0; LGHUB itself never
# touches slots > 0 on this device. When the canary fails, skip the
# remaining 14 NOT_SUPPORTED probes.
if total > 1:
entry = probe(1)
if entry is None:
logger.debug(
"AdvancedParaEQ: slot 1 returned NOT_SUPPORTED; " "firmware advertises %d slots but only honors slot 0",
total,
)
device._advanced_eq_working_slots = working
return working
working.append(entry)
for slot in range(2, total):
entry = probe(slot)
if entry is not None:
working.append(entry)
device._advanced_eq_working_slots = working
logger.debug(
"AdvancedParaEQ working slots on dir=%d: %d of %d advertised %s",
direction,
len(working),
total,
[w[0] for w in working],
)
return working
# Backward-compat alias kept until external callers are migrated.
probe_all_presets = probe_advanced_eq_slots
def get_advanced_eq_params(device, direction=DIRECTION_PLAYBACK, slot=0):
"""Query getCustomEQ (function 1). Returns list of (filter_type, freq_hz, gain_db) or None.
V0/V1: filter_type is always FILTER_TYPE_PEAKING (synthesized), freq is
raw Hz from wire, gain is whole dB.
V2: filter_type comes from the wire (0x00=HP, 0x78=peaking), freq is raw
Hz, gain is int16 × step_db.
step_db for V2 is cached on the device by get_advanced_eq_info.
"""
version = _get_version(device)
result = device.feature_request(SupportedFeature.HEADSET_ADVANCED_PARA_EQ, 0x10, direction, slot)
if result is None:
logger.debug(
"AdvancedParaEQ getCustomEQ V%d (dir=%d slot=%d): feature_request returned None",
version,
direction,
slot,
)
return None
if version >= 2:
info = getattr(device, "_advanced_eq_info", None)
if not info:
logger.warning("AdvancedParaEQ getCustomEQ V2: no cached getEQInfos — gain values will be wrong")
bands = parse_v2_bands(result, info)
if bands is None:
logger.debug(
"AdvancedParaEQ getCustomEQ V2 (dir=%d slot=%d): payload too short raw=%s",
direction,
slot,
result.hex(),
)
return None
step_db = info["step_db"] if info and "step_db" in info else 1.0
# Log raw=... too so we can compare wire shapes across firmware
# variants and across get-fns (getCustomEQ vs getEQDefaults).
logger.debug(
"AdvancedParaEQ getCustomEQ V2 (dir=%d slot=%d): %d band(s) step_db=%.4f raw=%s %s",
direction,
slot,
len(bands),
step_db,
result.hex(),
[f"{_band_label(t, f)} {round(g, 2)}dB" for t, f, g in bands],
)
return bands
# V0 / V1
bands = []
offset = 0
while offset + 3 <= len(result):
freq = struct.unpack(">H", result[offset : offset + 2])[0]
if freq == 0:
break
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0]
bands.append((FILTER_TYPE_PEAKING, freq, float(gain_db)))
offset += 3
logger.debug(
"AdvancedParaEQ getCustomEQ V%d (dir=%d slot=%d): parsed %d band(s) %s",
version,
direction,
slot,
len(bands),
bands,
)
return bands

View File

@ -96,32 +96,6 @@ HIDPP_SHORT_MESSAGE_ID = 0x10
HIDPP_LONG_MESSAGE_ID = 0x11
DJ_MESSAGE_ID = 0x20
# Centurion transport (used by PRO X 2 LIGHTSPEED headset and similar)
# Two variants exist, distinguished by report ID:
# 0x51 (PRO X 2): [0x51, cpl_length, flags, feat_idx, func_sw, params..., pad]
# 0x50 (G522): [0x50, device_addr, cpl_length, flags, feat_idx, func_sw, params..., pad]
# The 0x50 variant adds a device_addr byte at position [1], shifting all CPL fields by +1.
# cpl_length = number of bytes from flags to end of meaningful data (includes flags byte).
# The device_index byte from standard HID++ is NOT present in Centurion framing.
CENTURION_REPORT_ID = 0x51
CENTURION_ADDRESSED_REPORT_ID = 0x50 # addressed variant with device_addr byte at frame[1] (G522 etc.)
CENTURION_FRAME_SIZE = 64 # 1 byte report ID + 63 bytes payload
_CENTURION_MSG_SIZE = 63 # max reconstructed message size after unwrapping (2 + 61 payload bytes)
@dataclasses.dataclass
class CenturionHandleState:
"""Per-handle state for Centurion devices."""
report_id: int = CENTURION_REPORT_ID # 0x50 or 0x51
device_addr: int | None = None # learned from first RX (0x50 only)
protocol_version: tuple[int, int] | None = None # from ping response
# All centurion per-handle state in a single dict.
# Membership test (ihandle in _centurion_handles) gates centurion-specific code paths.
_centurion_handles: dict[int, CenturionHandleState] = {}
"""Default timeout on read (in seconds)."""
DEFAULT_TIMEOUT = 4
@ -313,7 +287,6 @@ def close(handle):
if handle:
try:
if isinstance(handle, int):
_centurion_handles.pop(handle, None)
hidapi.close(handle)
else:
handle.close()
@ -324,115 +297,6 @@ def close(handle):
return False
def _centurion_frame_header(state: CenturionHandleState, cpl_length: int, flags: int) -> bytes:
"""Build the fixed prefix of a centurion frame.
0x51: [0x51, cpl_length, flags] (3 bytes)
0x50: [0x50, device_addr, cpl_length, flags] (4 bytes)
"""
if state.report_id == CENTURION_ADDRESSED_REPORT_ID:
device_addr = state.device_addr if state.device_addr is not None else 0x00
return struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, device_addr, cpl_length, flags)
return struct.pack("!BBB", CENTURION_REPORT_ID, cpl_length, flags)
_CENTURION_REPORT_IDS = (CENTURION_REPORT_ID, CENTURION_ADDRESSED_REPORT_ID)
# Per-candidate read timeout (ms) for the device_addr probe.
# USB round-trip is <1ms; 5ms gives 5x margin.
_CENTURION_PROBE_PER_ADDR_TIMEOUT_MS = 5
def probe_centurion_device_addr(handle, state: CenturionHandleState) -> bool:
"""Brute-force probe the device address byte for a 0x50-variant Centurion handle.
Sends a ROOT.GetProtocolVersion request for each candidate device_addr
(0x000xFF), reading briefly after each write. The dongle silently ignores
wrong addresses and responds only to the correct one. Stops on first hit.
Worst case (no response): 256 × 5ms = ~1.3s.
Typical G522 (addr=0x23): 36 × 5ms = ~180ms.
No-op for 0x51 (no device_addr byte) or when an address is already known.
Returns True if the address was learned.
"""
if state.report_id != CENTURION_ADDRESSED_REPORT_ID or state.device_addr is not None:
return False
ihandle = int(handle)
logger.debug("(%s) probing centurion device_addr: scanning 0x00-0xFF", handle)
# ROOT.GetProtocolVersion: feat_idx=0x00, func=0x10, 3 zero param bytes
payload = bytes([0x00, 0x10, 0x00, 0x00, 0x00])
cpl_length = len(payload) + 1 # +1 for flags byte
write_errors = 0
for addr in range(256):
frame = struct.pack("!BBBB", CENTURION_ADDRESSED_REPORT_ID, addr, cpl_length, 0x00) + payload
frame = frame + b"\x00" * (CENTURION_FRAME_SIZE - len(frame))
try:
hidapi.write(ihandle, frame)
except Exception:
write_errors += 1
if write_errors > 3:
logger.debug("(%s) centurion device_addr probe: too many write failures, aborting", handle)
return False
continue
try:
data = hidapi.read(ihandle, CENTURION_FRAME_SIZE, _CENTURION_PROBE_PER_ADDR_TIMEOUT_MS)
except Exception as reason:
logger.debug("(%s) centurion device_addr probe read failed at addr 0x%02X: %s", handle, addr, reason)
return False
if data and len(data) >= 2 and ord(data[:1]) == state.report_id:
state.device_addr = ord(data[1:2])
logger.debug(
"(%s) probed centurion device addr 0x%02X (after %d candidates)",
handle,
state.device_addr,
addr + 1,
)
return True
logger.debug("(%s) centurion device_addr probe: no response from any of 256 candidates", handle)
return False
def _unwrap_centurion_frame(data: bytes, ihandle: int, handle) -> bytes:
"""Unwrap a Centurion CPL frame (0x50 or 0x51) into a standard HID++ long message.
Auto-detects the variant from the raw report ID byte (self-describing),
matching how _read() handles 0x10 vs 0x11.
For 0x50, learns the device address from byte[1] on first receive.
"""
raw_report_id = ord(data[:1])
if raw_report_id == CENTURION_ADDRESSED_REPORT_ID:
# 0x50: [report_id, device_addr, cpl_length, flags, feat_idx, func_sw, data...]
device_addr = ord(data[1:2])
state = _centurion_handles.get(ihandle)
if state is not None and state.device_addr is None:
state.device_addr = device_addr
if logger.isEnabledFor(logging.DEBUG):
logger.debug("(%s) learned centurion device addr 0x%02X", handle, device_addr)
cpl_length = ord(data[2:3])
inner_payload = data[4 : 3 + cpl_length] # cpl_length - 1 bytes (skip flags)
elif raw_report_id == CENTURION_REPORT_ID:
# 0x51: [report_id, cpl_length, flags, feat_idx, func_sw, data...]
cpl_length = ord(data[1:2])
inner_payload = data[3 : 2 + cpl_length] # cpl_length - 1 bytes (skip flags)
else:
return data # not a centurion frame
data = bytes([HIDPP_LONG_MESSAGE_ID, 0xFF]) + inner_payload
# Pad to a valid message size: standard long (20) or Centurion extended (63)
if len(data) <= _LONG_MESSAGE_SIZE:
data = data + b"\x00" * (_LONG_MESSAGE_SIZE - len(data))
elif len(data) <= _CENTURION_MSG_SIZE:
data = data + b"\x00" * (_CENTURION_MSG_SIZE - len(data))
else:
data = data[:_CENTURION_MSG_SIZE]
return data
def write(handle, devnumber, data, long_message=False):
"""Writes some data to the receiver, addressed to a certain device.
@ -454,17 +318,6 @@ def write(handle, devnumber, data, long_message=False):
wdata = struct.pack("!BB18s", HIDPP_LONG_MESSAGE_ID, devnumber, data)
else:
wdata = struct.pack("!BB5s", HIDPP_SHORT_MESSAGE_ID, devnumber, data)
ihandle = int(handle)
if ihandle in _centurion_handles:
# Centurion CPL framing — strip device_index from HID++ and wrap in CPL header.
# cpl_length = len(meaningful_payload) + 1 (the +1 counts the flags byte).
state = _centurion_handles[ihandle]
payload = wdata[2:] # skip report_id and devnumber from standard frame
cpl_length = len(data) + 1 # data is the unpadded payload; +1 for flags byte
wdata = _centurion_frame_header(state, cpl_length, 0x00) + payload
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"(%s) <= w[%02X %02X %s %s]",
@ -476,37 +329,7 @@ def write(handle, devnumber, data, long_message=False):
)
try:
hidapi.write(ihandle, wdata)
except Exception as reason:
logger.error("write failed, assuming handle %r no longer available", handle)
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
def write_centurion_cpl(handle, layer3_payload, flags=0x00):
"""Send a Centurion CPL frame with the given Layer 3+ payload.
Builds the appropriate header for the handle's report ID variant:
0x51: [0x51, cpl_length, flags, layer3_payload..., pad to 64]
0x50: [0x50, device_addr, cpl_length, flags, layer3_payload..., pad to 64]
where cpl_length = len(layer3_payload) + 1 (the +1 counts the flags byte).
For multi-fragment sends, flags encodes fragment index and continuation:
flags = (fragment_index << 1) | (1 if more_fragments else 0)
Single-frame messages use flags=0x00 (default).
"""
ihandle = int(handle)
if ihandle not in _centurion_handles:
raise ValueError("write_centurion_cpl called on non-Centurion handle")
state = _centurion_handles[ihandle]
cpl_length = len(layer3_payload) + 1 # +1 for flags byte
header = _centurion_frame_header(state, cpl_length, flags)
wdata = header + layer3_payload
wdata = wdata + b"\x00" * (CENTURION_FRAME_SIZE - len(wdata))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("(%s) <= centurion_cpl[%s]", handle, common.strhex(wdata[: len(header) + cpl_length - 1]))
try:
hidapi.write(ihandle, wdata)
hidapi.write(int(handle), wdata)
except Exception as reason:
logger.error("write failed, assuming handle %r no longer available", handle)
close(handle)
@ -538,17 +361,17 @@ def _is_relevant_message(data: bytes) -> bool:
"""
assert isinstance(data, bytes), (repr(data), type(data))
# mapping from report_id to accepted message lengths
# mapping from report_id to message length
report_lengths = {
HIDPP_SHORT_MESSAGE_ID: (SHORT_MESSAGE_SIZE,),
HIDPP_LONG_MESSAGE_ID: (_LONG_MESSAGE_SIZE, _CENTURION_MSG_SIZE),
DJ_MESSAGE_ID: (_MEDIUM_MESSAGE_SIZE,),
0x21: (_MAX_READ_SIZE,),
HIDPP_SHORT_MESSAGE_ID: SHORT_MESSAGE_SIZE,
HIDPP_LONG_MESSAGE_ID: _LONG_MESSAGE_SIZE,
DJ_MESSAGE_ID: _MEDIUM_MESSAGE_SIZE,
0x21: _MAX_READ_SIZE,
}
report_id = ord(data[:1])
if report_id in report_lengths:
if len(data) in report_lengths[report_id]:
if report_lengths.get(report_id) == len(data):
return True
else:
logger.warning(f"unexpected message size: report_id {report_id:02X} message {common.strhex(data)}")
@ -564,21 +387,15 @@ def _read(handle, timeout) -> tuple[int, int, bytes]:
been physically removed from the machine, or the kernel driver has been
unloaded. The handle will be closed automatically.
"""
ihandle = int(handle)
is_centurion = ihandle in _centurion_handles
read_size = CENTURION_FRAME_SIZE if is_centurion else _MAX_READ_SIZE
try:
# convert timeout to milliseconds, the hidapi expects it
timeout = int(timeout * 1000)
data = hidapi.read(ihandle, read_size, timeout)
data = hidapi.read(int(handle), _MAX_READ_SIZE, timeout)
except Exception as reason:
logger.warning("read failed, assuming handle %r no longer available", handle)
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
if data and is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
data = _unwrap_centurion_frame(data, ihandle, handle)
if data and _is_relevant_message(data): # ignore messages that fail check
report_id = ord(data[:1])
devnumber = ord(data[1:2])
@ -747,17 +564,13 @@ def request(
if reply_data[:1] == b"\xff" and reply_data[1:3] == request_data[:2]:
# a HID++ 2.0 feature call returned with an error
error = ord(reply_data[3:4])
try:
error_name = Hidpp20ErrorCode(error)
except ValueError:
error_name = f"unknown:{error:02X}"
logger.error(
"(%s) device %d error on feature request {%04X}: %d = %s",
handle,
devnumber,
request_id,
error,
error_name,
Hidpp20ErrorCode(error),
)
raise exceptions.FeatureCallError(
number=devnumber,
@ -828,15 +641,9 @@ def ping(handle, devnumber, long_message: bool = False):
if reply:
report_id, reply_devnumber, reply_data = reply
if reply_devnumber == devnumber or reply_devnumber == devnumber ^ 0xFF: # BT device returning 0x00
is_centurion = int(handle) in _centurion_handles
mark_ok = is_centurion or reply_data[4:5] == request_data[-1:]
if reply_data[:2] == request_data[:2] and mark_ok:
if reply_data[:2] == request_data[:2] and reply_data[4:5] == request_data[-1:]:
# HID++ 2.0+ device, currently connected
major = ord(reply_data[2:3])
minor = ord(reply_data[3:4])
if is_centurion:
_centurion_handles[int(handle)].protocol_version = (major, minor)
return major + minor / 10.0
return ord(reply_data[2:3]) + ord(reply_data[3:4]) / 10.0
if (
report_id == HIDPP_SHORT_MESSAGE_ID
@ -849,8 +656,8 @@ def ping(handle, devnumber, long_message: bool = False):
return 1.0
if error in [Hidpp10ErrorCode.RESOURCE_ERROR, Hidpp10ErrorCode.CONNECTION_REQUEST_FAILED]:
return # device unreachable
if error == Hidpp10ErrorCode.UNKNOWN_DEVICE: # no device with that number currently accessible
logger.info("(%s) device %d error on ping request: unknown device", handle, devnumber)
if error == Hidpp10ErrorCode.UNKNOWN_DEVICE: # no paired device with that number
logger.error("(%s) device %d error on ping request: unknown device", handle, devnumber)
raise exceptions.NoSuchDevice(number=devnumber, request=request_id)
if notifications_hook:
@ -868,21 +675,17 @@ def _read_input_buffer(handle, ihandle, notifications_hook):
Used by request() and ping() before their write.
"""
is_centurion = ihandle in _centurion_handles
read_size = CENTURION_FRAME_SIZE if is_centurion else _MAX_READ_SIZE
while True:
try:
# read whatever is already in the buffer, if any
data = hidapi.read(ihandle, read_size, 0)
data = hidapi.read(ihandle, _MAX_READ_SIZE, 0)
except Exception as reason:
logger.error("read failed, assuming receiver %s no longer available", handle)
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
if data:
if is_centurion and ord(data[:1]) in _CENTURION_REPORT_IDS:
data = _unwrap_centurion_frame(data, ihandle, handle)
if _is_relevant_message(data): # only process messages that pass check
# report_id = ord(data[:1])
if notifications_hook:
@ -894,22 +697,17 @@ def _read_input_buffer(handle, ihandle, notifications_hook):
return
# HID++ Software ID claimed by Solaar. Fixed (not rotated) so cooperative
# userspace HID++ clients sharing the same device can pick a different value
# and reliably filter Solaar's traffic out of their reply stream.
#
# Known values in use by other tools at the time of writing:
#
# 0x07 OpenRGB
# 0x0A LGSTrayEx
# 0x0D Logitech G HUB (host-side)
# 0x0F Logitech firmware (sub-device self-enumeration on wired transports)
#
# 0x0B avoids those and keeps the high bit set so notifications (sw_id=0)
# remain trivially distinguishable from replies.
SOLAAR_SOFTWARE_ID = 0x0B
def _get_next_sw_id() -> int:
"""Return Solaar's HID++ Software ID (fixed, see SOLAAR_SOFTWARE_ID)."""
return SOLAAR_SOFTWARE_ID
"""Returns 'random' software ID to separate replies from different devices.
Cycle the HID++ 2.0 software ID from 0x2 to 0xF to separate
results and notifications.
"""
if not hasattr(_get_next_sw_id, "software_id"):
_get_next_sw_id.software_id = 0xF
if _get_next_sw_id.software_id < 0xF:
_get_next_sw_id.software_id += 1
else:
_get_next_sw_id.software_id = 2
return _get_next_sw_id.software_id

View File

@ -123,8 +123,7 @@ def _lightspeed_receiver(product_id: int) -> dict:
"usb_interface": 2,
"receiver_kind": "lightspeed",
"name": _("Lightspeed Receiver"),
"may_unpair": True,
"re_pairs": False,
"may_unpair": False,
}
@ -175,7 +174,6 @@ LIGHTSPEED_RECEIVER_C53F = _lightspeed_receiver(0xC53F)
LIGHTSPEED_RECEIVER_C541 = _lightspeed_receiver(0xC541)
LIGHTSPEED_RECEIVER_C545 = _lightspeed_receiver(0xC545)
LIGHTSPEED_RECEIVER_C547 = _lightspeed_receiver(0xC547)
LIGHTSPEED_RECEIVER_C54D = _lightspeed_receiver(0xC54D)
# EX100 old style receiver pre-unifying protocol
EX100_27MHZ_RECEIVER_C517 = _ex100_receiver(0xC517)
@ -204,7 +202,6 @@ KNOWN_RECEIVERS = {
0xC541: LIGHTSPEED_RECEIVER_C541,
0xC545: LIGHTSPEED_RECEIVER_C545,
0xC547: LIGHTSPEED_RECEIVER_C547,
0xC54D: LIGHTSPEED_RECEIVER_C54D,
0xC517: EX100_27MHZ_RECEIVER_C517,
}

View File

@ -1,591 +0,0 @@
## Copyright (C) 2012-2013 Daniel Pavel
## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Centurion device protocol — receiver class, factory, device info/firmware/battery queries.
CenturionReceiver is a lightweight receiver-like container for Centurion
(PRO X 2 LIGHTSPEED and similar) dongles. Protocol functions query device
info, firmware, serial, name, and battery via Centurion-specific HID++ features.
"""
from __future__ import annotations
import errno
import logging
import struct
from typing import Callable
from solaar import configuration
from . import base
from . import exceptions
from . import hidpp10
from . import hidpp10_constants
from .centurion_constants import CenturionCoreFeature
from .common import Alert
from .common import Battery
from .common import BatteryStatus
from .common import FirmwareKind
from .common import _read_usb_product_string
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
# --- Centurion protocol functions (standalone, operate on any device-like object) ---
def get_firmware_centurion(device):
"""Reads firmware info from a Centurion device via DeviceInfo (0x0100) function 1."""
from . import common
fw = []
seen = set() # track response signatures to detect duplicates
for index in range(0, 8): # try up to 8 entities
try:
report = device.feature_request(SupportedFeature.CENTURION_DEVICE_INFO, 0x10, index)
except exceptions.FeatureCallError:
break
if not report or len(report) < 5:
break
# Dedup: parent device returns the same response for every entity index
sig = bytes(report[: 5 + report[4]])
if sig in seen:
break
seen.add(sig)
fw_type = report[0]
version = struct.unpack("!H", report[2:4])[0]
name_len = report[4]
name = report[5 : 5 + name_len].decode("ascii", errors="replace").rstrip("\x00") if name_len else ""
version_str = f"{version >> 8}.{version & 0xFF:02d}"
kind = FirmwareKind(fw_type) if fw_type <= 3 else FirmwareKind.Other
fw.append(common.FirmwareInfo(kind, name, version_str, None))
return tuple(fw) if fw else None
def get_serial_centurion(device):
"""Reads the serial number from a Centurion device via DeviceInfo (0x0100) function 2."""
try:
report = device.feature_request(SupportedFeature.CENTURION_DEVICE_INFO, 0x20)
except exceptions.FeatureCallError:
return None
if not report or len(report) < 2:
return None
str_len = report[0]
return report[1 : 1 + str_len].decode("ascii", errors="replace").rstrip("\x00")
def get_hardware_info_centurion(device):
"""Reads hardware info from a Centurion device via DeviceInfo (0x0100) function 0.
Returns (modelId, hardwareRevision, productId) or None.
"""
try:
report = device.feature_request(SupportedFeature.CENTURION_DEVICE_INFO)
except exceptions.FeatureCallError:
return None
if not report or len(report) < 4:
return None
model_id = report[0]
hw_revision = report[1]
product_id = struct.unpack("!H", report[2:4])[0]
return model_id, hw_revision, product_id
def _centurion_sub_device_info_request(device, function=0x00, *params):
"""Send a DeviceInfo (0x0100) request to the sub-device via bridge."""
sub_indices = getattr(device, "_centurion_sub_indices", {})
sub_idx = sub_indices.get(SupportedFeature.CENTURION_DEVICE_INFO)
if sub_idx is None:
return None
return device.centurion_bridge_request(sub_idx, function, *params)
def get_firmware_centurion_sub(device):
"""Reads firmware info from the Centurion sub-device (headset) via bridge."""
from . import common
fw = []
seen = set()
for index in range(0, 8):
report = _centurion_sub_device_info_request(device, 0x10, index)
if not report or len(report) < 5:
break
sig = bytes(report[: 5 + report[4]])
if sig in seen:
break
seen.add(sig)
fw_type = report[0]
version = struct.unpack("!H", report[2:4])[0]
name_len = report[4]
name = report[5 : 5 + name_len].decode("ascii", errors="replace").rstrip("\x00") if name_len else ""
version_str = f"{version >> 8}.{version & 0xFF:02d}"
kind = FirmwareKind(fw_type) if fw_type <= 3 else FirmwareKind.Other
fw.append(common.FirmwareInfo(kind, name, version_str, None))
return tuple(fw) if fw else None
def get_serial_centurion_sub(device):
"""Reads the serial number from the Centurion sub-device (headset) via bridge."""
report = _centurion_sub_device_info_request(device, 0x20)
if not report or len(report) < 2:
return None
str_len = report[0]
return report[1 : 1 + str_len].decode("ascii", errors="replace").rstrip("\x00")
def get_hardware_info_centurion_sub(device):
"""Reads hardware info from the Centurion sub-device (headset) via bridge.
Returns (modelId, hardwareRevision, productId) or None.
"""
report = _centurion_sub_device_info_request(device)
if not report or len(report) < 4:
return None
model_id = report[0]
hw_revision = report[1]
product_id = struct.unpack("!H", report[2:4])[0]
return model_id, hw_revision, product_id
def get_name_centurion(device):
"""Reads a Centurion device's name via DeviceName (0x0101).
Tries two response formats:
1. Inline: function 0 returns [name_len, name_bytes...] (like serial)
2. Chunked: function 0 returns [name_len], function 1 returns [name_bytes...] (like standard DeviceName)
"""
try:
reply = device.feature_request(SupportedFeature.CENTURION_DEVICE_NAME)
except exceptions.FeatureCallError:
return None
if not reply:
return None
name_length = reply[0]
if name_length == 0:
return None
# If the full name is inline (length + name bytes in one response)
if len(reply) >= 1 + name_length:
return reply[1 : 1 + name_length].decode("utf-8", errors="replace").rstrip("\x00")
# Otherwise, fetch name in chunks via function 1 (like standard DEVICE_NAME)
name = b""
while len(name) < name_length:
try:
fragment = device.feature_request(SupportedFeature.CENTURION_DEVICE_NAME, 0x10, len(name))
except exceptions.FeatureCallError:
break
if fragment:
name += fragment[: name_length - len(name)]
else:
break
return name.decode("utf-8", errors="replace").rstrip("\x00") if name else None
def get_battery_centurion(device):
"""Query battery via CENTURION_BATTERY_SOC."""
try:
report = device.feature_request(SupportedFeature.CENTURION_BATTERY_SOC)
if report is not None:
return decipher_battery_centurion(report)
except exceptions.FeatureCallError:
if SupportedFeature.CENTURION_BATTERY_SOC in device.features:
return SupportedFeature.CENTURION_BATTERY_SOC
return None
def decipher_battery_centurion(report) -> tuple[SupportedFeature, Battery]:
"""Decipher CENTURION_BATTERY_SOC (0x0104) response.
Response format (3 bytes):
Byte 0: Battery Percentage (0-100)
Byte 1: Battery Percentage (duplicate)
Byte 2: Charging Status (0=discharging, 1=charging, 2=charging via USB, 3=charge complete)
"""
if len(report) < 1:
return SupportedFeature.CENTURION_BATTERY_SOC, Battery(None, None, BatteryStatus.DISCHARGING, None)
soc = report[0]
logger.debug("centurion battery SOC raw: %s", report[:8].hex())
charging_status = report[2] if len(report) >= 3 else 0
if charging_status in (1, 2):
status = BatteryStatus.RECHARGING
elif charging_status == 3:
status = BatteryStatus.FULL
else:
status = BatteryStatus.DISCHARGING
return SupportedFeature.CENTURION_BATTERY_SOC, Battery(soc, None, status, None)
# --- CenturionReceiver class ---
class CenturionReceiver:
"""A lightweight receiver-like container for Centurion (PRO X 2 LIGHTSPEED) dongles.
Provides the Receiver interface to the UI so the dongle appears as a parent
with the headset as an indented child device. NOT a subclass of Receiver
Receiver's __init__ does HID++ 1.0 register reads and pairing setup that
don't apply to Centurion.
All centurion communication (bridge, features, settings, battery) lives in
the child Device; this class is just a UI container + handle owner.
"""
read_register: Callable = hidpp10.read_register
write_register: Callable = hidpp10.write_register
number = 0xFF
kind = None
isDevice = False
may_unpair = False
re_pairs = False
max_devices = 1
def __init__(self, low_level, handle, device_info, setting_callback=None):
assert handle
self.low_level = low_level
self.handle = handle
self.path = device_info.path
self.product_id = device_info.product_id
self.setting_callback = setting_callback
self.status_callback = None
self.notification_flags = None
self._devices = {}
self._firmware = None
self._dongle_features = None # independently probed dongle features
self._pending = False # True when device_addr unknown; deferred init completes on first RX
self.cleanups = []
# Receiver identity
self.serial = None
self._usb_name = getattr(device_info, "product", None)
if not self._usb_name and self.path:
self._usb_name = _read_usb_product_string(self.path)
# User-facing name: "Centurion" is Logitech's internal codename for this
# headset-dongle transport, kept in code/logs but not shown to users.
self.name = "Lightspeed Headset Receiver"
# Dummy pairing object — lock_open stays False
from .receiver import Pairing
self.pairing = Pairing()
# Discover dongle features independently
self._discover_dongle_features()
# Read serial from dongle's CENTURION_DEVICE_INFO if available
if self.serial is None:
try:
s = get_serial_centurion(self)
if s and s.strip() and s.strip().isprintable():
self.serial = s.strip()
except Exception:
pass
def enable_connection_notifications(self, enable=True):
return False
def remaining_pairings(self, cache=True):
return None
def device_codename(self, n):
return self._usb_name
def request(self, request_id, *params, no_reply=False):
"""Send an HID++ request directly to the dongle (not through bridge)."""
if self.handle:
return self.low_level.request(
self.handle, 0xFF, request_id, *params, no_reply=no_reply, long_message=True, protocol=2.0
)
def feature_request(self, feature, function=0x00, *params, no_reply=False):
"""Send a feature request to the dongle using discovered feature indices."""
if self._dongle_features is None:
self._discover_dongle_features()
feature_int = int(feature)
for _feat, feat_id, index in self._dongle_features or []:
if feat_id == feature_int:
request_id = (index << 8) | (function & 0xFF)
return self.request(request_id, *params, no_reply=no_reply)
raise exceptions.FeatureNotSupported(feature=feature)
def _discover_dongle_features(self):
"""Independently discover features on the dongle hardware."""
self._dongle_features = []
try:
# Query ROOT for FEATURE_SET index
response = self.request(0x0000, 0x00, 0x01)
if response is None or response[0] == 0:
return
fs_index = response[0]
# Get feature count
count_resp = self.request(fs_index << 8)
if count_resp is None:
return
feature_count = count_resp[0]
# Enumerate features via CenturionFeatureSet (func 1 = 0x10, per-index query)
for idx in range(feature_count):
resp = self.request((fs_index << 8) | 0x10, idx)
if resp is None or len(resp) < 3:
continue
feat_id = struct.unpack("!H", resp[1:3])[0]
try:
feature = SupportedFeature(feat_id)
except ValueError:
feature = f"unknown:{feat_id:04X}"
self._dongle_features.append((feature, feat_id, idx))
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Centurion dongle features: %s", self._dongle_features)
except Exception:
logger.debug("Centurion dongle feature discovery failed", exc_info=True)
@property
def dongle_features(self):
"""Return list of (feature, feat_id, index) tuples for dongle features."""
if self._dongle_features is None:
self._discover_dongle_features()
return self._dongle_features
def count(self):
return len([d for d in self._devices.values() if d is not None])
@property
def firmware(self):
if self._firmware is None and self.handle and not self._pending:
self._firmware = get_firmware_centurion(self)
return self._firmware or ()
def _complete_deferred_init(self):
"""Re-run feature discovery after device_addr has been learned.
Called once from the notification handler when the first 0x50 frame
arrives on a pending CenturionReceiver.
"""
if not self._pending:
return False
self._pending = False
ihandle = int(self.handle)
state = base._centurion_handles.get(ihandle)
learned_addr = state.device_addr if state else None
logger.debug(
"CenturionReceiver %s: completing deferred init (device_addr=0x%02X)",
self.path,
learned_addr or 0,
)
self._dongle_features = None
self._discover_dongle_features()
logger.debug(
"CenturionReceiver %s: deferred discovery found %d feature(s): %s",
self.path,
len(self._dongle_features or []),
[(f"{feat_id:#06x}", idx) for _, feat_id, idx in (self._dongle_features or [])],
)
if self.serial is None:
try:
s = get_serial_centurion(self)
if s and s.strip() and s.strip().isprintable():
self.serial = s.strip()
except Exception:
pass
has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (self._dongle_features or []))
if has_bridge:
self.notify_devices()
return True
logger.warning(
"CenturionReceiver %s: deferred init completed but no bridge found " "(features: %s)",
self.path,
[f"{feat_id:#06x}" for _, feat_id, _ in (self._dongle_features or [])],
)
return False
def notify_devices(self):
"""Create child Device for the headset and trigger its initialization."""
# Import Device locally to avoid circular import (centurion.py ↔ device.py)
from .device import Device
if self._pending:
# Don't create children yet — feature discovery hasn't succeeded.
# Signal receiver to UI so the tray entry exists.
self.changed(alert=Alert.NONE)
return
# Signal receiver to UI first — tray/window need the receiver entry
# before a child device can be added under it.
self.changed(alert=Alert.NONE)
# Create child Device with receiver=self, number=1
pairing_info = {
"wpid": self.product_id,
"kind": hidpp10_constants.DEVICE_KIND.headset, # every Centurion-transport device so far is a headset
"serial": None,
"polling": None,
"power_switch": None,
}
dev = Device(
self.low_level,
self,
1,
None,
pairing_info=pairing_info,
setting_callback=self.setting_callback,
)
# Set centurion attributes on the child
dev.centurion = True
dev.product_id = self.product_id
dev.hidpp_long = True
dev._centurion_usb_name = self._usb_name
# Pre-set bridge index from dongle features so ping can probe the headset
for _feat, feat_id, idx in self._dongle_features or []:
if feat_id == CenturionCoreFeature.CENT_PP_BRIDGE:
dev._centurion_bridge_index = idx
break
self._devices[1] = dev
configuration.attach_to(dev)
dev.status_callback = self.status_callback
# Ping to determine online status.
# Notify UI either way — offline devices show as greyed out (matching receiver behavior).
online = dev.ping()
logger.debug(
"CenturionReceiver %s: child device created, bridge_idx=%s, online=%s, protocol=%s",
self.path,
getattr(dev, "_centurion_bridge_index", None),
online,
dev._protocol,
)
dev.changed(active=online)
if self.status_callback is not None:
self.status_callback(dev)
def changed(self, alert=Alert.NOTIFICATION, reason=None):
if self.status_callback is not None:
self.status_callback(self, alert=alert, reason=reason)
def status_string(self):
count = self.count()
if count == 0:
return "No devices."
return f"{count} device connected."
def close(self):
handle, self.handle = self.handle, None
for _n, d in self._devices.items():
if d:
d.close()
self._devices.clear()
for cleanup in self.cleanups:
cleanup(self)
return handle and self.low_level.close(handle)
def __del__(self):
self.close()
def __iter__(self):
for dev in self._devices.values():
if dev is not None:
yield dev
def __getitem__(self, key):
dev = self._devices.get(key)
if dev is not None:
return dev
raise IndexError(key)
def __len__(self):
return len([d for d in self._devices.values() if d is not None])
def __contains__(self, dev):
if isinstance(dev, int):
return self._devices.get(dev) is not None
return self.__contains__(dev.number)
def __bool__(self):
return self.handle is not None
__nonzero__ = __bool__
def __eq__(self, other):
return other is not None and self.kind == other.kind and self.path == other.path
def __ne__(self, other):
return other is None or self.kind != other.kind or self.path != other.path
def __hash__(self):
return self.path.__hash__()
def __str__(self):
return "<%s(%s,%s%s)>" % (
self.name.replace(" ", "") if self.name else "CenturionReceiver",
self.path,
"" if isinstance(self.handle, int) else "T",
self.handle,
)
__repr__ = __str__
def create_centurion_receiver(low_level, device_info, setting_callback=None):
"""Opens a Centurion dongle and wraps it as a receiver-like container.
Creates a CenturionReceiver, discovers its features, then checks if
CentPPBridge (0x0003) is among them. If not, this is a direct-connected
device wired headset, or a Bluetooth-paired Centurion headset where
there is no separate dongle. Close and return None so the caller can
fall back to create_device().
:returns: A CenturionReceiver, or None.
"""
try:
handle = low_level.open_path(device_info.path)
if handle:
report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID
state = base.CenturionHandleState(report_id=report_id)
base._centurion_handles[int(handle)] = state
base.probe_centurion_device_addr(handle, state)
cr = CenturionReceiver(low_level, handle, device_info, setting_callback)
# Check if any discovered feature is CentPPBridge (0x0003)
has_bridge = any(feat_id == CenturionCoreFeature.CENT_PP_BRIDGE for _, feat_id, _ in (cr.dongle_features or []))
if has_bridge:
return cr
# No bridge found. Distinguish "silent 0x50 dongle" (device_addr
# unknown, headset not yet powered on) from "wired 0x50 device"
# (responded to probe, features found, but no bridge).
is_0x50 = state.report_id == base.CENTURION_ADDRESSED_REPORT_ID
if is_0x50 and state.device_addr is None and not cr.dongle_features:
logger.debug(
"Centurion 0x50 device %s: probe and discovery failed, " "deferring init until first RX frame",
device_info.path,
)
cr._pending = True
return cr
logger.info("Centurion device %s has no bridge, treating as direct device", device_info.path)
base._centurion_handles.pop(int(handle), None)
cr.handle = None # prevent __del__ from double-closing
low_level.close(handle)
return None
except OSError as e:
logger.exception("open %s", device_info)
if e.errno == errno.EACCES:
raise e
except Exception as e:
logger.exception("open %s", device_info)
raise e

View File

@ -1,38 +0,0 @@
"""Centurion transport-specific constants.
Feature IDs that collide with HID++ 2.0 core features live here
so they can coexist with SupportedFeature (which requires unique values).
"""
from __future__ import annotations
from enum import IntEnum
from .hidpp20_constants import SupportedFeature
class CenturionCoreFeature(IntEnum):
"""Centurion transport-specific features that collide with HID++ 2.0 core IDs."""
CENTURION_ROOT = 0x0000
CENTURION_FEATURE_SET = 0x0001
CENT_PP_BRIDGE = 0x0003
MULTI_HOST_CONTROL = 0x0005
KEEP_ALIVE = 0x0007
def __str__(self):
return self.name.replace("_", " ")
def resolve_feature(feat_id: int, centurion: bool = False):
"""Resolve a feature ID to the appropriate enum, checking centurion-specific
features first when on the centurion transport."""
if centurion:
try:
return CenturionCoreFeature(feat_id)
except ValueError:
pass
try:
return SupportedFeature(feat_id)
except ValueError:
return None

View File

@ -361,53 +361,6 @@ yaml.SafeLoader.add_constructor("!NamedInt", NamedInt.from_yaml)
yaml.add_representer(NamedInt, NamedInt.to_yaml)
class ColorInt(int):
"""A 24-bit RGB color (``0x000000``-``0xFFFFFF``) as an int subclass.
Renders as ``0xrrggbb`` in ``str()`` / ``repr()`` and as a YAML hex int
literal in dumped configs (e.g. ``color: 0xfc3300``), which loads back
natively as a plain int via YAML 1.1's hex int parsing — so the value
round-trips cleanly with no special loader registration. The constructor
accepts both ints and hex strings (``0xfc3300`` or ``#fc3300``) so configs
saved before this type existed continue to load unchanged.
Negative or out-of-range values fall back to plain decimal formatting so
sentinels like ``COLORSPLUS["No change"] = -1`` keep their natural display.
"""
def __new__(cls, value):
if isinstance(value, str):
s = value.strip().lower()
if s.startswith("#"):
value = int(s[1:], 16)
elif s.startswith(("0x", "0X")):
value = int(s, 16)
else:
value = int(s)
else:
value = int(value)
return super().__new__(cls, value)
def __str__(self):
v = int(self)
if 0 <= v <= 0xFFFFFF:
return "0x%06x" % v
return str(v)
def __repr__(self):
return self.__str__()
def color_int_representer(dumper, data):
v = int(data)
if 0 <= v <= 0xFFFFFF:
return dumper.represent_scalar("tag:yaml.org,2002:int", "0x%06x" % v)
return dumper.represent_scalar("tag:yaml.org,2002:int", str(v))
yaml.add_representer(ColorInt, color_int_representer)
class NamedInts:
"""An ordered set of NamedInt values.
@ -645,8 +598,6 @@ class BatteryStatus(Flag):
SLOW_RECHARGE = 0x04
INVALID_BATTERY = 0x05
THERMAL_ERROR = 0x06
# Solaar internal — not a HID++ protocol value
OFFLINE = 0xFF
class BatteryLevelApproximation(IntEnum):
@ -723,17 +674,3 @@ class Notification(IntEnum):
class BusID(IntEnum):
USB = 0x03
BLUETOOTH = 0x05
def _read_usb_product_string(hidraw_path):
"""Read the USB product string from sysfs for a hidraw device path."""
import pathlib
try:
# /sys/class/hidraw/hidrawN/device/../../product → USB device product string
hidraw_name = pathlib.Path(hidraw_path).name
product_path = pathlib.Path("/sys/class/hidraw") / hidraw_name / "device" / ".." / ".." / "product"
product = product_path.read_text().strip()
return product if product else None
except (OSError, ValueError):
return None

View File

@ -213,7 +213,7 @@ _D(
_D("Wireless Keyboard K520", codename="K520", protocol=1.0, wpid="2011", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Solar Keyboard K750", codename="K750", protocol=2.0, wpid="4002")
_D("Wireless Keyboard K270 (unifying)", codename="K270", protocol=2.0, wpid="4003")
# incorrect _D("Wireless Keyboard K360", codename="K360", protocol=2.0, wpid="4004")
_D("Wireless Keyboard K360", codename="K360", protocol=2.0, wpid="4004")
_D("Wireless Keyboard K230", codename="K230", protocol=2.0, wpid="400D")
_D("Wireless Touch Keyboard K400", codename="K400", protocol=2.0, wpid=("400E", "4024"))
_D("Wireless Keyboard MK270", codename="MK270", protocol=2.0, wpid="4023")
@ -237,7 +237,7 @@ _D("G213 Prodigy Gaming Keyboard", codename="G213", usbid=0xC336, interface=1)
_D("G512 RGB Mechanical Gaming Keyboard", codename="G512", usbid=0xC33C, interface=1)
_D("G815 Mechanical Keyboard", codename="G815", usbid=0xC33F, interface=1)
_D("diNovo Edge Keyboard", codename="diNovo", protocol=1.0, wpid="C714")
_D("K845 Mechanical Keyboard", codename="K845", usbid=0xC341, interface=1)
_D("K845 Mechanical Keyboard", codename="K845", usbid=0xC341, interface=3)
# Mice
@ -381,7 +381,7 @@ _D(
),
)
_D("Couch Mouse M515", codename="M515", protocol=2.0, wpid="4007")
# _D("Wireless Mouse M175", codename="M175", protocol=2.0, wpid="4008")
_D("Wireless Mouse M175", codename="M175", protocol=2.0, wpid="4008")
_D("Wireless Mouse M325", codename="M325", protocol=2.0, wpid="400A")
_D("Wireless Mouse M525", codename="M525", protocol=2.0, wpid="4013")
_D("Wireless Mouse M345", codename="M345", protocol=2.0, wpid="4017")
@ -424,7 +424,6 @@ _D("G903 Hero Gaming Mouse", codename="G903 Hero", usbid=0xC091)
_D(None, kind=DEVICE_KIND.mouse, usbid=0xC092, interface=1) # two mice share this ID
_D("M500S Mouse", codename="M500S", usbid=0xC093, interface=1)
# _D('G600 Gaming Mouse', codename='G600 Gaming', usbid=0xc24a, interface=1) # not an HID++ device
_D("G500 Gaming Mouse", codename="G500 Gaming", usbid=0xC068, interface=1, protocol=1.0)
_D("G500s Gaming Mouse", codename="G500s Gaming", usbid=0xC24E, interface=1, protocol=1.0)
_D("G502 Proteus Spectrum Optical Mouse", codename="G502 Proteus Spectrum", usbid=0xC332, interface=1)
_D("Logitech PRO Gaming Keyboard", codename="PRO Gaming Keyboard", usbid=0xC339, interface=1)
@ -465,5 +464,3 @@ _D(
kind=DEVICE_KIND.headset,
usbid=0x0ABA,
)
# PRO X 2 LIGHTSPEED Gaming Headset (0x0AF7) — fully probed via Centurion transport, no static descriptor needed
# G522 LIGHTSPEED Gaming Headset (0x0B18 dongle, 0x0B19 wired) — Centurion 0x50 variant, no static descriptor needed

View File

@ -19,7 +19,6 @@ from __future__ import annotations
import errno
import logging
import struct
import threading
import time
import typing
@ -30,7 +29,6 @@ from typing import Protocol
from solaar import configuration
from . import base
from . import descriptors
from . import exceptions
from . import hidpp10
@ -40,8 +38,6 @@ from . import settings
from . import settings_templates
from .common import Alert
from .common import Battery
from .common import BatteryStatus
from .common import _read_usb_product_string
from .hidpp10_constants import NotificationFlag
from .hidpp20_constants import SupportedFeature
@ -78,11 +74,6 @@ def create_device(low_level: LowLevelInterface, device_info, setting_callback=No
try:
handle = low_level.open_path(device_info.path)
if handle:
if getattr(device_info, "centurion", False):
report_id = getattr(device_info, "centurion_report_id", None) or base.CENTURION_REPORT_ID
state = base.CenturionHandleState(report_id=report_id)
base._centurion_handles[int(handle)] = state
base.probe_centurion_device_addr(handle, state)
# a direct connected device might not be online (as reported by user)
return Device(
low_level,
@ -133,24 +124,12 @@ class Device:
self.product_id = device_info.product_id if device_info else None
self.hidpp_short = device_info.hidpp_short if device_info else None
self.hidpp_long = device_info.hidpp_long if device_info else None
self.centurion = device_info.centurion if device_info else False
self._centurion_usb_name = None
if self.centurion:
self.hidpp_long = True # Centurion devices always use long HID++ messages
# Read USB product string for device name — avoids slow bridge probe via CENTURION_DEVICE_NAME.
# device_info.product is often None (udev reads USB interface attrs, not device attrs),
# so fall back to reading from sysfs.
self._centurion_usb_name = getattr(device_info, "product", None) if device_info else None
if not self._centurion_usb_name and self.path:
self._centurion_usb_name = _read_usb_product_string(self.path)
self.bluetooth = device_info.bus_id == 0x0005 if device_info else False # Bluetooth needs long messages
self.hid_serial = device_info.serial if device_info else None
self.setting_callback = setting_callback # for changes to settings
self.status_callback = None # for changes to other potentially visible aspects
self.wpid = pairing_info["wpid"] if pairing_info else None # the Wireless PID is unique per device model
self._kind = pairing_info["kind"] if pairing_info else None # mouse, keyboard, etc (see hidpp10.DEVICE_KIND)
if self._kind is None and self.centurion:
self._kind = hidpp10_constants.DEVICE_KIND.headset # every Centurion-transport device so far is a headset
self._serial = pairing_info["serial"] if pairing_info else None # serial number (an 8-char hex string)
self._polling_rate = pairing_info["polling"] if pairing_info else None
self._power_switch = pairing_info["power_switch"] if pairing_info else None
@ -161,8 +140,7 @@ class Device:
self._modelId = None # model id (contains identifiers for the transports of the device)
self._tid_map = None # map from transports to product identifiers
self._persister = None # persister holds settings
self._led_effects = self._firmware = self._keys = self._remap_keys = self._gestures = self._force_buttons = None
self._keyboard_layout = None # lazy: country code from HID++ 0x4540, None if unsupported
self._led_effects = self._firmware = self._keys = self._remap_keys = self._gestures = None
self._profiles = self._backlight = self._settings = None
self.registers = []
self.notification_flags = None
@ -175,7 +153,6 @@ class Device:
self._gestures_lock = threading.Lock()
self._settings_lock = threading.Lock()
self._persister_lock = threading.Lock()
self._simple_lock = threading.Lock()
self._notification_handlers = {} # See `add_notification_handler`
self.cleanups = [] # functions to run on the device when it is closed
@ -206,16 +183,13 @@ class Device:
self.descriptor = (
descriptors.get_btid(self.product_id) if self.bluetooth else descriptors.get_usbid(self.product_id)
)
# for direct-connected devices get 'number' from descriptor protocol else use 0xFF
self.number = 0x00 if self.descriptor and self.descriptor.protocol and self.descriptor.protocol < 2.0 else 0xFF
try: # determine whether a direct-connected device is online
self.ping()
except exceptions.NoSuchDevice as e:
if self.number == 0xFF: # guessed wrong number?
self.number = 0x00
self.ping()
if self.number is None: # for direct-connected devices get 'number' from descriptor protocol else use 0xFF
if self.descriptor and self.descriptor.protocol and self.descriptor.protocol < 2.0:
number = 0x00
else:
raise e
number = 0xFF
self.number = number
self.ping() # determine whether a direct-connected device is online
if self.descriptor:
self._name = self.descriptor.name
@ -226,10 +200,8 @@ class Device:
self._protocol = self.descriptor.protocol if self.descriptor.protocol else None
self.registers = self.descriptor.registers if self.descriptor.registers else []
# Centurion devices always use HID++ 2.0 features regardless of the
# protocol version the dongle reports (e.g. G522 reports 1.1).
if self._protocol is not None and not self.centurion:
self.features = {} if self._protocol < 2.0 else hidpp20.FeaturesArray(self)
if self._protocol is not None:
self.features = None if self._protocol < 2.0 else hidpp20.FeaturesArray(self)
else:
self.features = hidpp20.FeaturesArray(self) # may be a 2.0 device; if not, it will fix itself later
@ -244,33 +216,16 @@ class Device:
@property
def protocol(self):
if not self._protocol:
try:
self.ping()
except exceptions.NoSuchDevice:
logger.warning("device %s inaccessible - no protocol set", self)
result = self._protocol or 0
# Centurion devices always use HID++ 2.0 features regardless of the
# protocol version the dongle reports (e.g. G522 reports 1.1).
# Ensure all `protocol < 2.0` gates route through the 2.0 code path.
if self.centurion and result < 2.0:
return 2.0
return result
self.ping()
return self._protocol or 0
@property
def codename(self):
if not self._codename:
if self.online and self.protocol >= 2.0:
if not self.centurion:
self._codename = _hidpp20.get_friendly_name(self)
if not self._codename and self.name:
# Use the full live name; only drop a leading "Logitech".
# Truncating at the first space mangled good names like
# "G502 X PLUS" (direct USB connection, no friendly name).
names = self.name.split(" ")
if not self.centurion and len(names) > 1 and names[0] == "Logitech":
self._codename = " ".join(names[1:])
else:
self._codename = self.name
self._codename = _hidpp20.get_friendly_name(self)
if not self._codename:
self._codename = self.name.split(" ", 1)[0] if self.name else None
if not self._codename and self.receiver:
codename = self.receiver.device_codename(self.number)
if codename:
@ -282,45 +237,17 @@ class Device:
@property
def name(self):
if not self._name:
with self._simple_lock:
if self._name is None:
if self.online and self.centurion:
self._name = _hidpp20.get_name_centurion(self) or getattr(self, "_centurion_usb_name", None)
if not self._name:
self._name = f"Unknown device {self.wpid or self.product_id}"
elif self.online and self.protocol >= 2.0:
self._name = _hidpp20.get_name(self)
if self.online and self.protocol >= 2.0:
self._name = _hidpp20.get_name(self)
return self._name or self._codename or f"Unknown device {self.wpid or self.product_id}"
def get_ids(self):
if self.centurion:
self._get_ids_centurion()
return
ids = _hidpp20.get_ids(self)
if ids:
self._unitId, self._modelId, self._tid_map = ids
if logger.isEnabledFor(logging.INFO) and self._serial and self._serial != self._unitId:
logger.info("%s: unitId %s does not match serial %s", self, self._unitId, self._serial)
def _get_ids_centurion(self):
if getattr(self, "_centurion_ids_done", False):
return
self._centurion_ids_done = True
serial = _hidpp20.get_serial_centurion(self)
if not serial or not serial.strip() or not serial.strip().isprintable():
serial = _hidpp20.get_serial_centurion_sub(self)
if serial and serial.strip() and serial.strip().isprintable():
self._serial = serial.strip()
self._unitId = self._serial
hw_info = _hidpp20.get_hardware_info_centurion(self)
if hw_info:
model_id, hw_revision, product_id = hw_info
# modelId is the stable per-model disambiguator (G522 0x32, G325
# 0x44). productId is shared across the headset family and varies
# by firmware (G522 0x0508 -> 0x0509), so it must NOT key modelId.
self._modelId = f"{model_id:02X}"
self._tid_map = {"usbid": f"{product_id:04X}"}
@property
def unitId(self):
if not self._unitId and self.online and self.protocol >= 2.0:
@ -341,8 +268,6 @@ class Device:
@property
def kind(self):
# Centurion devices are seeded with kind=headset at construction, so
# this online lookup only runs for descriptor-less HID++ 2.0 devices.
if not self._kind and self.online and self.protocol >= 2.0:
self._kind = _hidpp20.get_kind(self)
return self._kind or "?"
@ -350,9 +275,7 @@ class Device:
@property
def firmware(self) -> tuple[common.FirmwareInfo]:
if self._firmware is None and self.online:
if self.centurion:
self._firmware = _hidpp20.get_firmware_centurion_sub(self) or _hidpp20.get_firmware_centurion(self)
elif self.protocol >= 2.0:
if self.protocol >= 2.0:
self._firmware = _hidpp20.get_firmware(self)
else:
self._firmware = _hidpp10.get_firmware(self)
@ -360,8 +283,6 @@ class Device:
@property
def serial(self):
if not self._serial and self.online and self.centurion:
self.get_ids()
return self._serial or ""
@property
@ -379,13 +300,6 @@ class Device:
self._polling_rate = rate if rate else self._polling_rate
return self._polling_rate
@property
def keyboard_layout(self):
if self._keyboard_layout is None and self.online and self.protocol >= 2.0:
if SupportedFeature.KEYBOARD_LAYOUT_2 in self.features:
self._keyboard_layout = _hidpp20.get_keyboard_layout(self)
return self._keyboard_layout
@property
def led_effects(self):
if not self._led_effects and self.online and self.protocol >= 2.0:
@ -432,75 +346,10 @@ class Device:
self._profiles = _hidpp20.get_profiles(self)
return self._profiles
def force_buttons(self):
if self._force_buttons is None:
if self.online and self.protocol >= 2.0:
self._force_buttons = _hidpp20.get_force_buttons(self) or ()
return self._force_buttons
def set_configuration(self, configuration_, no_reply=False):
if self.online and self.protocol >= 2.0:
_hidpp20.config_change(self, configuration_, no_reply=no_reply)
def signal_configuration_complete(self, cookie=None):
"""SetComplete on ConfigChange to ack end of configuration.
With no cookie, sends the host's session counter (see
Hidpp20.set_configuration_complete)."""
if self.online and self.protocol >= 2.0:
_hidpp20.set_configuration_complete(self, cookie=cookie)
def _record_config_cookie(self):
"""After a successful apply, SetComplete with the next session cookie
and persist it so the dedup gate in apply_settings_if_needed can
detect drift on follow-up reconfig notifications within this session."""
if self.protocol < 2.0:
return
if not (self.features and SupportedFeature.CONFIG_CHANGE in self.features):
return
cookie = _hidpp20.next_session_cookie()
self.signal_configuration_complete(cookie=cookie)
if self.persister is not None:
self.persister["_config_cookie"] = [cookie[0], cookie[1]]
def apply_settings_if_needed(self):
"""Cookie-gated dedup helper for repeat WIRELESS_DEVICE_STATUS
reconfig notifications on an already-active device. Skips when the
live ConfigChange cookie matches the value stored by the most
recent apply, otherwise applies and re-records. Must NOT be used as
the initial-activation apply path across power cycles, devices
whose firmware resets the cookie to a fixed value would falsely
match a stored cookie from a prior session and skip the apply the
device actually needs.
Returns True if apply ran, False if it was skipped."""
if not self.online:
return False
if self.protocol >= 2.0 and self.features and SupportedFeature.CONFIG_CHANGE in self.features:
live = _hidpp20.get_configuration_cookie(self)
if live and len(live) >= 2:
stored = self.persister.get("_config_cookie") if self.persister else None
live_pair = [live[0], live[1]]
if stored == live_pair:
if logger.isEnabledFor(logging.INFO):
logger.info(
"%s: config cookie %02X%02X matches stored — skip apply_all_settings",
self,
live[0],
live[1],
)
return False
if logger.isEnabledFor(logging.INFO):
logger.info(
"%s: config cookie live=%02X%02X stored=%s — apply_all_settings",
self,
live[0],
live[1],
"%02X%02X" % (stored[0], stored[1]) if stored else "None",
)
settings.apply_all_settings(self)
self._record_config_cookie()
return True
def reset(self, no_reply=False):
self.set_configuration(0, no_reply)
@ -537,15 +386,6 @@ class Device:
def battery(self): # None or level, next, status, voltage
if self.protocol < 2.0:
if self.centurion:
logger.debug(
"%s: battery() dispatching HID++ 1.0 path for a Centurion device "
"(protocol=%s, _protocol=%s) — device_addr probe likely failed, "
"expect INVALID_SUB_ID_COMMAND",
self,
self.protocol,
self._protocol,
)
return _hidpp10.get_battery(self)
else:
battery_feature = self.persister.get("_battery", None) if self.persister else None
@ -557,7 +397,7 @@ class Device:
self.persister["_battery"] = feature.value
return battery
except Exception:
if self.persister and battery_feature is None and result is not None and result != 0:
if self.persister and battery_feature is None and result is not None:
self.persister["_battery"] = result.value
def set_battery_info(self, info):
@ -609,28 +449,15 @@ class Device:
):
if logger.isEnabledFor(logging.INFO):
logger.info("%s pushing device settings %s", self, self.settings)
# Activation apply must be unconditional — across power
# cycles, the device may have lost state while its cookie
# reset to a value that happens to match what we stored
# last session. Cookie comparison is only a valid dedup
# signal for repeat reconfig notifications within an
# already-active session (see apply_settings_if_needed).
settings.apply_all_settings(self)
self._record_config_cookie()
if not was_active:
if self.protocol < 2.0: # Make sure to set notification flags on the device
self.notification_flags = self.enable_connection_notifications()
else:
self.set_configuration(0x11) # signal end of configuration
self.read_battery() # battery information may have changed so try to read it now
elif was_active and self.receiver and not isinstance(self.receiver, CenturionReceiver):
elif was_active and self.receiver: # need to set configuration pending flag in receiver
hidpp10.set_configuration_pending_flags(self.receiver, 0xFF)
if not active and self.receiver and self.battery_info is not None and self.battery_info.level is not None:
self.battery_info = Battery(
self.battery_info.level,
self.battery_info.next_level,
BatteryStatus.OFFLINE,
self.battery_info.voltage,
self.battery_info.light_level,
)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("device %d changed: active=%s %s", self.number, self._active, self.battery_info)
if self.status_callback is not None:
@ -694,12 +521,9 @@ class Device:
long = self.hidpp_long is True or (
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
)
# Centurion child: CPL framing strips devnumber and responses always
# have devnumber=0xFF, so we must send 0xFF to match responses.
devnumber = 0xFF if (self.centurion and self.receiver and not self.handle) else self.number
return self.low_level.request(
self.handle or (self.receiver.handle if self.receiver else None),
devnumber,
self.handle or self.receiver.handle,
self.number,
request_id,
*params,
no_reply=no_reply,
@ -709,305 +533,11 @@ class Device:
def feature_request(self, feature, function=0x00, *params, no_reply=False):
if self.protocol >= 2.0:
if self.centurion:
# Ensure sub-device features are discovered before routing decision
if self.features is not None:
self.features._check()
# Guard against Centurion/HID++ 2.0 feature ID collisions. IntEnum
# members with the same int value hash equal, so a dict lookup for
# SupportedFeature.DEVICE_NAME (0x0005) succeeds even when the
# device actually has CenturionCoreFeature.MULTI_HOST_CONTROL at
# that slot. If the type of the stored enum differs from what the
# caller asked for, treat the feature as unsupported.
if self.features is not None:
idx = self.features.get(feature)
if idx is not None:
stored = self.features.inverse.get(idx)
if stored is not None and type(stored) is not type(feature):
return None
if feature in getattr(self, "_centurion_sub_features", ()):
sub_idx = self.features.get(feature)
if sub_idx is not None:
return self.centurion_bridge_request(sub_idx, function, *params, no_reply=no_reply)
return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
# Max sub-message bytes in the first bridge fragment (for 0x51):
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) - 2 (bridge prefix) - 2 (bridge hdr) = 57;
# one byte of conservative margin gives 56. For 0x50 the device_addr byte
# eats one more, so first_chunk = 55 (handled dynamically below).
_BRIDGE_FIRST_CHUNK = 56
# Continuation fragments carry raw sub_msg data (no bridge prefix/hdr):
# 64 - 1 (report ID) - 1 (cpl_len) - 1 (flags) = 61; one byte of margin
# gives 60.
_BRIDGE_CONT_CHUNK = 60
def centurion_bridge_request(self, sub_feat_idx, sub_function=0x00, *params, no_reply=False):
"""Send a request to a Centurion sub-device via CentPPBridge.
Builds the 4-layer nested message:
Layer 1: [report_id] (0x51 or 0x50)
Layer 2: [device_addr (0x50 only),] cpl_length, flags
Layer 3: [bridge_idx, sendFragment_func|swid, bridge_hdr...]
Layer 4: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
For multi-fragment sends, only the first fragment includes the bridge
prefix and header. Continuation fragments carry raw sub_msg data.
The CPL flags byte encodes fragment index and continuation:
flags = (fragment_index << 1) | (1 if more_fragments else 0)
Single-frame messages use flags=0x00.
Returns the sub-device response data (after bridge header), or None.
"""
if not getattr(self, "centurion", False):
raise ValueError("centurion_bridge_request called on non-Centurion device")
bridge_idx = getattr(self, "_centurion_bridge_index", None)
if bridge_idx is None:
raise ValueError("CentPPBridge index not discovered yet")
handle = self.handle or (self.receiver.handle if self.receiver else None)
if not handle:
return None
# Adjust bridge chunk sizes for 0x50 variant (device_addr byte takes 1 frame byte)
cent_state = base._centurion_handles.get(int(handle))
addr_overhead = 1 if cent_state and cent_state.report_id == base.CENTURION_ADDRESSED_REPORT_ID else 0
first_chunk = self._BRIDGE_FIRST_CHUNK - addr_overhead
cont_chunk = self._BRIDGE_CONT_CHUNK - addr_overhead
sw_id = base._get_next_sw_id()
# Build sub-device message: [sub_cpl=0x00, sub_feat_idx, sub_func|swid, params...]
# sub_function is in standard HID++ format: func_number << 4 (e.g. 0x10 for function 1)
sub_params = b"".join(struct.pack("B", p) if isinstance(p, int) else p for p in params) if params else b""
sub_msg = struct.pack("BBB", 0x00, sub_feat_idx, (sub_function & 0xF0) | sw_id) + sub_params
# Build bridge header: [device_id<<4 | len_hi, len_lo]
# device_id=0 for the headset, len is the total sub-message length
sub_len = len(sub_msg)
bridge_hdr = struct.pack("BB", (0x00 << 4) | ((sub_len >> 8) & 0x0F), sub_len & 0xFF)
bridge_prefix = struct.pack("BB", bridge_idx, (0x01 << 4) | sw_id)
timeout = base.DEFAULT_TIMEOUT
with base.acquire_timeout(base.handle_lock(handle), handle, timeout):
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"bridge TX: sub_idx=%d func=0x%02X sw_id=%d payload=%s",
sub_feat_idx,
sub_function,
sw_id,
sub_params.hex() if sub_params else "",
)
if sub_len <= first_chunk:
# Single-frame path
layer3 = bridge_prefix + bridge_hdr + sub_msg
base.write_centurion_cpl(handle, layer3)
else:
# Multi-fragment send
# Fragment 0: bridge_prefix + bridge_hdr + first chunk of sub_msg
# Fragments 1+: raw sub_msg continuation data (no bridge overhead)
# CPL flags = (frag_index << 1) | (1 if more_fragments else 0)
# All fragments are sent back-to-back without waiting for
# intermediate ACKs. The device reassembles internally and
# sends a single ACK + MessageEvent after the last fragment.
frag_index = 0
offset = 0
while offset < sub_len:
if frag_index == 0:
chunk_size = first_chunk
chunk = sub_msg[offset : offset + chunk_size]
layer3 = bridge_prefix + bridge_hdr + chunk
else:
chunk_size = cont_chunk
chunk = sub_msg[offset : offset + chunk_size]
layer3 = chunk
has_more = (offset + chunk_size) < sub_len
flags = (frag_index << 1) | (1 if has_more else 0)
base.write_centurion_cpl(handle, layer3, flags=flags)
offset += len(chunk)
frag_index += 1
if no_reply:
return None
# The device echoes our exact sub-device function+swid byte in
# MessageEvent responses. Match on that to reject cross-contamination
# from late-arriving responses to other function calls on the same
# feature (e.g. GetRGBZoneInfo response showing up on a later
# GetHostModeState read).
expected_sub_func_sw = (sub_function & 0xF0) | sw_id
# Read ACK + MessageEvent response
request_started = time.time()
ack_received = False
while time.time() - request_started < timeout:
reply = base._read(handle, timeout)
if not reply:
continue
_report_id, _devnumber, reply_data = reply
# ACK: short response echoing feat_idx and func|swid
if len(reply_data) >= 2 and reply_data[0] == bridge_idx:
func_sw = reply_data[1]
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == sw_id:
ack_received = True
break
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0:
# MessageEvent arrived before ACK — validate it's for our request
if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
return self._parse_bridge_response(reply_data)
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"bridge skipping reply (pre-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X"
" (expected idx=0x%02X func_sw=0x%02X) data=%s",
reply_data[4] if len(reply_data) > 4 else 0,
reply_data[5] if len(reply_data) > 5 else 0,
reply_data[6] if len(reply_data) > 6 else 0,
sub_feat_idx,
expected_sub_func_sw,
reply_data.hex(),
)
if not ack_received:
logger.warning("centurion_bridge_request: no ACK received")
return None
# Read MessageEvent response (bridge function 1 with SW ID 0 = event)
while time.time() - request_started < timeout:
reply = base._read(handle, timeout)
if not reply:
continue
_report_id, _devnumber, reply_data = reply
if len(reply_data) >= 2 and reply_data[0] == bridge_idx:
func_sw = reply_data[1]
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == 0:
if self._is_bridge_response_for(reply_data, sub_feat_idx, expected_sub_func_sw):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("bridge idx=%d fn=0x%02X -> OK", sub_feat_idx, sub_function)
return self._parse_bridge_response(reply_data)
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"bridge skipping reply (post-ACK): got sub_cpl=0x%02X sub_idx=0x%02X func_sw=0x%02X"
" (expected idx=0x%02X func_sw=0x%02X) data=%s",
reply_data[4] if len(reply_data) > 4 else 0,
reply_data[5] if len(reply_data) > 5 else 0,
reply_data[6] if len(reply_data) > 6 else 0,
sub_feat_idx,
expected_sub_func_sw,
reply_data.hex(),
)
logger.warning("centurion_bridge_request: no MessageEvent received")
return None
@staticmethod
def _wait_for_bridge_ack(handle, bridge_idx, sw_id, timeout):
"""Wait for a bridge ACK response between multi-fragment sends."""
started = time.time()
while time.time() - started < timeout:
reply = base._read(handle, timeout)
if not reply:
continue
_report_id, _devnumber, reply_data = reply
if len(reply_data) >= 2 and reply_data[0] == bridge_idx:
func_sw = reply_data[1]
if (func_sw >> 4) == 0x01 and (func_sw & 0x0F) == sw_id:
return True
return False
@staticmethod
def _is_bridge_response_for(reply_data, expected_sub_feat_idx, expected_sub_func_sw=None):
"""Check if a bridge MessageEvent is a response for our specific sub-feature request.
Accepts both normal responses (sub_feat_idx matches) and error responses
(sub_feat_idx=0xFF with original feat_idx in next byte).
Unsolicited notifications (sub_cpl=0xFF) are rejected.
If `expected_sub_func_sw` is provided, also matches on the echoed
sub-device function byte (`(function << 4) | sw_id`). This prevents
cross-talk between different function calls on the SAME feature, which
can happen when a late-arriving response for one function gets picked
up by a later request on the same feature (observed on G522 where a
GetRGBZoneInfo response contaminated a subsequent GetHostModeState).
"""
if len(reply_data) < 6:
return False
sub_cpl = reply_data[4]
sub_feat_idx = reply_data[5]
# Notifications have sub_cpl=0xFF; our responses have sub_cpl=0x00
if sub_cpl != 0x00:
return False
if sub_feat_idx == expected_sub_feat_idx:
if expected_sub_func_sw is not None and len(reply_data) >= 7:
if reply_data[6] != expected_sub_func_sw:
return False
return True
# Error response: sub_feat_idx=0xFF, next byte is the original feat_idx that errored
if sub_feat_idx == 0xFF and len(reply_data) >= 7 and reply_data[6] == expected_sub_feat_idx:
if expected_sub_func_sw is not None and len(reply_data) >= 8:
if reply_data[7] != expected_sub_func_sw:
return False
return True
return False
@staticmethod
def _parse_bridge_response(reply_data):
"""Extract sub-device response from a CentPPBridge MessageEvent.
reply_data layout (after report_id and devnumber have been stripped):
[bridge_idx, func_sw, dev_id<<4|len_hi, len_lo, sub_cpl, sub_feat_idx, sub_func_sw, data...]
Returns the sub-device data starting from sub_feat_idx onward.
Error responses have sub_feat_idx=0xFF: [... sub_cpl, 0xFF, orig_feat_idx, orig_func_sw, error_code]
These return None.
"""
if len(reply_data) < 7:
return None
sub_feat_idx = reply_data[5]
# Error response from sub-device
if sub_feat_idx == 0xFF:
# Error frame layout after sub_cpl: [0xFF, orig_feat_idx, orig_func_sw, error_code, ...]
orig_feat_idx = reply_data[6] if len(reply_data) > 6 else 0
orig_func_sw = reply_data[7] if len(reply_data) > 7 else 0
error_code = reply_data[8] if len(reply_data) > 8 else 0
logger.debug(
"bridge sub-device error: orig_feat_idx=%d orig_func=0x%02X error=0x%02X",
orig_feat_idx,
orig_func_sw,
error_code,
)
return None
return reply_data[7:] # response data after sub_cpl, sub_feat_idx, sub_func_sw
def _record_ping_protocol(self, handle, protocol):
"""Record a successful ping's protocol version, including raw Centurion (major, minor)."""
self._protocol = protocol
cent_state = base._centurion_handles.get(int(handle))
if cent_state and cent_state.protocol_version:
self._centurion_protocol = cent_state.protocol_version
def ping(self):
"""Checks if the device is online and present, returns True of False.
Some devices are integral with their receiver but may not be present even if the receiver responds to ping."""
if self.centurion and self.receiver and not self.handle:
# Centurion child: first check if dongle is reachable
handle = self.receiver.handle
try:
protocol = self.low_level.ping(handle, 0xFF, long_message=True)
except exceptions.NoReceiver:
self.online = False
return False
if protocol:
self._record_ping_protocol(handle, protocol)
# Dongle responded — now check if headset is actually on by probing through bridge.
# Send ROOT.GetFeature(0x0001) to the sub-device via CentPPBridge.
bridge_idx = getattr(self, "_centurion_bridge_index", None)
if bridge_idx is not None:
try:
result = self.centurion_bridge_request(0, 0x00, 0x00, 0x01)
self.online = result is not None and self.present
except Exception:
self.online = False
else:
self.online = False
return self.online
long = self.hidpp_long is True or (
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
)
@ -1018,7 +548,7 @@ class Device:
protocol = None
self.online = protocol is not None and self.present
if protocol:
self._record_ping_protocol(handle, protocol)
self._protocol = protocol
if logger.isEnabledFor(logging.DEBUG):
logger.debug("pinged %s: online %s protocol %s present %s", self.number, self.online, protocol, self.present)
return self.online
@ -1027,16 +557,12 @@ class Device:
pass
def close(self):
# Run device.cleanups before clearing self.handle — cleanup callbacks
# typically need to issue final feature_request() writes (e.g. release
# SW control, restore device-side state) and feature_request() relies
# on self.handle being set.
if hasattr(self, "cleanups"):
for cleanup in self.cleanups:
cleanup(self)
handle, self.handle = self.handle, None
if self in Device.instances:
Device.instances.remove(self)
if hasattr(self, "cleanups"):
for cleanup in self.cleanups:
cleanup(self)
return handle and self.low_level.close(handle)
def __index__(self):
@ -1072,8 +598,3 @@ class Device:
def __del__(self):
self.close()
# Re-export from centurion.py — must be after Device class to avoid circular import
from .centurion import CenturionReceiver # noqa: E402,F401
from .centurion import create_centurion_receiver # noqa: E402,F401

View File

@ -1,92 +0,0 @@
"""Per-device-model quirks for RGB lighting.
Keyed by ``device.modelId``. For normal HID++ devices that is the string
Logitech composes by concatenating every transport PID (btid + btleid + wpid
+ usbid) one entry covers the model on any transport. For Centurion
headsets it is the firmware-stable model byte (G522 ``"32"``, G325 ``"44"``);
see ``device._get_ids_centurion``.
Two postures, by feature class:
* **Effect parameters that do NOT persist** (zone effects, LED directions)
default-ALLOW. Those are validated and low-harm (a wrong value is cosmetic
and transient). They use blocklists elsewhere, e.g. ``LedDirectionBlocklist``
in ``hidpp20.py``. Nothing of that kind lives here.
* **NVconfig-saved colors** (0x8071 RGBEffects boot effects, 0x0622 HeadsetRGB
signature effects) default-DENY. Those writes persist to non-volatile
storage, so an unvalidated control can durably misconfigure a device. Every
field is hidden and every slot suppressed unless the exact model is listed
here as known-good.
Setting ``SOLAAR_EXPERIMENTAL`` truthy bypasses the allowlists entirely for
testers / reverse-engineering on devices not yet validated.
Entries are hand-curated; document the observation in a comment.
"""
from __future__ import annotations
import os
_ALL_NVCONFIG_FIELDS = {"color1", "color2", "speed"}
def _experimental() -> bool:
"""True when SOLAAR_EXPERIMENTAL is set truthy — bypasses allowlist masking."""
return os.environ.get("SOLAAR_EXPERIMENTAL", "").strip().lower() in ("1", "true", "yes", "on")
# Feature 0x8071 RGBEffects, Function 3 NvConfig — persistent boot/shutdown
# effects. Default-DENY allowlist: modelId -> cap_id -> set of color fields
# the firmware is KNOWN to honor. A listed cap shows the setting (its On/Off
# toggle plus the listed color pickers); an empty set shows the toggle only.
# An unlisted cap or unlisted model suppresses the setting entirely.
RGB_EFFECTS_NVCONFIG_ALLOWED: dict[str, dict[int, set[str]]] = {
# G502 X PLUS — startup (0x0001): color bytes are inert, only the enabled
# flag is honored, so no color fields are allowed (toggle only). Shutdown
# (0x0040) is not supported and is suppressed by build()-time probe anyway.
"4099C0950000": {0x0001: set()},
# G515 LIGHTSPEED TKL — startup and shutdown both honor both colors.
"B38940B4C355": {0x0001: {"color1", "color2"}, 0x0040: {"color1", "color2"}},
}
# Feature 0x0622 HeadsetRGB signature effects — persistent startup / shutdown
# / passive effects. Default-DENY allowlist: modelId -> effect_id -> set of
# fields the firmware is KNOWN to honor. An unlisted effect_id or model
# suppresses that signature-effect setting entirely.
HEADSET_SIGNATURE_EFFECTS_ALLOWED: dict[str, dict[int, set[str]]] = {
# G522 LIGHTSPEED (Centurion model byte 0x32, from DeviceInfo func 0;
# confirmed against multiple diagnostic logs). Verified against user
# reports: startup honors only the primary color (secondary unused, speed
# has no effect); shutdown honors both colors (speed has no effect). The
# passive slot (effect_id 2) is omitted — its behavior is not understood,
# so the whole passive setting is suppressed.
"32": {
0: {"color1"},
1: {"color1", "color2"},
},
}
def rgb_effects_nvconfig_allowed_fields(device, cap_id: int) -> set[str] | None:
"""Color fields to expose for an 0x8071 NvConfig boot effect.
Returns the allowed field set (possibly empty On/Off toggle only), or
``None`` to suppress the setting entirely.
"""
if _experimental():
return set(_ALL_NVCONFIG_FIELDS)
model_id = getattr(device, "modelId", None) or ""
return RGB_EFFECTS_NVCONFIG_ALLOWED.get(model_id, {}).get(cap_id)
def headset_signature_allowed_fields(device, effect_id: int) -> set[str] | None:
"""Fields to expose for an 0x0622 signature effect slot.
Returns the allowed field set, or ``None`` to suppress the slot entirely.
"""
if _experimental():
return set(_ALL_NVCONFIG_FIELDS)
model_id = getattr(device, "modelId", None) or ""
return HEADSET_SIGNATURE_EFFECTS_ALLOWED.get(model_id, {}).get(effect_id)

View File

@ -73,10 +73,9 @@ logger = logging.getLogger(__name__)
# KeyPress action gets the current keyboard group using XkbGetState from libX11.so using ctypes definitions
# under Wayland the keyboard group is None resulting in using the first keyboard group
# KeyPress action translates keysyms to keycodes using the GDK keymap
# KeyPress, MouseScroll, and MouseClick actions use uinput.
# KeyPress, MouseScroll, and MouseClick actions use XTest (under X11) or uinput.
# For uinput to work the user must have write access for /dev/uinput.
# The Solaar udev rule should set this up
# Otherwise run sudo setfacl -m u:${user}:rw /dev/uinput
# To get this access run sudo setfacl -m u:${user}:rw /dev/uinput
#
# Rule GUI keyname determination uses a local file generated
# from http://cgit.freedesktop.org/xorg/proto/x11proto/plain/keysymdef.h
@ -86,7 +85,8 @@ logger = logging.getLogger(__name__)
# Setting up is complex because there are several systems that each provide partial facilities:
# GDK - always available (when running with a window system) but only provides access to keymap
# X11 - provides access to active process and process with window under mouse and current modifier keys
# uinput and evdev - provides input simulation
# Xtest extension to X11 - provides input simulation, partly works under Wayland
# Wayland - provides input simulation
XK_KEYS: Dict[str, int] = keysymdef.key_symbols
@ -111,11 +111,14 @@ if wayland:
)
try:
import Xlib
_x11 = None # X11 might be available
except Exception:
_x11 = False # X11 is not available
# Globals
xtest_available = True # Xtest might be available
xdisplay = None
@ -167,7 +170,7 @@ class XkbStateRec(ctypes.Structure):
def x11_setup():
global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS
global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS, xtest_available
if _x11 is not None:
return _x11
try:
@ -184,6 +187,7 @@ def x11_setup():
except Exception:
logger.warning("X11 not available - some rule capabilities inoperable", exc_info=sys.exc_info())
_x11 = False
xtest_available = False
return _x11
@ -268,6 +272,10 @@ def setup_uinput():
logger.warning("cannot create uinput device: %s", e)
if wayland: # Wayland can't use xtest so may as well set up uinput now
setup_uinput()
def kbdgroup():
if xkb_setup():
state = XkbStateRec()
@ -316,6 +324,31 @@ def xy_direction(_x, _y):
return "noop"
def simulate_xtest(code, event):
global xtest_available
if x11_setup() and xtest_available:
try:
event = (
Xlib.X.KeyPress
if event == _KEY_PRESS
else Xlib.X.KeyRelease
if event == _KEY_RELEASE
else Xlib.X.ButtonPress
if event == _BUTTON_PRESS
else Xlib.X.ButtonRelease
if event == _BUTTON_RELEASE
else None
)
Xlib.ext.xtest.fake_input(xdisplay, event, code)
xdisplay.sync()
if logger.isEnabledFor(logging.DEBUG):
logger.debug("xtest simulated input %s %s %s", xdisplay, event, code)
return True
except Exception as e:
xtest_available = False
logger.warning("xtest fake input failed: %s", e)
def simulate_uinput(what, code, arg):
global udevice
if setup_uinput():
@ -331,11 +364,30 @@ def simulate_uinput(what, code, arg):
def simulate_key(code, event): # X11 keycode but Solaar event code
if not wayland and simulate_xtest(code, event):
return True
if evdev and simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event):
return True
logger.warning("no way to simulate key input")
def click_xtest(button, count):
if isinstance(count, int):
for _ in range(count):
if not simulate_xtest(button[0], _BUTTON_PRESS):
return False
if not simulate_xtest(button[0], _BUTTON_RELEASE):
return False
else:
if count != RELEASE:
if not simulate_xtest(button[0], _BUTTON_PRESS):
return False
if count != DEPRESS:
if not simulate_xtest(button[0], _BUTTON_RELEASE):
return False
return True
def click_uinput(button, count):
if isinstance(count, int):
for _ in range(count):
@ -354,6 +406,8 @@ def click_uinput(button, count):
def click(button, count):
if not wayland and click_xtest(button, count):
return True
if click_uinput(button, count):
return True
logger.warning("no way to simulate mouse click")
@ -361,6 +415,14 @@ def click(button, count):
def simulate_scroll(dx, dy):
if not wayland and xtest_available:
success = True
if dx:
success = click_xtest(buttons["scroll_right" if dx > 0 else "scroll_left"], count=abs(dx))
if dy and success:
success = click_xtest(buttons["scroll_up" if dy > 0 else "scroll_down"], count=abs(dy))
if success:
return True
if setup_uinput():
success = True
if dx:
@ -1259,17 +1321,17 @@ class MouseClick(Action):
self.count = count
elif warn:
logger.warning(
"rule MouseClick action: argument %s should be an integer or click, depress, or release",
"rule MouseClick action: argument %s should be an integer or CLICK, PRESS, or RELEASE",
count,
)
self.count = 1
def __str__(self):
return f"MouseClick: {self.button} ({str(self.count)})"
return f"MouseClick: {self.button} ({int(self.count)})"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.INFO):
logger.info(f"MouseClick action: {str(self.count)} {self.button}")
logger.info(f"MouseClick action: {int(self.count)} {self.button}")
if self.button and self.count:
click(buttons[self.button], self.count)
time.sleep(0.01)
@ -1437,7 +1499,7 @@ def key_is_down(key: NamedInt) -> bool:
def evaluate_rules(feature, notification: HIDPPNotification, device):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluating rules on %s %s", feature, notification)
logger.debug("evaluating rules on %s", notification)
rules.evaluate(feature, notification, device, True)

View File

@ -1,211 +0,0 @@
## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Shared helpers for devices exposing Feature 0x0620 HEADSET_RGB_HOSTMODE.
The G522 is currently the only Solaar-supported device advertising this
feature, but anything else presenting 0x0620 will pick the same code path
automatically. The module deliberately avoids G522-specific assumptions
so future RGB-capable headsets can reuse it.
Two entry points the settings templates rely on:
- `discover_zones(device)` one-shot zone enumeration run at setting
build time. Briefly claims Solaar host control so GetRGBZoneInfo
returns a non-empty zone list, then restores the previous host-mode
state. Result is cached on the device.
- `write_zone_map(device, zone_color_map)` the shared write path used
by both the "LEDs Primary" and "Per-zone Lighting" settings. Groups
zones by final RGB color and emits one SetRgbZonesSingleValue per
unique color, then a single FrameEnd to commit.
"""
from __future__ import annotations
import logging
from typing import Iterable
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
# Function IDs on Feature 0x0620 we actually use.
FN_GET_RGB_ZONE_INFO = 0x10
FN_SET_RGB_ZONES_SINGLE = 0x50
FN_FRAME_END = 0x60
FN_GET_HOST_MODE_STATE = 0x70
FN_SET_HOST_MODE_STATE = 0x80
# Frame type sent with FrameEnd. 0x01 = transient commit (re-applies on the
# next refresh). 0x02 would be persistent, but G522 firmware rejects it
# with LOGITECH_INTERNAL (0x05) unless an onboard profile precondition we
# haven't mapped yet is satisfied.
FRAME_TYPE_TRANSIENT = 0x01
_HOST_MODE_SOLAAR = 1
_HOST_MODE_DEVICE = 0
def _device_cache_attr() -> str:
return "_headset_rgb_zone_ids"
def _read_host_mode(device) -> int | None:
"""Read the current host-mode state byte, or None on any failure."""
try:
resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_HOST_MODE_STATE)
except Exception as e:
logger.debug("headset_rgb: GetHostModeState raised %s", e)
return None
if not resp or len(resp) < 1:
return None
return resp[0]
def _set_host_mode(device, value: int) -> bool:
try:
device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_HOST_MODE_STATE, bytes([value & 0xFF]))
except Exception as e:
logger.debug("headset_rgb: SetHostModeState(%d) raised %s", value, e)
return False
return True
def _parse_zone_info(resp: bytes) -> list[int]:
"""Parse a GetRGBZoneInfo response into a zone-id list.
Two formats observed: "tight" ([count, zone_ids...]) on G522, and
the canonical protocol-doc layout (3-byte gap + 1-byte reserved
before zone IDs). Both are tried; whichever yields exactly `count`
IDs wins. Zone id 0 isn't filtered — some devices may use it.
"""
if not resp or len(resp) < 1:
return []
zone_count = resp[0]
tight = list(resp[1 : 1 + zone_count]) if 1 <= zone_count <= len(resp) - 1 else []
if tight and len(tight) == zone_count:
return tight
gap = list(resp[5 : 5 + zone_count]) if len(resp) >= 5 + zone_count else []
if gap and len(gap) == zone_count:
return gap
return []
def discover_zones(device) -> list[int] | None:
"""Return the list of RGB zone IDs on `device`, or None on failure.
Caches the result on `device._headset_rgb_zone_ids` so subsequent
callers don't repeat the round-trip. Briefly claims Solaar host mode
if needed GetRGBZoneInfo has been observed to return count=0 when
the device is still under firmware control and restores the prior
state afterward so user-configured onboard effects resume.
"""
cached = getattr(device, _device_cache_attr(), None)
if cached:
return cached
if not getattr(device, "online", False):
return None
prior_mode = _read_host_mode(device)
claimed = False
if prior_mode != _HOST_MODE_SOLAAR:
if not _set_host_mode(device, _HOST_MODE_SOLAAR):
return None
claimed = True
try:
try:
resp = device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_GET_RGB_ZONE_INFO)
except Exception as e:
logger.debug("headset_rgb: GetRGBZoneInfo raised %s", e)
return None
zones = _parse_zone_info(bytes(resp) if resp else b"")
if not zones:
logger.debug(
"headset_rgb: GetRGBZoneInfo returned no zones (raw=%s)",
resp.hex() if resp else resp,
)
return None
logger.debug("headset_rgb: discovered %d zone(s) %s", len(zones), [f"0x{z:02X}" for z in zones])
setattr(device, _device_cache_attr(), zones)
return zones
finally:
if claimed and prior_mode is not None:
_set_host_mode(device, prior_mode)
def _split_rgb(color_int: int) -> tuple[int, int, int]:
return (color_int >> 16) & 0xFF, (color_int >> 8) & 0xFF, color_int & 0xFF
def write_zone_map(device, zone_color_map: dict) -> bool:
"""Apply a zone->RGB mapping to the device.
`zone_color_map` maps zone id (int) to 24-bit RGB color (int,
`(r<<16)|(g<<8)|b`). Claims host mode, groups zones by color,
emits one SetRgbZonesSingleValue per unique color, then a single
FrameEnd. Returns True on success, False on any transport error.
"""
if not zone_color_map:
return False
if not getattr(device, "online", False):
logger.debug("headset_rgb: device offline, skipping write")
return False
# Group zones by color for batched writes.
groups: dict[int, list[int]] = {}
for zone, color in zone_color_map.items():
groups.setdefault(int(color), []).append(int(zone))
try:
_set_host_mode(device, _HOST_MODE_SOLAAR)
for color_int, zones in groups.items():
r, g, b = _split_rgb(color_int)
# SetRgbZonesSingleValue: [R, G, B, count, zone_ids...]
payload = bytes([r, g, b, len(zones)]) + bytes(zones)
device.feature_request(SupportedFeature.HEADSET_RGB_HOSTMODE, FN_SET_RGB_ZONES_SINGLE, payload)
logger.debug(
"headset_rgb: set (%02X,%02X,%02X) on %d zone(s) %s",
r,
g,
b,
len(zones),
[f"0x{z:02X}" for z in zones],
)
# FrameEnd commits the pending per-zone updates. Transient commit
# only — persistent (0x02) requires onboard-profile preconditions
# that aren't mapped yet.
device.feature_request(
SupportedFeature.HEADSET_RGB_HOSTMODE,
FN_FRAME_END,
bytes([FRAME_TYPE_TRANSIENT, 0x00, 0x00, 0x00]),
)
except Exception as e:
logger.warning("headset_rgb: write_zone_map failed: %s", e)
return False
return True
def zone_named_ints(zones: Iterable[int]):
"""Build a list of NamedInt keys suitable for a ChoicesMap setting.
Factored out so settings code can import without pulling common.NamedInt
at module-load time if preferred.
"""
from . import common
return [common.NamedInt(int(z), f"Zone {int(z)}") for z in zones]

View File

@ -189,9 +189,7 @@ class Hidpp10:
write_register(device, Registers.THREE_LEDS, v1, v2)
def get_notification_flags(self, device: Device):
flags = self._get_register(device, Registers.NOTIFICATIONS)
if flags is not None:
return NotificationFlag(flags)
return self._get_register(device, Registers.NOTIFICATIONS)
def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag):
assert device is not None

View File

@ -16,8 +16,8 @@
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from __future__ import annotations
from enum import Flag
from enum import IntEnum
from enum import IntFlag
from typing import List
from .common import NamedInts
@ -68,7 +68,7 @@ class PowerSwitchLocation(IntEnum):
return cls.UNKNOWN
class NotificationFlag(IntFlag):
class NotificationFlag(Flag):
"""Some flags are used both by devices and receivers.
The Logitech documentation mentions that the first and last (third)
@ -89,14 +89,23 @@ class NotificationFlag(IntFlag):
"""
@classmethod
def flag_names(cls, flags) -> List[str]:
def flag_names(cls, flag_bits: int) -> List[str]:
"""Extract the names of the flags from the integer."""
if flags is None:
return []
if flags.name is not None:
return flags.name.replace("_", " ").lower().split("|")
# Python < 3.11: .name is None for composite flags, decompose manually
return [m.name.replace("_", " ").lower() for m in cls if m.value and m in flags]
indexed = {item.value: item.name for item in cls}
flag_names = []
unknown_bits = flag_bits
for k in indexed:
# Ensure that the key (flag value) is a power of 2 (a single bit flag)
assert bin(k).count("1") == 1
if k & flag_bits == k:
unknown_bits &= ~k
flag_names.append(indexed[k].replace("_", " ").lower())
# Yield any remaining unknown bits
if unknown_bits != 0:
flag_names.append(f"unknown:{unknown_bits:06X}")
return flag_names
NUMPAD_NUMERICAL_KEYS = 0x800000
F_LOCK_STATUS = 0x400000
@ -116,13 +125,13 @@ class NotificationFlag(IntFlag):
THREED_GESTURE = 0x000001
def flags_to_str(flags, fallback: str) -> str:
def flags_to_str(flag_bits: int | None, fallback: str) -> str:
flag_names = []
if flags is not None and flags is not False:
if flags.value == 0:
if flag_bits is not None:
if flag_bits == 0:
flag_names = (fallback,)
else:
flag_names = NotificationFlag.flag_names(flags)
flag_names = NotificationFlag.flag_names(flag_bits)
return f"\n{' ':15}".join(sorted(flag_names))
@ -147,19 +156,11 @@ class PairingError(IntEnum):
TOO_MANY_DEVICES = 0x03
SEQUENCE_TIMEOUT = 0x06
@property
def label(self) -> str:
return self.name.lower().replace("_", " ")
class BoltPairingError(IntEnum):
DEVICE_TIMEOUT = 0x01
FAILED = 0x02
@property
def label(self) -> str:
return self.name.lower().replace("_", " ")
class Registers(IntEnum):
"""Known HID registers.
@ -212,7 +213,7 @@ class InfoSubRegisters(IntEnum):
BOLT_DEVICE_NAME = 0x60 # 0x6N01, by connected device
class DeviceFeature(IntFlag):
class DeviceFeature(Flag):
"""Features for devices.
Flags taken from

View File

@ -21,10 +21,8 @@ import socket
import struct
import threading
from collections import UserDict
from enum import Flag
from enum import IntEnum
from random import getrandbits
from typing import Any
from typing import Dict
from typing import Generator
@ -36,13 +34,10 @@ import yaml
from solaar.i18n import _
from typing_extensions import Protocol
from . import centurion as _centurion
from . import common
from . import exceptions
from . import hidpp10_constants
from . import special_keys
from .centurion_constants import CenturionCoreFeature
from .centurion_constants import resolve_feature
from .common import Battery
from .common import BatteryLevelApproximation
from .common import BatteryStatus
@ -52,7 +47,6 @@ from .hidpp20_constants import DEVICE_KIND
from .hidpp20_constants import ChargeLevel
from .hidpp20_constants import ChargeType
from .hidpp20_constants import ErrorCode
from .hidpp20_constants import FeatureFlag
from .hidpp20_constants import GestureId
from .hidpp20_constants import ParamId
from .hidpp20_constants import SupportedFeature
@ -85,9 +79,6 @@ class Device(Protocol):
...
# pfps: Consider adding a class method that sanitizes inputs by removing unknown bits.
class KeyFlag(Flag):
"""Capabilities and desired software handling for a control.
@ -95,11 +86,6 @@ class KeyFlag(Flag):
We treat bytes 4 and 8 of `getCidInfo` as a single bitfield.
"""
UNUSED_8000 = 0x8000
UNUSED_4000 = 0x4000
UNUSED_2000 = 0x2000
UNUSED_1000 = 0x1000
RAW_WHEEL = 0x800
ANALYTICS_KEY_EVENTS = 0x400
FORCE_RAW_XY = 0x200
RAW_XY = 0x100
@ -119,10 +105,6 @@ class MappingFlag(Flag):
We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield
"""
UNUSED_4000 = 0x4000
UNUSED_1000 = 0x1000
RAW_WHEEL = 0x400
UNKNOWN_200 = 0x200 # seen on a Wireless Mouse M510 WPID 4004
ANALYTICS_KEY_EVENTS_REPORTING = 0x100
FORCE_RAW_XY_DIVERTED = 0x40
RAW_XY_DIVERTED = 0x10
@ -143,9 +125,7 @@ class FeaturesArray(dict):
self.supported = True # Actually don't know whether it is supported yet
self.device = device
self.inverse = {}
self.sub_inverse = {}
self.version = {}
self.flags = {}
self.count = 0
def _check(self) -> bool:
@ -167,156 +147,23 @@ class FeaturesArray(dict):
logger.warning("FEATURE_SET found, but failed to read features count")
return False
else:
self.count = count[0] + 1 # ROOT feature not included in count
self[SupportedFeature.ROOT] = 0
self[SupportedFeature.FEATURE_SET] = fs_index
if getattr(self.device, "centurion", False):
self._check_centurion(fs_index, count)
else:
self.count = count[0] + 1 # ROOT feature not included in count
return True
else:
self.supported = False
return False
def _check_centurion(self, fs_index, count_response):
"""Enumerate features on a Centurion device (parent + sub-device via CentPPBridge).
Phase A: Enumerate parent device features via CenturionFeatureSet.
Find the CentPPBridge index (feature ID 0x0003 on Centurion = CentPPBridge).
Phase B: Route through CentPPBridge to discover sub-device features.
Use CenturionFeatureSet bulk query to get all sub-device features.
Store sub-device features keyed by SupportedFeature enum.
"""
# Phase A: Parent features
feature_count = count_response[0] # includes ROOT on Centurion
self.count = feature_count
bridge_index = None
for index in range(feature_count):
if self.inverse.get(index) is not None:
continue # already registered (ROOT=0, FEATURE_SET=fs_index)
response = self.device.request((fs_index << 8) | 0x10, index)
if response is None or len(response) < 3:
continue
# Centurion FeatureSet response: [remaining_count, feat_hi, feat_lo, type, version]
feat_id = struct.unpack("!H", response[1:3])[0]
feat_type = response[3] if len(response) > 3 else 0
feat_version = response[4] if len(response) > 4 else 0
feature = resolve_feature(feat_id, centurion=True)
if feature is None:
feature = f"unknown:{feat_id:04X}"
self[feature] = index
self.inverse[index] = feature
# Record version/flags so version-gated settings (sidetone, auto-sleep)
# use the correct payload format on direct USB Centurion devices too.
self.version[feature] = feat_version
self.flags[feature] = feat_type
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"Centurion parent feature: %s at index %d, version=%d, flags=0x%02X",
feature,
index,
feat_version,
feat_type,
)
if feature is CenturionCoreFeature.CENT_PP_BRIDGE:
bridge_index = index
if bridge_index is not None:
self.device._centurion_bridge_index = bridge_index
self.device._centurion_sub_features = set()
self.device._centurion_sub_indices = {}
self._discover_sub_device_features(bridge_index)
def _discover_sub_device_features(self, bridge_index):
"""Phase B: Discover sub-device features via CentPPBridge.
Uses per-index queries: GetCount (func 0) returns total count, then
GetFeatureId (func 1) returns one feature per call. Avoids the
single-frame truncation of bulk queries a Centurion frame is 64
bytes so a bulk reply can only fit ~13 features regardless of how
many the sub-device actually has.
"""
# First, find the sub-device's FeatureSet index via CenturionRoot (sub_feat_idx=0)
# Query: CenturionRoot.GetFeature(0x0001) to find FeatureSet index on sub-device
fs_id_hi = (SupportedFeature.FEATURE_SET >> 8) & 0xFF
fs_id_lo = SupportedFeature.FEATURE_SET & 0xFF
response = self.device.centurion_bridge_request(0x00, 0x00, fs_id_hi, fs_id_lo)
if response is None or len(response) < 1:
logger.warning("Failed to find FeatureSet on Centurion sub-device")
return
sub_fs_index = response[0]
if sub_fs_index == 0:
logger.warning("Sub-device FeatureSet not found (index=0)")
return
# Query feature count (function 0 = GetCount). Response: [count, ...].
count_resp = self.device.centurion_bridge_request(sub_fs_index, 0x00)
if count_resp is None or len(count_resp) < 1:
logger.warning("Failed to read Centurion sub-device feature count")
return
total_count = count_resp[0]
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Centurion sub-device: FeatureSet reports %d features", total_count)
# Per-index query: GetFeatureId (function 1 = 0x10).
# Response: [remaining, feat_hi, feat_lo, type, version].
# We now also record `type` (flags) and `version` for each feature so
# version-gated settings (sidetone, auto-sleep, etc.) can use the
# correct payload format instead of defaulting to V0.
sub_feat_idx = 0
for idx in range(total_count):
response = self.device.centurion_bridge_request(sub_fs_index, 0x10, idx)
if response is None or len(response) < 3:
logger.debug("Centurion sub-device: no response at index %d", idx)
continue
feat_id = struct.unpack("!H", response[1:3])[0]
feat_type = response[3] if len(response) > 3 else 0
feat_version = response[4] if len(response) > 4 else 0
try:
feature = SupportedFeature(feat_id)
except ValueError:
feature = f"unknown:{feat_id:04X}"
self.device._centurion_sub_indices[feature] = sub_feat_idx
if dict.get(self, feature) is None:
dict.__setitem__(self, feature, sub_feat_idx)
self.device._centurion_sub_features.add(feature)
self.sub_inverse[sub_feat_idx] = feature
# Record version/flags so downstream settings can version-gate their
# payload format. get_feature_version(feature) reads self.version[feature].
self.version[feature] = feat_version
self.flags[feature] = feat_type
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"Centurion sub-device feature: %s at sub-index %d, version=%d, flags=0x%02X",
feature,
sub_feat_idx,
feat_version,
feat_type,
)
sub_feat_idx += 1
self._sub_feature_count = sub_feat_idx
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Centurion sub-device: discovered %d features total", sub_feat_idx)
def get_feature(self, index: int) -> SupportedFeature | None:
feature = self.inverse.get(index)
if feature is not None:
return feature
# Sub-device index; bridge unwrap offsets by 0x100 (see listener).
if index >= 0x100:
return self.sub_inverse.get(index - 0x100)
elif self._check():
feature = self.inverse.get(index)
if feature is not None:
return feature
# On Centurion devices, all features are discovered upfront (parent + sub-device)
if getattr(self.device, "centurion", False):
return None
try:
response = self.device.feature_request(SupportedFeature.FEATURE_SET, 0x10, index)
except exceptions.FeatureCallError:
logger.warning("failed to retrieve feature at index %d", index)
return None
response = self.device.feature_request(SupportedFeature.FEATURE_SET, 0x10, index)
if response:
data = struct.unpack("!H", response[:2])[0]
try:
@ -325,35 +172,18 @@ class FeaturesArray(dict):
feature = f"unknown:{data:04X}"
self[feature] = index
self.version[feature] = response[3]
self.flags[feature] = response[2]
return feature
def enumerate(self): # return all features and their index, ordered by index
if self._check():
for index in range(self.count):
feature = self.get_feature(index)
if feature is not None:
yield feature, index
# Also yield sub-device features for Centurion devices
sub_count = getattr(self, "_sub_feature_count", 0)
for sub_idx in range(sub_count):
feature = self.sub_inverse.get(sub_idx)
if feature is not None:
yield feature, sub_idx
yield feature, index
def get_feature_version(self, feature: NamedInt) -> Optional[int]:
if self[feature]:
return self.version.get(feature, 0)
def get_flags(self, feature: NamedInt) -> Optional[int]:
if self[feature]:
return self.flags.get(feature, 0)
def get_hidden(self, feature: NamedInt) -> Optional[bool]:
if self[feature]:
return self.flags.get(feature, 0) & FeatureFlag.INTERNAL
return True
def __contains__(self, feature: NamedInt) -> bool:
try:
index = self.__getitem__(feature)
@ -369,21 +199,11 @@ class FeaturesArray(dict):
index = super().get(feature)
if index is not None:
return index
# Centurion devices enumerate all features upfront in _check_centurion().
# If the feature isn't in the dict after _check(), it genuinely doesn't
# exist — skip the raw ROOT.GetFeature query that the dongle rejects
# with LOGITECH_ERROR and that creates cycling log spam during settings init.
if getattr(self.device, "centurion", False):
return None
try:
response = self.device.request(0x0000, struct.pack("!H", feature))
except exceptions.FeatureCallError:
return None
response = self.device.request(0x0000, struct.pack("!H", feature))
if response:
index = response[0]
self[feature] = index if index else False
self.version[feature] = response[2]
self.flags[feature] = response[1]
return index if index else False
def __setitem__(self, feature, index):
@ -397,7 +217,7 @@ class FeaturesArray(dict):
raise ValueError("Don't delete features from FeatureArray")
def __len__(self) -> int:
return self.count + getattr(self, "_sub_feature_count", 0)
return self.count
__bool__ = __nonzero__ = _check
@ -1169,37 +989,22 @@ class LEDParam:
ramp = "ramp"
form = "form"
saturation = "saturation"
direction = "direction"
# NamedInts (not IntEnum) so the GTK ComboBoxText shows readable labels.
LedRampChoice = common.NamedInts(Default=0, Yes=1, No=2)
class LedRampChoice(IntEnum):
DEFAULT = 0
YES = 1
NO = 2
LedFormChoices = common.NamedInts(
Default=0,
Sine=1,
Square=2,
Triangle=3,
Sawtooth=4,
Shark_fin=5,
Exponential=6,
)
LedDirectionChoices = common.NamedInts()
LedDirectionChoices[0] = _("Cycle")
LedDirectionChoices[1] = _("Right")
LedDirectionChoices[2] = _("Down")
LedDirectionChoices[3] = _("Center Out")
LedDirectionChoices[4] = _("In")
LedDirectionChoices[5] = _("Out")
LedDirectionChoices[6] = _("Left")
LedDirectionChoices[7] = _("Up")
LedDirectionChoices[8] = _("Center In")
# Direction values to hide on devices whose LED grid can't render them.
LedDirectionBlocklist = {
"40B4": {4, 5}, # G515 LS TKL — no edge-radiating wave geometry
}
class LedFormChoices(IntEnum):
DEFAULT = 0
SINE = 1
SQUARE = 2
TRIANGLE = 3
SAWTOOTH = 4
SHARKFIN = 5
EXPONENTIAL = 6
LEDParamSize = {
@ -1210,77 +1015,33 @@ LEDParamSize = {
LEDParam.ramp: 1,
LEDParam.form: 1,
LEDParam.saturation: 1,
LEDParam.direction: 1,
}
# Entry: [NamedInt, params, defaults, ranges] — trailing dicts optional.
# ranges overrides a field's global min/max, e.g. period: (2, 200).
# not implemented from x8070 Wave=4, Stars=5, Press=6, Audio=7
# not implemented from x8071 Custom=12, Kitt=13, HSVPulsing=20,
# WaveC=22, RippleC=23, SignatureActive=24, SignaturePassive=25
LEDEffects = {
0x00: [NamedInt(0x00, _("Disabled")), {}],
0x01: [NamedInt(0x01, _("Static")), {LEDParam.color: 0, LEDParam.ramp: 3}],
0x02: [NamedInt(0x02, _("Pulse")), {LEDParam.color: 0, LEDParam.speed: 3}],
0x03: [
NamedInt(0x03, _("Cycle")),
{LEDParam.period: 5, LEDParam.intensity: 7},
{LEDParam.period: 5000, LEDParam.intensity: 100},
],
# No probe device enumerates base Wave; assume the 0x16 layout so the
# UI matches what 0x16-capable hardware shows.
0x04: [
NamedInt(0x04, _("Wave")),
{LEDParam.period: 6, LEDParam.direction: 9},
{LEDParam.period: 5000},
],
0x03: [NamedInt(0x03, _("Cycle")), {LEDParam.period: 5, LEDParam.intensity: 7}],
0x08: [NamedInt(0x08, _("Boot")), {}],
0x09: [NamedInt(0x09, _("Demo")), {}],
0x0A: [
NamedInt(0x0A, _("Breathe")),
{LEDParam.color: 0, LEDParam.period: 3, LEDParam.form: 5, LEDParam.intensity: 6},
{LEDParam.period: 5000, LEDParam.intensity: 100},
],
0x0B: [
NamedInt(0x0B, _("Ripple")),
{LEDParam.color: 0, LEDParam.period: 4},
{LEDParam.period: 20},
{LEDParam.period: (2, 200)},
],
0x0B: [NamedInt(0x0B, _("Ripple")), {LEDParam.color: 0, LEDParam.period: 4}],
0x0E: [NamedInt(0x0E, _("Decomposition")), {LEDParam.period: 6, LEDParam.intensity: 8}],
0x0F: [NamedInt(0x0F, _("Signature1")), {LEDParam.period: 5, LEDParam.intensity: 7}],
0x10: [NamedInt(0x10, _("Signature2")), {LEDParam.period: 5, LEDParam.intensity: 7}],
0x15: [
NamedInt(0x15, _("Cycle")),
{LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8},
{LEDParam.saturation: 255, LEDParam.period: 5000, LEDParam.intensity: 100},
],
0x16: [
NamedInt(0x16, _("Wave")),
{LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8, LEDParam.direction: 9},
{LEDParam.saturation: 255, LEDParam.period: 5000, LEDParam.intensity: 100},
],
# Saturation derivative of Ripple 0x0B; pcap layout: color @ 0-2,
# saturation @ 3, period @ 6-7.
0x17: [
NamedInt(0x17, _("Ripple")),
{LEDParam.color: 0, LEDParam.saturation: 3, LEDParam.period: 6},
{LEDParam.saturation: 255, LEDParam.period: 20},
{LEDParam.period: (2, 200)},
],
# Synthetic — host-side dim ramp, no wire effect.
0x80: [NamedInt(0x80, _("Dim")), {LEDParam.intensity: 0}],
0x15: [NamedInt(0x15, _("CycleS")), {LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8}],
}
class LEDEffectSetting: # an effect plus its parameters
# Params whose value space is an RGB color; wrapped in ColorInt so the
# value self-formats as ``0xrrggbb`` in solaar show and the YAML config.
_COLOR_PARAMS = (str(LEDParam.color),)
def __init__(self, **kwargs):
self.ID = None
for key, val in kwargs.items():
# type(val) is int — exact match excludes NamedInt/ColorInt and
# any other int subclass; only "raw" ints get wrapped here.
if key in self._COLOR_PARAMS and type(val) is int and 0 <= val <= 0xFFFFFF: # noqa: E721
val = common.ColorInt(val)
setattr(self, key, val)
@classmethod
@ -1664,7 +1425,7 @@ class OnboardProfiles:
device.ping()
response = device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x00)
memory, profile, _macro = struct.unpack("!BBB", response[0:3])
if memory != 0x01 or profile > 0x05:
if memory != 0x01 or profile > 0x04:
return
count, oob, buttons, sectors, size, shift = struct.unpack("!BBBBHB", response[3:10])
gbuttons = buttons if (shift & 0x3 == 0x2) else 0
@ -1759,11 +1520,6 @@ def feature_request(device, feature, function=0x00, *params, no_reply=False):
class Hidpp20:
# Host-side counter for SetComplete cookies (see set_configuration_complete).
# Seeded to a non-zero random 16-bit value at import so successive sessions
# don't trivially collide; we just need to never send 0x0000.
_session_cookie = getrandbits(16) or 1
def get_firmware(self, device) -> tuple[common.FirmwareInfo] | None:
"""Reads a device's firmware info.
@ -1793,27 +1549,6 @@ class Hidpp20:
fw.append(fw_info)
return tuple(fw)
def get_firmware_centurion(self, device):
return _centurion.get_firmware_centurion(device)
def get_serial_centurion(self, device):
return _centurion.get_serial_centurion(device)
def get_hardware_info_centurion(self, device):
return _centurion.get_hardware_info_centurion(device)
def _centurion_sub_device_info_request(self, device, function=0x00, *params):
return _centurion._centurion_sub_device_info_request(device, function, *params)
def get_firmware_centurion_sub(self, device):
return _centurion.get_firmware_centurion_sub(device)
def get_serial_centurion_sub(self, device):
return _centurion.get_serial_centurion_sub(device)
def get_hardware_info_centurion_sub(self, device):
return _centurion.get_hardware_info_centurion_sub(device)
def get_ids(self, device):
"""Reads a device's ids (unit and model numbers)"""
ids = device.feature_request(SupportedFeature.DEVICE_FW_VERSION)
@ -1865,9 +1600,6 @@ class Hidpp20:
return name.decode("utf-8")
def get_name_centurion(self, device):
return _centurion.get_name_centurion(device)
def get_friendly_name(self, device: Device):
"""Reads a device's friendly name.
@ -1912,9 +1644,6 @@ class Hidpp20:
except exceptions.FeatureCallError:
return SupportedFeature.ADC_MEASUREMENT if SupportedFeature.ADC_MEASUREMENT in device.features else None
def get_battery_centurion(self, device: Device):
return _centurion.get_battery_centurion(device)
def get_battery(self, device, feature):
"""Return battery information - feature, approximate level, next, charging, voltage
or battery feature if there is one but it is not responding or None for no battery feature"""
@ -1935,10 +1664,10 @@ class Hidpp20:
def get_keys(self, device: Device):
# TODO: add here additional variants for other REPROG_CONTROLS
count = None
if device.features and SupportedFeature.REPROG_CONTROLS_V2 in device.features:
if SupportedFeature.REPROG_CONTROLS_V2 in device.features:
count = device.feature_request(SupportedFeature.REPROG_CONTROLS_V2)
return KeysArrayV2(device, ord(count[:1]))
elif device.features and SupportedFeature.REPROG_CONTROLS_V4 in device.features:
elif SupportedFeature.REPROG_CONTROLS_V4 in device.features:
count = device.feature_request(SupportedFeature.REPROG_CONTROLS_V4)
return KeysArrayV4(device, ord(count[:1]))
return None
@ -1960,12 +1689,6 @@ class Hidpp20:
if SupportedFeature.BACKLIGHT2 in device.features:
return Backlight(device)
def get_force_buttons(self, device: Device):
if getattr(device, "_force_buttons", None) is not None:
return device._force_buttons
if SupportedFeature.FORCE_SENSING_BUTTON in device.features:
return ForceSensingButtonArray(device)
def get_profiles(self, device: Device):
if getattr(device, "_profiles", None) is not None:
return device._profiles
@ -2137,41 +1860,7 @@ class Hidpp20:
SupportedFeature._fallback = lambda x: f"unknown:{x:04X}"
return result
def get_keyboard_layout(self, device: Device):
"""Return the device's keyboard layout country code, or None.
Country code semantics match the HID HUT keyboard country codes that
Logitech's KEYBOARD_LAYOUT_2 (0x4540) feature reports in the first byte.
Used by the per-key painter to pick the matching regional layout.
"""
result = device.feature_request(SupportedFeature.KEYBOARD_LAYOUT_2, 0x00)
if result:
return struct.unpack("!B", result[:1])[0]
return None
def get_configuration_cookie(self, device: Device):
"""ConfigChange (0x0020) GetCookie — read the device's current configuration cookie."""
response = device.feature_request(SupportedFeature.CONFIG_CHANGE, 0x00)
return response[:2] if response else None
def next_session_cookie(self):
"""Bump and return the host-side counter used as the SetComplete cookie."""
Hidpp20._session_cookie = (Hidpp20._session_cookie + 1) & 0xFFFF or 1
return bytes([Hidpp20._session_cookie >> 8, Hidpp20._session_cookie & 0xFF])
def set_configuration_complete(self, device: Device, cookie=None, no_reply=False):
"""ConfigChange (0x0020) SetComplete — acknowledge host has synced with device configuration.
Sends a host-side monotonic counter, incremented per call and
always non-zero. Cookie 0x0000 has been observed to release the
SW effect-engine claim on at least the G515 LS TKL; we avoid it."""
if cookie is None:
cookie = self.next_session_cookie()
if cookie and len(cookie) >= 2:
return device.feature_request(SupportedFeature.CONFIG_CHANGE, 0x10, cookie[0], cookie[1], no_reply=no_reply)
def config_change(self, device: Device, configuration, no_reply=False):
"""Deprecated — use set_configuration_complete() instead."""
return device.feature_request(SupportedFeature.CONFIG_CHANGE, 0x10, configuration, no_reply=no_reply)
@ -2180,7 +1869,6 @@ battery_functions = {
SupportedFeature.BATTERY_VOLTAGE: Hidpp20.get_battery_voltage,
SupportedFeature.UNIFIED_BATTERY: Hidpp20.get_battery_unified,
SupportedFeature.ADC_MEASUREMENT: Hidpp20.get_adc_measurement,
SupportedFeature.CENTURION_BATTERY_SOC: Hidpp20.get_battery_centurion,
}
@ -2261,9 +1949,6 @@ def decipher_battery_unified(report) -> tuple[SupportedFeature, Battery]:
return SupportedFeature.UNIFIED_BATTERY, Battery(discharge if discharge else approx_level, None, status, None)
decipher_battery_centurion = _centurion.decipher_battery_centurion
def decipher_adc_measurement(report) -> tuple[SupportedFeature, Battery]:
# partial implementation - needs mapping to levels
adc_voltage, flags = struct.unpack("!HB", report[:3])
@ -2312,113 +1997,3 @@ def estimate_battery_level_percentage(value_millivolt: int) -> int | None:
percent = p_low + (p_high - p_low) * (value_millivolt - v_low) / (v_high - v_low)
return round(percent)
return 0
class ForceSensingButton:
"""A button that has a force value at which to trigger the button"""
@classmethod
def create(cls, device, number: int):
buttondata = device.feature_request(SupportedFeature.FORCE_SENSING_BUTTON, 0x10, number)
buttoncurrent = device.feature_request(SupportedFeature.FORCE_SENSING_BUTTON, 0x20, number)
if buttondata is not None and buttoncurrent is not None:
changeable, default, max_value, min_value = struct.unpack("!HHHH", buttondata[:8])
changeable = changeable & 0x01
current = struct.unpack("!H", buttoncurrent[:2])[0]
return cls(device, number, changeable, default, max_value, min_value, current)
def __init__(self, device, number: int, changeable: bool, default: int, max_value: int, min_value: int, current: int):
self._device = device
self.number = number
self.changeable = changeable
self.default = default
self.min_value = min_value
self.max_value = max_value
self._current = current
def get_current(self) -> int:
return self._current
def set_current(self, current: int) -> None:
if not self.changeable:
logger.warning(f"FORCE_SENSING_BUTTON on device {self._device} does not allow changing force.")
if self.min_value <= current <= self.max_value:
ret = self._device.feature_request(
SupportedFeature.FORCE_SENSING_BUTTON, 0x30, struct.pack("!BH", self.number, current)
)
if ret is None and logger.isEnabledFor(logging.DEBUG):
logger.debug(f"FORCE_SENSING_BUTTON setButtonConfig on device {self._device} didn't respond.")
def acceptable_current(self, value: int) -> bool:
return self.min_value <= value <= self.max_value
class ForceSensingButtonArray(UserDict):
"""A map of buttons supporting force sensing"""
def __new__(cls, device: Device):
assert device is not None
count = device.feature_request(SupportedFeature.FORCE_SENSING_BUTTON, 0x00)
if count:
instance = super().__new__(cls)
instance._count = ord(count[:1])
return instance
def __init__(self, device: Device):
super().__init__(self)
self.device = device
for index in range(0, self._count):
self[index] = None
def __getitem__(self, index: int):
item = super().__getitem__(index)
if item is None:
self.query_key(index)
return super().__getitem__(index)
def query_key(self, index):
if index not in self:
raise IndexError(index)
button = ForceSensingButton.create(self.device, index)
if button:
self[index] = button
return button
def query(self):
for index in self:
button = ForceSensingButton.create(self.device, index)
if button:
self[index] = button
return self
# interface for single force button
def get_current(self):
return self[0].get_current()
def set_current(self, current: int) -> None:
self[0].set_current(current)
def acceptable(self, value: int) -> bool:
return self[0].acceptable(value)
def acceptable_current_key(self, index: int, value: int) -> bool:
return self[index].acceptable(value)
# --- OnboardEQ (0x0636) — re-exported from onboard_eq.py ---
# --- AdvancedParaEQ (0x020D) — re-exported from advanced_para_eq.py ---
from .advanced_para_eq import FILTER_TYPE_HP # noqa: E402, F401
from .advanced_para_eq import FILTER_TYPE_PEAKING # noqa: E402, F401
from .advanced_para_eq import FILTER_TYPE_PEAKING_G522 # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_active_slot # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_defaults # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_friendly_name # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_info # noqa: E402, F401
from .advanced_para_eq import get_advanced_eq_params # noqa: E402, F401
from .advanced_para_eq import parse_v2_bands # noqa: E402, F401
from .advanced_para_eq import probe_advanced_eq_slots # noqa: E402, F401
from .advanced_para_eq import probe_all_presets as probe_advanced_eq_presets # noqa: E402, F401
from .onboard_eq import _build_set_eq_payload # noqa: E402, F401
from .onboard_eq import get_onboard_eq_info # noqa: E402, F401
from .onboard_eq import get_onboard_eq_params # noqa: E402, F401
from .onboard_eq import set_onboard_eq_params # noqa: E402, F401

View File

@ -40,7 +40,6 @@ class SupportedFeature(IntEnum):
DEVICE_GROUPS = 0x0006
DEVICE_FRIENDLY_NAME = 0x0007
KEEP_ALIVE = 0x0008
PROPERTY_ACCESS = 0x0011
CONFIG_CHANGE = 0x0020
CRYPTO_ID = 0x0021
TARGET_SOFTWARE = 0x0030
@ -62,13 +61,10 @@ class SupportedFeature(IntEnum):
CONFIG_DEVICE_PROPS = 0x1806
CHANGE_HOST = 0x1814
HOSTS_INFO = 0x1815
BLE_PRO_PRE_PAIRING = 0x1816
BACKLIGHT = 0x1981
BACKLIGHT2 = 0x1982
BACKLIGHT3 = 0x1983
ILLUMINATION = 0x1990
FORCE_SENSING_BUTTON = 0x19C0
HAPTIC = 0x19B0
PRESENTER_CONTROL = 0x1A00
SENSOR_3D = 0x1A01
REPROG_CONTROLS = 0x1B00
@ -76,16 +72,10 @@ class SupportedFeature(IntEnum):
REPROG_CONTROLS_V2_2 = 0x1B02 # LogiOptions 2.10.73 features.xml
REPROG_CONTROLS_V3 = 0x1B03
REPROG_CONTROLS_V4 = 0x1B04
ANALOG_BUTTONS = 0x1B0C # Analog button tuning (actuation point, rapid trigger, haptics)
FULL_KEY_CUSTOMIZATION = 0x1B05
CONTROL_LIST = 0x1B10
SWITCH_SWAPABILITY = 0x1B20
DEVICE_MODE = 0x1B30
REPORT_HID_USAGE = 0x1BC0
PERSISTENT_REMAPPABLE_ACTION = 0x1C00
WIRELESS_DEVICE_STATUS = 0x1D4B
REMAINING_PAIRING = 0x1DF0
ENABLE_HIDDEN_FEATURES = 0x1E00
FIRMWARE_PROPERTIES = 0x1F1F
ADC_MEASUREMENT = 0x1F20
# Mouse
@ -118,7 +108,6 @@ class SupportedFeature(IntEnum):
KEYBOARD_LAYOUT = 0x4520
KEYBOARD_DISABLE_KEYS = 0x4521
KEYBOARD_DISABLE_BY_USAGE = 0x4522
KEYBOARD_DISABLE_CONTROLS = 0x4523
DUALPLATFORM = 0x4530
MULTIPLATFORM = 0x4531
KEYBOARD_LAYOUT_2 = 0x4540
@ -141,98 +130,22 @@ class SupportedFeature(IntEnum):
MKEYS = 0x8020
MR = 0x8030
BRIGHTNESS_CONTROL = 0x8040
LOGI_MODIFIERS = 0x8051
REPORT_RATE = 0x8060
EXTENDED_ADJUSTABLE_REPORT_RATE = 0x8061
COLOR_LED_EFFECTS = 0x8070
RGB_EFFECTS = 0x8071
RPM_INDICATOR = 0x807A
RPM_LED_PATTERN = 0x807B
PER_KEY_LIGHTING = 0x8080
PER_KEY_LIGHTING_V2 = 0x8081
MODE_STATUS = 0x8090
LEGACY_AXIS_RESPONSE_CURVE = 0x80A3
AXIS_RESPONSE_CURVE = 0x80A4
BANDED_AXIS = 0x80B1
COMBINED_PEDALS = 0x80D0
BUNNY_HOPPING = 0x80E0
ONBOARD_PROFILES = 0x8100
PROFILE_MANAGEMENT = 0x8101
MOUSE_BUTTON_SPY = 0x8110
LATENCY_MONITORING = 0x8111
GAMING_ATTACHMENTS = 0x8120
FORCE_FEEDBACK = 0x8123
DUAL_CLUTCH = 0x8127
WHEEL_CENTER_POSITION = 0x812C
DISPLAY_GAME_DATA = 0x8130
CENTER_SPRING = 0x8131
AXIS_MAPPING = 0x8132
GLOBAL_DAMPING = 0x8133
BRAKE_FORCE = 0x8134
PEDAL_STATUS = 0x8135
TORQUE_LIMIT = 0x8136
CONFIGURATION_PROFILES = 0x8137
OPERATING_RANGE = 0x8138
TRUE_FORCE = 0x8139
FFB_FILTER = 0x8140
# Headsets
SIDETONE = 0x8300
EQUALIZER = 0x8310
HEADSET_OUT = 0x8320
# Centurion core
CENTURION_DEVICE_INFO = 0x0100
CENTURION_DEVICE_NAME = 0x0101
CENTURION_ROOT = 0x0102
CENTURION_MEMFAULT = 0x0103
CENTURION_BATTERY_SOC = 0x0104
CENTURION_AUTO_SLEEP = 0x0108
CENTURION_GENERIC_DFU = 0x010A
CENTURION_LED_BRIGHTNESS = 0x0110
CENTURION_EU_POWER_MODE = 0x0115
CENTURION_DEVICE_BOOL_STATE = 0x0116
# Headsets (Centurion-era)
HEADSET_VOLUME = 0x0200
HEADSET_EQ = 0x0201
HEADSET_ADVANCED_PARA_EQ = 0x020D
HEADSET_MIC_TEST = 0x020E
HEADSET_EQ_STYLES = 0x0213
BT_HOST_INFO = 0x0305
LIGHTSPEED_PAIRING = 0x0309
BT_GAMING_MODE = 0x030A
HEADSET_RGB_EFFECTS = 0x0600
HEADSET_MIC_MUTE = 0x0601
HEADSET_MIC_SNR = 0x0602
HEADSET_AUDIO_SIDETONE = 0x0604
HEADSET_HOST_SWITCH = 0x0607
HEADSET_MIX = 0x0609
HEADSET_TONES = 0x060B
HEADSET_NOISE_EXPOSURE = 0x060D
HEADSET_AI_NOISE_REDUCTION = 0x060E
HEADSET_MIC_GAIN = 0x0611
HEADSET_USAGE_TRACKING = 0x0617
HEADSET_BATTERY_SAVER = 0x0618
HEADSET_RGB_HOSTMODE = 0x0620
HEADSET_RGB_ONBOARD_EFFECTS = 0x0621
HEADSET_RGB_SIGNATURE_EFFECTS = 0x0622
HEADSET_DO_NOT_DISTURB = 0x0631
CENTURION_ONBOARD_PROFILES = 0x0634
HEADSET_RGB_STREAMING = 0x0635
HEADSET_ONBOARD_EQ = 0x0636
# Audio mixing / LogiVoice
MIXER_AUDIO = 0x0800
MIXER_MIC = 0x0801
LOGIVOICE = 0x0900
LOGIVOICE_NOISE_REDUCTION = 0x0901
LOGIVOICE_NOISE_GATE = 0x0902
LOGIVOICE_COMPRESSOR = 0x0903
LOGIVOICE_DE_ESSER = 0x0904
LOGIVOICE_DE_POPPER = 0x0905
LOGIVOICE_LIMITER = 0x0906
LOGIVOICE_HIGH_PASS_FILTER = 0x0907
LOGIVOICE_EQUALIZER = 0x0908
LOGIVOICE_AINR = 0x0909
METERING = 0x0B01
MIC_GAIN_AUTO_MODE = 0x0B02
# Fake features for Solaar internal use
MOUSE_GESTURE = 0xFE00
@ -363,23 +276,3 @@ class ParamId(IntEnum):
PIXEL_ZONE = 2 # 4 2-byte integers, left, bottom, width, height; pixels
RATIO_ZONE = 3 # 4 bytes, left, bottom, width, height; unit 1/240 pad size
SCALE_FACTOR = 4 # 2-byte integer, with 256 as normal scale
HapticWaveForms = NamedInts(
SHARP_STATE_CHANGE=0x00,
DAMP_STATE_CHANGE=0x01,
SHARP_COLLISION=0x02,
DAMP_COLLISION=0x03,
SUBTLE_COLLISION=0x04,
HAPPY_ALERT=0x05,
ANGRY_ALERT=0x06,
COMPLETED=0x07,
SQUARE=0x08,
WAVE=0x09,
FIREWORK=0x0A,
MAD=0x0B,
KNOCK=0x0C,
JINGLE=0x0D,
RINGING=0xE,
WHISPER_COLLISION=0x1B,
)

View File

@ -15,7 +15,6 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import dataclasses
import logging
import queue
import threading
@ -53,11 +52,6 @@ class _ThreadedHandle:
else:
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug("%r opened new handle %d", self, handle)
# If original handle was centurion, copy state to new per-thread handle
for h in self._handles:
if h in base._centurion_handles:
base._centurion_handles[handle] = dataclasses.replace(base._centurion_handles[h])
break
self._local.handle = handle
self._handles.append(handle)
return handle
@ -151,8 +145,7 @@ class EventsListener(threading.Thread):
self.receiver.close()
break
if n:
report_id, devnumber, data = n
n = base.make_notification(report_id, devnumber, data)
n = base.make_notification(*n)
else:
n = self._queued_notifications.get() # deliver any queued notifications
if n:

View File

@ -1,300 +0,0 @@
## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""LogiVoice (0x0900 + 0x0901-0x0907) read helpers.
Each LogiVoice processing module exposes the same 5-function API:
fn 0 SetState
fn 1 GetState -> u8 state (boolean)
fn 2 SetParameters
fn 3 GetParameters -> module-specific payload (see PARAMETERS_FIELDS)
fn 4 GetInfo -> per-field [min, max] bounds (see parse_info)
All multi-byte integers on the wire are big-endian. Parameters layouts are
module-specific; PARAMETERS_FIELDS encodes per-field offset / width /
signedness / range / label metadata. The first field is at offset 0 there
is no leading "state" byte (the state toggle is on fn 0/1 only).
Writes are NOT implemented yet. State toggles via fn 0x00/0x10 are
shipping as boolean settings; per-field Parameters writes need a live
round-trip verification before they're safe to expose.
"""
from __future__ import annotations
import logging
import struct
from typing import Iterable
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
# Wire function IDs (standard across all LogiVoice modules).
FN_SET_STATE = 0x00
FN_GET_STATE = 0x10
FN_SET_PARAMETERS = 0x20
FN_GET_PARAMETERS = 0x30
FN_GET_INFO = 0x40
# Human-readable names for the modules Solaar may see on a LogiVoice device.
MODULE_NAMES = {
SupportedFeature.LOGIVOICE: "LogiVoice",
SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "Noise Reduction",
SupportedFeature.LOGIVOICE_NOISE_GATE: "Noise Gate",
SupportedFeature.LOGIVOICE_COMPRESSOR: "Compressor",
SupportedFeature.LOGIVOICE_DE_ESSER: "De-esser",
SupportedFeature.LOGIVOICE_DE_POPPER: "De-popper",
SupportedFeature.LOGIVOICE_LIMITER: "Limiter",
SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "High Pass Filter",
}
# Short slugs used in Solaar setting IDs (`logivoice-<slug>-<field>`).
MODULE_SLUGS = {
SupportedFeature.LOGIVOICE_NOISE_REDUCTION: "nr",
SupportedFeature.LOGIVOICE_NOISE_GATE: "ng",
SupportedFeature.LOGIVOICE_COMPRESSOR: "comp",
SupportedFeature.LOGIVOICE_DE_ESSER: "deesser",
SupportedFeature.LOGIVOICE_DE_POPPER: "depopper",
SupportedFeature.LOGIVOICE_LIMITER: "limiter",
SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: "hpf",
}
class Field:
"""Metadata for one decoded Parameters field.
offset: byte offset within the GetParameters payload.
byte_count: width (1 or 2 for fields we currently decode).
signed: whether to interpret as signed int.
min_value/max_value: range for the Solaar slider validator. For opaque
fields, use the full representable range (0..255 or 0..65535).
label: human-readable name for UI.
opaque: True if the field's wire encoding isn't pinned down label
shows raw units and the caller should treat as round-trip.
"""
def __init__(self, name, offset, byte_count, signed, min_value, max_value, label, opaque=False):
self.name = name
self.offset = offset
self.byte_count = byte_count
self.signed = signed
self.min_value = min_value
self.max_value = max_value
self.label = label
self.opaque = opaque
# Per-module field layout for GetParameters / SetParameters payload. Each
# module's struct is the union of named fields below; there is no separate
# "state" byte at offset 0 — that toggle is only on fn 0x00/0x10. Field
# encodings (signedness, byte order, units) and value ranges come from the
# device's GetInfo response (see parse_info) and are confirmed against
# captured bring-up bytes; ranges hardcoded here are the bounds the device
# reports and the values it ships as factory defaults.
#
# `opaque=True` is reserved for fields whose unit scale isn't pinned down
# (currently width_q on De-esser / De-popper — the host-side scale constant
# is loaded at runtime and not statically resolvable). Treat opaque values
# as monotonic raw integers until a live probe anchors the units.
PARAMETERS_FIELDS: dict[SupportedFeature, list[Field]] = {
SupportedFeature.LOGIVOICE_NOISE_REDUCTION: [
Field("sensitivity", 0, 1, False, 0, 40, "Sensitivity"),
Field("release", 1, 2, False, 1, 1000, "Release (ms)"),
Field("bias", 3, 1, False, 0, 5, "Bias"),
Field("attenuation", 4, 1, True, -20, 0, "Attenuation (dB)"),
],
SupportedFeature.LOGIVOICE_NOISE_GATE: [
Field("threshold", 0, 1, True, -60, -35, "Threshold (dB)"),
Field("attenuation", 1, 1, True, -50, -3, "Attenuation (dB)"),
Field("attack", 2, 2, False, 1, 200, "Attack (ms)"),
Field("hold", 4, 2, False, 1, 1000, "Hold (ms)"),
Field("release", 6, 2, False, 1, 1000, "Release (ms)"),
],
SupportedFeature.LOGIVOICE_COMPRESSOR: [
Field("threshold", 0, 1, True, -40, 0, "Threshold (dB)"),
Field("attack", 1, 2, False, 1, 200, "Attack (ms)"),
Field("release", 3, 2, False, 50, 1000, "Release (ms)"),
Field("post_gain", 5, 1, True, -12, 12, "Post Gain (dB)"),
Field("pre_gain", 6, 1, True, -12, 12, "Pre Gain (dB)"),
# Ratio reports min=1 max=20 from GetInfo; whether the device interprets
# it as a literal X:1 ratio or a curve-table index is unconfirmed.
Field("ratio", 7, 1, False, 1, 20, "Ratio"),
],
SupportedFeature.LOGIVOICE_DE_ESSER: [
Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"),
Field("frequency", 1, 2, False, 1000, 10000, "Frequency (Hz)"),
# width_q is a Q-format quantization with a device-loaded scale we
# don't know; range/default come straight from GetInfo.
Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True),
Field("attack", 4, 2, False, 1, 200, "Attack (ms)"),
Field("release", 6, 2, False, 20, 1000, "Release (ms)"),
Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"),
],
SupportedFeature.LOGIVOICE_DE_POPPER: [
Field("threshold", 0, 1, True, -50, 0, "Threshold (dB)"),
Field("frequency", 1, 2, False, 60, 500, "Frequency (Hz)"),
Field("width_q", 3, 1, False, 2, 120, "Width/Q", opaque=True),
Field("attack", 4, 2, False, 1, 200, "Attack (ms)"),
Field("release", 6, 2, False, 20, 1000, "Release (ms)"),
Field("attenuation", 8, 1, True, -40, 0, "Attenuation (dB)"),
],
SupportedFeature.LOGIVOICE_LIMITER: [
Field("boost", 0, 1, True, -128, 127, "Boost (dB)"),
Field("attack", 1, 2, False, 1, 65535, "Attack (ms)"),
Field("release", 3, 2, False, 1, 65535, "Release (ms)"),
],
SupportedFeature.LOGIVOICE_HIGH_PASS_FILTER: [
Field("frequency", 0, 2, False, 60, 300, "Cutoff (Hz)"),
],
}
def expected_payload_length(feature: SupportedFeature) -> int:
fields = PARAMETERS_FIELDS.get(feature)
if not fields:
return 0
return max(f.offset + f.byte_count for f in fields)
def get_state(device, feature: SupportedFeature):
"""Read the module's on/off state via fn 1. Returns int 0-255 or None."""
result = device.feature_request(feature, FN_GET_STATE)
if result is None or len(result) < 1:
return None
return result[0]
def get_parameters(device, feature: SupportedFeature):
"""Read the module's Parameters struct via fn 3. Returns raw bytes or None."""
result = device.feature_request(feature, FN_GET_PARAMETERS)
if result is None:
return None
return bytes(result)
def get_info(device, feature: SupportedFeature):
"""Read module capability info via fn 4. Returns raw bytes or None.
Decoded per-field bounds are available via parse_info().
"""
result = device.feature_request(feature, FN_GET_INFO)
if result is None:
return None
return bytes(result)
def _decode_field(chunk: bytes, byte_count: int, signed: bool) -> int:
"""Decode `byte_count` bytes from `chunk` as an integer per the field's wire
encoding. Multi-byte values are big-endian (matches Parameters)."""
if byte_count == 1:
return struct.unpack("b" if signed else "B", chunk[:1])[0]
if byte_count == 2:
return struct.unpack(">h" if signed else ">H", chunk[:2])[0]
return int.from_bytes(chunk[:byte_count], "big", signed=signed)
def parse_info(feature: SupportedFeature, payload: bytes) -> dict:
"""Decode a GetInfo response into per-field {min, max} bounds.
Layout: for each field in PARAMETERS_FIELDS in order, the payload carries
[min_value, max_value] back-to-back using the field's wire encoding (so
a u16 field contributes 4 bytes 2 for min, 2 for max). Trailing bytes
in the response are pad/zero.
Returns a dict mapping field name to {"min": int, "max": int}. Fields
that don't fit in the payload are omitted.
"""
fields = PARAMETERS_FIELDS.get(feature)
if not fields or not payload:
return {}
out = {}
offset = 0
for f in fields:
end = offset + 2 * f.byte_count
if end > len(payload):
break
min_val = _decode_field(payload[offset : offset + f.byte_count], f.byte_count, f.signed)
max_val = _decode_field(payload[offset + f.byte_count : end], f.byte_count, f.signed)
out[f.name] = {"min": min_val, "max": max_val}
offset = end
return out
def parse_parameters(feature: SupportedFeature, payload: bytes) -> dict:
"""Decode Parameters bytes into a dict per the per-module field table.
Returns {} on unknown feature or short payload caller still has the raw
hex via get_parameters() for corpus logging.
"""
fields = PARAMETERS_FIELDS.get(feature)
if not fields or payload is None:
return {}
parsed = {}
for f in fields:
end = f.offset + f.byte_count
if end > len(payload):
continue
chunk = payload[f.offset : end]
if f.byte_count == 1:
val = struct.unpack("b" if f.signed else "B", chunk)[0]
elif f.byte_count == 2:
val = struct.unpack(">h" if f.signed else ">H", chunk)[0]
else:
val = int.from_bytes(chunk, "big", signed=f.signed)
parsed[f.name] = val
return parsed
def probe_module(device, feature: SupportedFeature) -> None:
"""One-shot corpus probe. Logs state + raw parameters + parsed + raw info
+ decoded info bounds."""
name = MODULE_NAMES.get(feature, f"0x{int(feature):04X}")
state = get_state(device, feature)
params = get_parameters(device, feature)
info = get_info(device, feature)
logger.debug(
"LogiVoice %s [0x%04X]: state=%s parameters=%s info=%s",
name,
int(feature),
state,
params.hex() if params else None,
info.hex() if info else None,
)
parsed = parse_parameters(feature, params) if params else {}
if parsed:
logger.debug("LogiVoice %s parsed: %s", name, parsed)
bounds = parse_info(feature, info) if info else {}
if bounds:
logger.debug("LogiVoice %s info bounds: %s", name, bounds)
def probe_all_modules(device, features: Iterable[SupportedFeature]) -> None:
"""Probe every LogiVoice module present on the device.
Call once at device-bring-up so the -dd corpus has a full snapshot.
Caller passes whichever subset of LogiVoice features are actually
discovered (usually derived from device.features).
"""
for feature in features:
if feature not in PARAMETERS_FIELDS and feature != SupportedFeature.LOGIVOICE:
continue
try:
probe_module(device, feature)
except Exception as e:
logger.debug("LogiVoice probe_module(%s) raised %s", feature, e)

View File

@ -34,7 +34,6 @@ from . import diversion
from . import hidpp10
from . import hidpp10_constants
from . import hidpp20
from . import rgb_power
from . import settings_templates
from .common import Alert
from .common import BatteryStatus
@ -281,16 +280,13 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
result = hidpp20.decipher_adc_measurement(notification.data)
if result: # if good data and the device was not present then a push is needed
device.set_battery_info(result[1])
device.changed(active=True, alert=Alert.NONE, reason=_("ADC measurement notification"), push=not old_present)
device.changed(active=True, alert=Alert.ALL, reason=_("ADC measurement notification"), push=not old_present)
else: # this feature is also used to signal device becoming inactive
device.present = False # exception to device presence
device.changed(active=False)
else:
logger.warning("%s: unknown ADC MEASUREMENT %s", device, notification)
elif feature == SupportedFeature.CENTURION_BATTERY_SOC:
device.set_battery_info(hidpp20.decipher_battery_centurion(notification.data)[1])
elif feature == SupportedFeature.SOLAR_DASHBOARD:
if notification.data[5:9] == b"GOOD":
charge, lux, adc = struct.unpack("!BHH", notification.data[:5])
@ -323,14 +319,9 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
if logger.isEnabledFor(logging.DEBUG):
logger.debug("wireless status: %s", notification)
reason = "powered on" if notification.data[2] == 1 else None
if notification.data[1] == 1: # device is asking for software reconfiguration
if notification.data[1] == 1: # device is asking for software reconfiguration so need to change status
alert = Alert.NONE
device.changed(active=True, alert=alert, reason=reason)
# changed(active=True) already runs apply_settings_if_needed on
# the first transition; for follow-up reconfig notifications
# on an already-active device, fire the gate here so the
# cookie comparison decides whether to re-push.
device.apply_settings_if_needed()
device.changed(active=True, alert=alert, reason=reason, push=True)
else:
logger.warning("%s: unknown WIRELESS %s", device, notification)
@ -428,55 +419,6 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
brightness = struct.unpack("!H", device.feature_request(SupportedFeature.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
elif feature == SupportedFeature.RGB_EFFECTS:
fn = notification.address >> 4
if fn == 1: # onUserActivity: type=0 is IDLE, type!=0 is ACTIVE
activity_type = notification.data[0] if notification.data else 0xFF
rgb_power.on_user_activity(device, activity_type)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB_EFFECTS notification addr=%02x: %s", device, notification.address, notification)
elif feature == SupportedFeature.HEADSET_ADVANCED_PARA_EQ:
# G522 emits change events with the same payload shape as the
# corresponding setter request:
# fn 0 — band change (3-byte header [dir, slot, pad] + bands)
# fn 2 — friendly-name change (header + nameLen + name)
# fn 3 — UUID change (header + 16-byte UUID)
# Low nibble of `address` is the swid the firmware echoes back —
# match on the function index only.
fn = notification.address >> 4
if fn == 0:
info = getattr(device, "_advanced_eq_info", None)
payload = notification.data[3:] if notification.data else b""
if info and len(payload) >= 5:
bands = hidpp20.parse_v2_bands(b"\x00" + payload, info)
if bands and device.setting_callback:
band_map = {i: int(round(g)) for i, (_t, _f, g) in enumerate(bands)}
device.setting_callback(device, settings_templates.HeadsetAdvancedEQ, [band_map])
elif logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: HEADSET_ADVANCED_PARA_EQ band-change event with no parseable payload %s", device, notification
)
elif fn in (2, 3) and logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: HEADSET_ADVANCED_PARA_EQ fn=%d change event %s", device, fn, notification)
elif logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: unknown HEADSET_ADVANCED_PARA_EQ %s", device, notification)
elif feature == SupportedFeature.HEADSET_MIC_MUTE:
# G522 emits state-change events on two function indices, both carrying
# the new state in data[0] (0 = unmuted, 1 = muted):
# fn 0 — physical mute switch press
# fn 1 — echo following a host-driven SetState (fn 2) write
# Low nibble of `address` is the swid the firmware echoes back, which
# varies with the request — match on the function index only.
fn = notification.address >> 4
if fn in (0, 1) and notification.data:
muted = bool(notification.data[0])
if device.setting_callback:
device.setting_callback(device, settings_templates.HeadsetMicMute, [muted])
elif logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: unknown HEADSET_MIC_MUTE %s", device, notification)
diversion.process_notification(device, notification, feature)
return True
@ -491,8 +433,7 @@ def handle_pairing_lock(receiver: Receiver, notification: HIDPPNotification) ->
receiver.pairing.new_device = None
pair_error = ord(notification.data[:1])
if pair_error:
error_string = hidpp10_constants.PairingError(pair_error).label
receiver.pairing.error = error_string
receiver.pairing.error = error_string = hidpp10_constants.PairingError(pair_error).name
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
@ -512,7 +453,7 @@ def handle_discovery_status(receiver: Receiver, notification: HIDPPNotification)
receiver.pairing.device_passkey = None
discover_error = ord(notification.data[:1])
if discover_error:
receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error).label
receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error).name
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
receiver.changed(reason=reason)
return True
@ -554,7 +495,7 @@ def handle_pairing_status(receiver: Receiver, notification: HIDPPNotification) -
elif notification.address == 0x02 and not pair_error:
receiver.pairing.new_device = receiver.register_new_device(notification.data[7])
if pair_error:
receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error).label
receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error).name
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)

View File

@ -1,184 +0,0 @@
## Copyright (C) 2012-2013 Daniel Pavel
## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""OnboardEQ (0x0636) biquad coefficient math and payload builders.
Pure computation no device or transport dependencies beyond feature_request().
"""
from __future__ import annotations
import math
import struct
from .hidpp20_constants import SupportedFeature
# Opaque bytes observed between band params and coefficient header. First
# byte matches band_count; bytes 2-3 look like LE16 coeff blob size. Keep
# verbatim until a device counter-example forces a re-derivation.
_EQ_MYSTERY_BYTES = b"\x05\x5a\xe3\x00"
def _peaking_eq_biquad(freq_hz, gain_db, Q, sample_rate=48000.0):
"""Compute peaking EQ biquad coefficients (Audio EQ Cookbook).
Returns (b0/a0, b1/a0, b2/a0, a1/a0, a2/a0) normalised coefficients.
"""
A = 10.0 ** (gain_db / 40.0)
w0 = 2.0 * math.pi * freq_hz / sample_rate
cos_w0 = math.cos(w0)
alpha = math.sin(w0) / (2.0 * Q)
a0 = 1.0 + alpha / A
return (
(1.0 + alpha * A) / a0,
(-2.0 * cos_w0) / a0,
(1.0 - alpha * A) / a0,
(-2.0 * cos_w0) / a0,
(1.0 - alpha / A) / a0,
)
def _quantize_coeffs(b0, b1, b2, a1, a2):
"""Quantize biquad coefficients to mixed Q1.31 / Q2.30 fixed-point.
b0, b2, a2 use Q1.31 (x 2^31); b1, a1 use Q2.30 (x 2^30).
Values are truncated to 24-bit precision (low byte zeroed) matching
the device DSP's internal format.
Returns list of 10 uint16 values (5 coefficients x 2 LE words each,
high word first).
"""
scales = [2**31, 2**30, 2**31, 2**30, 2**31] # b0, b1, b2, a1, a2
words = []
for val, scale in zip([b0, b1, b2, a1, a2], scales):
q = int(round(val * scale))
q = max(-(1 << 31), min((1 << 31) - 1, q))
q = q & 0xFFFFFF00 # 24-bit precision (low byte always zero)
words.append((q >> 16) & 0xFFFF) # high word
words.append(q & 0xFFFF) # low word
return words
def _build_coeff_section(bands, sample_rate, section_type=1):
"""Build one coefficient section for a DSP processing block.
Returns bytes: 4-byte section header + coefficient data as LE uint16 words.
Section header: [type, 0x00, count_lo, count_hi].
Coefficients are normalized by a rescale factor to prevent Q1.31 overflow.
Only feedforward coefficients (b0, b1, b2) are divided by rescale; feedback
coefficients (a1, a2) are left unchanged. The DSP multiplies the output by
rescale to restore correct gain.
"""
_HEADROOM = 1.19 # 19% headroom margin before quantization
num_bands = len(bands)
all_words = [num_bands] # first uint16 = num_bands
# First pass: compute raw biquad coefficients for all bands
raw_coeffs = []
for freq, gain, Q in bands:
raw_coeffs.append(_peaking_eq_biquad(freq, gain, max(Q, 0.1), sample_rate))
# Compute rescale: ensure max |b0| fits in Q1.31 with headroom
max_b0 = max(abs(c[0]) for c in raw_coeffs)
rescale = max(1.0, max_b0) * _HEADROOM
# Second pass: normalize b-coefficients and quantize
for b0, b1, b2, a1, a2 in raw_coeffs:
all_words.extend(_quantize_coeffs(b0 / rescale, b1 / rescale, b2 / rescale, a1, a2))
# Rescale factor as Q6.26, 24-bit precision
rs = int(round(rescale * (1 << 26)))
rs = max(-(1 << 31), min((1 << 31) - 1, rs)) & 0xFFFFFF00
all_words.append((rs >> 16) & 0xFFFF)
all_words.append(rs & 0xFFFF)
coeff_count = num_bands * 10 + 3 # num_bands word + 10 per band + 2 rescale words
hdr = bytes([section_type, 0x00, coeff_count & 0xFF, (coeff_count >> 8) & 0xFF])
data = struct.pack(f"<{len(all_words)}H", *all_words)
return hdr + data
def _build_eq_coeffs_payload(bands):
"""Build the full EQCoeffs wire payload for SetEQParameters.
Two coefficient sections: type=1 (48 kHz playback) and type=2 (16 kHz mic).
Returns bytes: 7-byte header + sections (no trailing padding).
"""
section_count = 2
header = bytes([0x03, 0x0E, 0x00, section_count, 0x00, 0x00, 0x00])
sections = _build_coeff_section(bands, 48000.0, section_type=1)
sections += _build_coeff_section(bands, 16000.0, section_type=2)
return header + sections
def _build_set_eq_payload(slot, bands):
"""Build complete SetEQParameters payload: band params + biquad coefficients.
bands: list of (freq_hz, gain_db, Q) tuples.
Returns bytes ready to send as sub-device params.
"""
params = bytes([slot, len(bands)])
for freq, gain, Q in bands:
params += struct.pack(">H", freq) + bytes([gain & 0xFF, Q & 0xFF])
params += _EQ_MYSTERY_BYTES
params += _build_eq_coeffs_payload(bands)
return params
def get_onboard_eq_info(device):
"""Query HEADSET_ONBOARD_EQ GetEQInfos (function 0).
Returns (has_hw_eq, num_bands) or None.
"""
result = device.feature_request(SupportedFeature.HEADSET_ONBOARD_EQ, 0x00)
if result is None or len(result) < 5:
return None
has_hw_eq = bool(result[0] & 0x80)
num_bands = result[4]
return (has_hw_eq, num_bands)
def get_onboard_eq_params(device, slot=0x00):
"""Query HEADSET_ONBOARD_EQ GetEQParameters (function 0x10).
Returns list of (freq_hz, gain_db, q) tuples, or None.
"""
result = device.feature_request(SupportedFeature.HEADSET_ONBOARD_EQ, 0x10, slot)
if result is None or len(result) < 2:
return None
band_count = result[1]
bands = []
offset = 2
for _i in range(band_count):
if offset + 4 > len(result):
break
freq_hz = struct.unpack(">H", result[offset : offset + 2])[0]
gain_db = struct.unpack("b", bytes([result[offset + 2]]))[0] # signed
q = result[offset + 3]
bands.append((freq_hz, gain_db, q))
offset += 4
return bands
def set_onboard_eq_params(device, bands, slot=0x00):
"""Send HEADSET_ONBOARD_EQ SetEQParameters (function 0x20).
bands: list of (freq_hz, gain_db, Q) tuples.
Returns response or None.
"""
payload = _build_set_eq_payload(slot, bands)
return device.feature_request(SupportedFeature.HEADSET_ONBOARD_EQ, 0x20, payload)

View File

@ -413,31 +413,6 @@ class Receiver:
"""Receiver specific unpairing."""
return self.write_register(Registers.RECEIVER_PAIRING, 0x03, key)
def force_unpair_slot(self, slot: int) -> bool:
"""Force-unpair a slot by writing the unpair register, ignoring cache state.
Intended for clearing stale pairings on receivers (Lightspeed in particular)
where Solaar cannot read pairing info for a slot. Bypasses the ``may_unpair``
and ``re_pairs`` gates that ``_unpair_device`` applies. Returns True if the
register write was acknowledged by the receiver.
"""
if not self.handle:
return False
slot = int(slot)
reply = self._unpair_device_per_receiver(slot)
if reply:
cached = self._devices.get(slot)
if cached:
cached.online = False
cached.wpid = None
if slot in self._devices:
del self._devices[slot]
if logger.isEnabledFor(logging.INFO):
logger.info("%s force-unpaired slot %d", self, slot)
return True
logger.warning("%s failed to force-unpair slot %d", self, slot)
return False
def __len__(self):
return len([d for d in self._devices.values() if d is not None])

View File

@ -1,219 +0,0 @@
"""Read-only corpus probe for the headset RGB feature pair:
- HEADSET_RGB_ONBOARD_EFFECTS (0x0621)
- HEADSET_RGB_SIGNATURE_EFFECTS (0x0622)
Logs raw response bytes and lengths at INFO so field testers without
``-dd`` can still capture the data. All calls are strictly read-side
no setters are invoked. If a feature isn't present the probe
short-circuits cleanly.
Pcap analysis of G HUB's color-set traffic confirmed that on 0x0621,
``setRGBClusterEffect`` (fn 0x30) takes a 10-byte payload
``[cluster, effect_id_BE_u16, R, G, B, ...]`` where ``effect_id=0x0000``
means "Static (with RGB)" this is also the slot-0 entry in the
fn 0x10 ``getRGBClusterInfo`` reply, which we decode structurally so
the test corpus shows effect-id semantics in plaintext.
"""
from __future__ import annotations
import logging
from . import exceptions
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
def _hex_or_none(data) -> str | None:
return data.hex() if data else None
def _format_feature(feat) -> str:
"""Render a feature for the log: 0x{id:04X}{:NAME} when known, else raw.
Unknown features are stored as the string "unknown:HHHH" by the feature
discovery code, so handle that shape explicitly int(feat) on those
raises ValueError. Wrap the rest in a broad except so a future unhandled
feature shape can't kill the whole table dump.
"""
if feat is None:
return "?"
if isinstance(feat, str):
if feat.startswith("unknown:") and len(feat) > 8:
return f"0x{feat[8:].upper()}"
return feat
try:
return f"0x{int(feat):04X}:{feat.name}"
except (AttributeError, TypeError, ValueError):
try:
return f"0x{int(feat):04X}"
except (TypeError, ValueError):
return repr(feat)
def _log_feature_table(device) -> None:
if not device.features:
return
try:
# Parent features live in FeaturesArray.inverse, indexed by their
# parent feature-set position. On Centurion devices these are the
# ones the dongle itself exposes (typically 5-6 entries).
parent = []
for idx in range(len(device.features)):
parent.append(f"{idx}:{_format_feature(device.features[idx])}")
logger.debug("RGB probe: parent features for %s: %s", device, ", ".join(parent))
# Centurion sub-device features live in FeaturesArray.sub_inverse,
# keyed by sub-device feature index. These are where the actual
# headset features (0x0620/0x0621/0x0622, LogiVoice, EQ, mic mute,
# …) live — without dumping them the log shows only the dongle's
# parent features and gives the wrong impression that the device
# has nothing else.
sub_inverse = getattr(device.features, "sub_inverse", None)
if sub_inverse:
sub = [f"{idx}:{_format_feature(feat)}" for idx, feat in sorted(sub_inverse.items())]
logger.debug("RGB probe: sub-device features for %s: %s", device, ", ".join(sub))
except Exception as e:
logger.debug("RGB probe: feature-table dump failed: %s", e)
def _call(device, feature: SupportedFeature, fn: int, *params):
"""Wrap feature_request with uniform INFO logging.
Returns the raw bytes on success, None on transport/no-feature, and
doesn't raise — FeatureCallError is caught and logged as an error code.
"""
label = f"0x{int(feature):04X}.fn{fn:02X}"
if params:
label += "(" + ",".join(f"{b:02X}" for b in params) + ")"
try:
resp = device.feature_request(feature, fn, *params)
except exceptions.FeatureCallError as e:
logger.debug("RGB probe: %s err=0x%02X", label, getattr(e, "error", 0) & 0xFF)
return None
except Exception as e:
logger.debug("RGB probe: %s raised %r", label, e)
return None
if resp is None:
logger.debug("RGB probe: %s no reply (feature unsupported or transport failure)", label)
return None
logger.debug("RGB probe: %s resp=%s len=%d", label, _hex_or_none(resp), len(resp))
return resp
# Names for known effect_ids on the headset RGB cluster. Confirmed via
# pcap analysis of G HUB color-set traffic: setRGBClusterEffect with
# effect_id=0x0000 + RGB writes a static color, so 0x0000 is "Static"
# rather than the "Off / Disabled" we'd guessed from cluster ordering.
# Other ids haven't been observed on the wire yet — names are placeholder
# until further pcap traffic confirms them.
_EFFECT_ID_NAMES = {
0x0000: "Static",
0x0001: "Effect 0x0001",
0x0006: "Effect 0x0006",
0x0007: "Effect 0x0007",
0x000F: "Effect 0x000F",
0x007F: "Effect 0x007F",
}
def _decode_cluster_info(resp) -> str | None:
"""Decode a 0x0621 fn 0x10 getRGBClusterInfo reply into a readable
summary. Best-effort returns None on unexpected length/shape.
Observed shape on G522: 4-byte records (effect_id LE u16, slot_idx
LE u16). The effect_id at slot 0 is 0x0000 = "Static" (with RGB),
confirmed by pcap of G HUB color-set traffic. Records continue
until trailing-zero padding.
Note: most HID++ multi-byte fields are BE, but this particular
response uses LE confirmed against captured factory-default bytes
on G522 where the values 0x0001 / 0x000F / 0x007F appear at byte 0
of each record with byte 1 = 0x00 (consistent with LE u16).
"""
if not resp or len(resp) < 4:
return None
effects = []
seen_static = False
for i in range(0, len(resp) - 3, 4):
eid = resp[i] | (resp[i + 1] << 8)
slot = resp[i + 2] | (resp[i + 3] << 8)
# Skip purely-zero padding once we've seen the (effect=0, slot=0) entry.
if eid == 0 and slot == 0:
if seen_static:
continue
seen_static = True
name = _EFFECT_ID_NAMES.get(eid, f"0x{eid:04X}")
effects.append(f"slot={slot}:{name}")
return ", ".join(effects) if effects else None
def probe_onboard_effects(device) -> None:
"""Probe 0x0621 RGBOnboardEffects read-side functions."""
feature = SupportedFeature.HEADSET_RGB_ONBOARD_EFFECTS
if not device.features or feature not in device.features:
return
logger.debug("RGB probe: 0x0621 HEADSET_RGB_ONBOARD_EFFECTS present on %s", device)
# fn 0x00 getInfo — empty payload
_call(device, feature, 0x00)
# fn 0x10 getRGBClusterInfo — iterate cluster indexes 0..7, stop on error.
for cluster_idx in range(8):
resp = _call(device, feature, 0x10, cluster_idx)
if resp is None:
break
decoded = _decode_cluster_info(resp)
if decoded:
logger.debug("RGB probe: 0x0621.fn10(%02X) decoded: %s", cluster_idx, decoded)
# fn 0x20 getRGBClusterEffect — current state per cluster.
for cluster_idx in range(8):
resp = _call(device, feature, 0x20, cluster_idx)
if resp is None:
break
# fn 0x40 getRGBCustomEffectName — single call, documented.
_call(device, feature, 0x40)
def probe_signature_effects(device) -> None:
"""Probe 0x0622 RGBSignatureEffects read-side functions."""
feature = SupportedFeature.HEADSET_RGB_SIGNATURE_EFFECTS
if not device.features or feature not in device.features:
return
logger.debug("RGB probe: 0x0622 HEADSET_RGB_SIGNATURE_EFFECTS present on %s", device)
# fn 0x00 getSignatureEffectsInfo.
_call(device, feature, 0x00)
# fn 0x10 getSignatureEffectParams — iterate effectId 0..2 (Startup/Shutdown/Passive).
# effectId is u16 BE.
for eid in range(3):
_call(device, feature, 0x10, (eid >> 8) & 0xFF, eid & 0xFF)
# fn 0x30 getSignatureEffectState — same effectId range.
for eid in range(3):
_call(device, feature, 0x30, (eid >> 8) & 0xFF, eid & 0xFF)
def probe(device) -> None:
"""Run both read-only RGB-effects probes once per device.
Gated via ``_rgb_effects_probed`` so re-entry on reconnect / setting
rebuild doesn't spam the log with duplicate corpus dumps.
"""
if getattr(device, "_rgb_effects_probed", False):
return
device._rgb_effects_probed = True
_log_feature_table(device)
try:
probe_onboard_effects(device)
except Exception as e:
logger.debug("RGB probe: onboard-effects probe raised %r", e)
try:
probe_signature_effects(device)
except Exception as e:
logger.debug("RGB probe: signature-effects probe raised %r", e)

View File

@ -1,979 +0,0 @@
## Copyright (C) 2012-2013 Daniel Pavel
## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Software-driven RGB power management for devices that hand off LED
control to the host (RGB_EFFECTS / 0x8071).
Handles the firmware onUserActivity events, the two-stage idle effect
(smooth dim ramp or animation), and the software sleep timer that fires
after idle_timeout has elapsed.
"""
from __future__ import annotations
import logging
from time import sleep
from . import exceptions
from . import hidpp20_constants
from . import settings
from . import special_keys
from .hidpp20_constants import SupportedFeature
logger = logging.getLogger(__name__)
try:
from gi.repository import GLib
_has_glib = True
except ImportError:
_has_glib = False
# SetSWControl flag bits for RGB_EFFECTS (0x8071).
FLAG_EFFECT = 0x01
FLAG_POWER = 0x02
FLAG_NVCONFIG = 0x04
# SetSWControl payloads: [subfn=set, mode=3, flags]
SW_ACTIVE = bytes([0x01, 0x03, FLAG_NVCONFIG]) # firmware monitors idle
SW_IDLE = bytes([0x01, 0x03, FLAG_POWER]) # firmware monitors activity
SW_RELEASE = bytes([0x01, 0x00, 0x00])
_managers = {} # keyed by id(device)
def get_manager(device):
"""Return the active RGBPowerManager for `device`, or None."""
return _managers.get(id(device))
def on_user_activity(device, activity_type):
"""Dispatch firmware onUserActivity events to the device's power manager."""
mgr = _managers.get(id(device))
if mgr:
mgr.on_user_activity(activity_type)
def translate_color_for_display(color, state, dim_pct, dim_step, dim_steps):
"""Map a saved (undimmed) color to the display color for `state`.
Returns None for SLEEPING."""
if state == RGBPowerManager.ACTIVE:
return color
if state == RGBPowerManager.SLEEPING:
return None
target = RGBPowerManager._compute_dim_color(color, dim_pct)
if state == RGBPowerManager.IDLE:
return target
# DIMMING — interpolate from saved toward dimmed target by ramp progress.
t = (dim_step / dim_steps) if dim_steps else 1.0
return RGBPowerManager._interpolate_color(color, target, t)
def translate_for_device(device, color):
"""Translate `color` through the device's RGBPowerManager state, or
return it unchanged when no manager is registered. None signals SLEEPING."""
mgr = _managers.get(id(device))
if mgr is None:
return color
return mgr.translate_color(color)
_EFFECT_STATIC = 0x01
def perkey_has_paint(device):
"""Return ``(perkey_setting, has_paint)``. has_paint is True when the
per-key buffer has at least one real color and the user hasn't opted
out via the lock icon (sensitivity == IGNORE). The locked-but-applied
state (False) still counts as paint."""
perkey = None
for s in getattr(device, "settings", []) or []:
if s.name == "per-key-lighting":
perkey = s
break
if perkey is None:
return None, False
validator = getattr(perkey, "_validator", None)
choices = getattr(validator, "choices", None)
if not choices:
return perkey, False
# Apply path runs rgb_zone_ before per-key, so _value may still be None
# when this gate is consulted — fall back to the persister.
value = getattr(perkey, "_value", None)
persister = getattr(device, "persister", None)
if value is None and persister is not None:
value = persister.get("per-key-lighting")
if not value:
return perkey, False
no_change = special_keys.COLORSPLUS["No change"]
if not any(c != no_change and isinstance(c, int) and c >= 0 for c in value.values()):
return perkey, False
if persister is not None and persister.get_sensitivity("per-key-lighting") == settings.SENSITIVITY_IGNORE:
return perkey, False
return perkey, True
def zone_effect_is_static(device):
"""True when the persisted zone effect is Static, or when no
rgb_zone_* setting exists at all (per-key-only hardware)."""
has_zone = False
persister = getattr(device, "persister", None)
for s in getattr(device, "settings", []) or []:
if s.name.startswith("rgb_zone_"):
has_zone = True
value = getattr(s, "_value", None)
if value is None and persister is not None:
value = persister.get(s.name)
if value is not None and int(getattr(value, "ID", 0) or 0) == _EFFECT_STATIC:
return True
return not has_zone
def zone_effect_is_ignored(device):
"""True when every rgb_zone_* setting on `device` is marked
SENSITIVITY_IGNORE in the persister."""
persister = getattr(device, "persister", None)
if persister is None:
return False
zones = [s for s in getattr(device, "settings", []) or [] if s.name.startswith("rgb_zone_")]
if not zones:
return False
return all(persister.get_sensitivity(s.name) == settings.SENSITIVITY_IGNORE for s in zones)
def effective_zone_base_color(device):
"""Color to use for per-key unset cells: 0 (off/black) when the zone
effect is ignored (or unavailable), the persisted zone color otherwise.
Reads through the persister so we still get the saved color even before
apply has populated _value.
During an idle-Static transition the saved color is substituted with
the idle effect's color so unset cells track the idle primary. Reverts
on wake when state returns to ACTIVE."""
if zone_effect_is_ignored(device):
return 0
mgr = _managers.get(id(device))
if mgr is not None and mgr._state == RGBPowerManager.IDLE and mgr._idle_effect_id() == 0x01:
return int(getattr(mgr._idle_effect, "color", 0) or 0)
persister = getattr(device, "persister", None)
for s in getattr(device, "settings", []) or []:
if not s.name.startswith("rgb_zone_"):
continue
value = getattr(s, "_value", None)
if value is None and persister is not None:
value = persister.get(s.name)
if value is not None:
color = getattr(value, "color", None)
if isinstance(color, int):
return int(color)
return 0
_RETRY_BUSY_BACKOFF_MS = (30, 60, 90)
def feature_request_acked(device, feature, function, data=b"", retries=3):
"""feature_request with BUSY/timeout retries. Returns reply bytes
on ACK, None on hard failure (logged WARNING)."""
busy_attempt = 0
max_busy = len(_RETRY_BUSY_BACKOFF_MS)
for attempt in range(retries + 1):
try:
reply = device.feature_request(feature, function, data)
except exceptions.FeatureCallError as e:
if getattr(e, "error", None) == hidpp20_constants.ErrorCode.BUSY and busy_attempt < max_busy:
delay_ms = _RETRY_BUSY_BACKOFF_MS[busy_attempt]
busy_attempt += 1
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: feature 0x%04x fn 0x%02x BUSY, retry %d/%d after %dms",
device,
int(feature),
function,
busy_attempt,
max_busy,
delay_ms,
)
sleep(delay_ms / 1000.0)
continue
logger.warning("%s: feature 0x%04x fn 0x%02x rejected: %s", device, int(feature), function, e)
return None
if reply is not None:
if (attempt > 0 or busy_attempt > 0) and logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: feature 0x%04x fn 0x%02x succeeded after %d timeout retries, %d BUSY retries",
device,
int(feature),
function,
attempt,
busy_attempt,
)
return reply
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: feature 0x%04x fn 0x%02x timed out (attempt %d/%d)",
device,
int(feature),
function,
attempt + 1,
retries + 1,
)
logger.warning("%s: feature 0x%04x fn 0x%02x no ACK after %d attempts", device, int(feature), function, retries + 1)
return None
def _probe_tmpl_bytes(device):
"""GetEffectSpecificInfo page 1: returns (tmpl_0, tmpl_1) or
(None, None) if the device has no firmware effect cards."""
try:
reply = device.feature_request(SupportedFeature.RGB_EFFECTS, 0x00, 0xFF, 0x00, 0x01, 0x00, 0x01)
except exceptions.FeatureCallError:
return (None, None)
if reply is None or len(reply) < 12:
return (None, None)
return (reply[10], reply[11])
def push_artanis_perkey_prep(device):
"""Disable the firmware effects engine on mice with firmware effect cards.
Returns True if the call ACKed."""
infos = getattr(device, "led_effects", None)
if not infos or not infos.zones:
return False
num_effects = len(infos.zones[0].effects)
# SetEffectByIndex: cluster + effectIdx + 10 param bytes + persist.
# Shipping with call 2 only — sufficient on tested hardware (G502 X PLUS).
# Call 1 (TMPL-handshake) left commented for reactivation if broader
# testing turns up a device that needs it; uncomment the _probe_tmpl_bytes
# use and the call1 block together.
# tmpl_0, tmpl_1 = _probe_tmpl_bytes(device)
# if tmpl_0 is None:
# return False
# call1 = b"\xff\x02" + b"\x00" * 6 + bytes([tmpl_0, tmpl_1]) + b"\x00\x00" + b"\x01"
# if feature_request_acked(device, SupportedFeature.RGB_EFFECTS, 0x10, call1) is None:
# return False
call2 = b"\xff" + bytes([num_effects]) + b"\x00" * 10 + b"\x01"
return feature_request_acked(device, SupportedFeature.RGB_EFFECTS, 0x10, call2) is not None
def start(device):
"""Begin software RGB power management for `device`. No-op without GLib."""
if not _has_glib:
return
key = id(device)
if key not in _managers:
mgr = RGBPowerManager(device)
_managers[key] = mgr
mgr.start()
else:
mgr = _managers[key]
mgr.reset()
# Push persisted settings into the manager. Settings marked ignore via the
# lock icon are skipped so the manager keeps its built-in default.
from . import hidpp20
persister = getattr(device, "persister", None)
def _ignored(name):
return persister is not None and persister.get_sensitivity(name) == settings.SENSITIVITY_IGNORE
for s in device.settings:
if _ignored(s.name):
continue
if s.name == "rgb_idle_timeout":
val = s._value if s._value is not None else 60
mgr.set_idle_timeout(int(val))
elif s.name == "rgb_sleep_timeout":
val = s._value if s._value is not None else 300
mgr.set_sleep_timeout(int(val))
elif s.name == "rgb_idle_effect":
val = s._value if s._value is not None else hidpp20.LEDEffectSetting(ID=0x80, intensity=50)
mgr.set_idle_effect(val)
def stop(device):
"""End software RGB power management for `device`."""
key = id(device)
mgr = _managers.pop(key, None)
if mgr:
mgr.stop()
def cleanup(device):
"""device.cleanups handler — restore firmware control on device close.
On devices that support NvConfig cap 0x0040 (shutdown effect), also fires
SetRgbPowerMode(0) as the final step so the firmware plays the configured
shutdown animation during the activeoff transition. If the cap is
disabled, the firmware powers down LEDs silently. Matches LGHUB exit.
See solaar_shutdown_effect_trigger_spec.md.
rgb_control is the gate: when LED Control is off, skip every wire write
here. We never claimed, so there's nothing to release; firing the shutdown
animation would visibly contradict the user's "leave my lighting alone".
"""
stop(device)
if any(s.name == "rgb_control" and not s._value for s in getattr(device, "settings", []) or []):
return
try:
device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_RELEASE)
if device.features and SupportedFeature.PROFILE_MANAGEMENT in device.features:
device.feature_request(SupportedFeature.PROFILE_MANAGEMENT, 0x60, b"\x03")
elif device.features and SupportedFeature.ONBOARD_PROFILES in device.features:
device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x10, b"\x01")
except Exception:
pass # Device may already be offline
if getattr(device, "_rgb_has_shutdown_cap", False):
try:
# SetRgbPowerMode(set=1, mode=0) — firmware off transition.
# no_reply: device goes offline; don't block waiting for an ACK.
device.feature_request(SupportedFeature.RGB_EFFECTS, 0x90, b"\x01\x00", no_reply=True)
except Exception:
pass
class RGBPowerManager:
"""Two-stage idle handler driven by firmware onUserActivity events.
State machine: ACTIVE DIMMING IDLE SLEEPING.
Stage 1 (idle) runs a host-side dim ramp or hands off to a firmware
animation. Stage 2 (sleep) is a software timer that fires
sleep_timeout - idle_timeout after IDLE.
"""
ACTIVE = 0
DIMMING = 1
IDLE = 2
SLEEPING = 3
_DIM_INTERVAL_MS = 200
_DIM_STEPS = 25 # ~5s dim ramp
def __init__(self, device):
self._device = device
self._state = self.ACTIVE
self._idle_timeout = 60
self._sleep_timeout = 300
# LEDEffectSetting with ID in {0x00 Disabled, 0x80 Dim, 0x0A
# Breathe, 0x0B Ripple}. Populated by start() from the persister.
self._idle_effect = None
self._sleep_timer_id = None
self._dim_timer_id = None
self._dim_step = 0
self._dim_zones = []
self._dim_perkey = None
def start(self):
self._state = self.ACTIVE
self._read_firmware_timers()
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: RGB power manager started (firmware idle=%ds, sleep=%ds)",
self._device,
self._idle_timeout,
self._sleep_timeout,
)
def stop(self):
self._cancel_dim_timer()
self._cancel_sleep_timer()
if self._state != self.ACTIVE:
try:
self._wake()
except Exception:
pass # Best effort during shutdown
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB power manager stopped", self._device)
def reset(self):
"""Reset to ACTIVE on device reconnect. Re-reads firmware timers so
externally-updated values (other tool wrote NV between sessions)
are picked up even when our settings are ignored."""
self._cancel_dim_timer()
self._cancel_sleep_timer()
self._state = self.ACTIVE
self._read_firmware_timers()
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB power manager reset to ACTIVE", self._device)
def set_idle_timeout(self, seconds):
self._idle_timeout = seconds
self._cancel_sleep_timer()
if seconds == 0 and self._state in (self.DIMMING, self.IDLE):
self._wake()
self._write_firmware_idle_timeout(seconds)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB idle timeout set to %ss", self._device, seconds)
def set_sleep_timeout(self, seconds):
"""0 disables sleep."""
self._sleep_timeout = seconds
self._cancel_sleep_timer()
if seconds == 0 and self._state == self.SLEEPING:
self._wake()
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB sleep timeout set to %ss", self._device, seconds)
def set_idle_effect(self, effect):
"""`effect` is an LEDEffectSetting. Wake immediately if the user
switched to Disabled while we're mid-idle."""
self._idle_effect = effect
if self._idle_effect_id() == 0x00 and self._state in (self.DIMMING, self.IDLE):
self._wake()
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: RGB idle effect set to ID=0x%02X (period=%s, intensity=%s)",
self._device,
self._idle_effect_id(),
getattr(self._idle_effect, "period", None),
getattr(self._idle_effect, "intensity", None),
)
def _idle_effect_id(self):
"""Return the ID of the current idle effect, or 0 if unset."""
return int(getattr(self._idle_effect, "ID", 0) or 0)
# --- Firmware activity events ---
def on_user_activity(self, activity_type):
"""Handle firmware onUserActivity event from RGB_EFFECTS (0x8071).
activity_type=0: IDLE user stopped typing, firmware idle timer expired.
activity_type!=0: ACTIVE user resumed typing after being idle.
The firmware sends a burst of ~8 events with exponential backoff.
Only the first event matters; subsequent events for the same transition are ignored.
"""
if not self._device.online:
return
if activity_type == 0:
# IDLE event — firmware detected inactivity at idle_timeout
if self._state != self.ACTIVE:
return # Already idle/dimming/sleeping, ignore burst
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: firmware IDLE event — starting idle sequence", self._device)
# Switch to flags=3 so firmware monitors for activity during dim/idle
try:
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_IDLE)
except Exception:
pass
idle_enabled = self._idle_effect_id() != 0 and self._idle_timeout > 0 and not self._is_ignored("rgb_idle_effect")
if idle_enabled:
self._start_idle_effect()
else:
self._state = self.IDLE
# Sleep is host-driven only — schedule whenever _sleep_timeout > 0,
# regardless of the setting's ignore flag (which only blocks pushing
# the user value to firmware, see start()).
sleep_enabled = self._sleep_timeout > 0
if sleep_enabled:
delay = max(self._sleep_timeout - self._idle_timeout, 0)
if delay == 0:
self._start_sleep()
else:
self._sleep_timer_id = GLib.timeout_add_seconds(delay, self._sleep_timer_fired)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: sleep timer scheduled in %ds", self._device, delay)
else:
# ACTIVE event — user resumed typing
if self._state == self.ACTIVE:
return # Already active, ignore burst
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: firmware ACTIVE event — waking", self._device)
self._cancel_sleep_timer()
self._wake()
def _sleep_timer_fired(self):
"""GLib callback — software sleep timer expired after IDLE."""
self._sleep_timer_id = None
if self._state in (self.IDLE, self.DIMMING) and self._device.online:
self._start_sleep()
return False # One-shot timer
def _cancel_sleep_timer(self):
if self._sleep_timer_id is not None:
GLib.source_remove(self._sleep_timer_id)
self._sleep_timer_id = None
def _read_firmware_timers(self):
"""Read idle/sleep timeouts from firmware as the manager's defaults."""
try:
resp = self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x70, b"\x00")
if resp and len(resp) >= 7:
idle_s = (resp[3] << 8) | resp[4]
sleep_s = (resp[5] << 8) | resp[6]
if idle_s > 0:
self._idle_timeout = idle_s
if sleep_s > 0:
self._sleep_timeout = sleep_s
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: firmware timers: idle=%ds, sleep=%ds",
self._device,
idle_s,
sleep_s,
)
except Exception as e:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: could not read firmware timers, using defaults: %s", self._device, e)
def _write_firmware_idle_timeout(self, seconds):
"""Push idle/sleep timeouts back to firmware so it fires IDLE on time."""
try:
idle_hi = (seconds >> 8) & 0xFF
idle_lo = seconds & 0xFF
sleep_hi = (self._sleep_timeout >> 8) & 0xFF
sleep_lo = self._sleep_timeout & 0xFF
payload = bytes([0x01, 0x00, 0x00, idle_hi, idle_lo, sleep_hi, sleep_lo])
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x70, payload)
except Exception as e:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: could not write firmware idle timeout: %s", self._device, e)
# --- Idle effect ---
def _start_idle_effect(self):
idle_id = self._idle_effect_id()
if idle_id == 0x80: # Dim
dim_pct = int(getattr(self._idle_effect, "intensity", 50) or 50)
self._start_dim_ramp(dim_pct)
elif idle_id == 0x01: # Static — snap to idle color
self._start_static_idle()
elif idle_id != 0x00:
self._apply_animation(idle_id)
def _start_static_idle(self):
"""Snap to the idle effect's color exactly as if the user had set
the active Static zone color to it. Per-key paint continues to
display; unset cells repaint to the idle primary color via
effective_zone_base_color's IDLE-state substitution. No animation
instant transition. Wake reverts via _restore_colors()."""
idle_color = int(getattr(self._idle_effect, "color", 0) or 0)
infos = getattr(self._device, "led_effects", None)
if not infos or not infos.zones:
self._state = self.IDLE
return
self._state = self.IDLE
perkey_setting, has_paint = perkey_has_paint(self._device)
perkey_dominates = has_paint and zone_effect_is_static(self._device)
if perkey_dominates and perkey_setting is not None:
# Per-key is the visible layer — repaint unset cells with the
# idle color (effective_zone_base_color now returns it because
# state == IDLE and idle effect ID == Static).
try:
if perkey_setting._fill_unset_zones_with_base_color():
perkey_setting._send_with_retry(0x70, b"\x00") # FrameEnd
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: static idle per-key repaint failed: %s", self._device, e)
return
# Zone is the visible layer — push Static at idle.color to each zone.
for zone in infos.zones:
if 0x01 in (e.ID for e in zone.effects):
try:
self._push_static_effect(zone, idle_color)
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: static idle zone push failed: %s", self._device, e)
def _start_dim_ramp(self, dim_pct):
"""Smooth ~5s dim ramp. Dims the per-key buffer when it's the visible
layer (any real per-key paint), otherwise the zone effect."""
infos = getattr(self._device, "led_effects", None)
if not infos or not infos.zones:
self._state = self.IDLE
return
perkey_setting, has_paint = perkey_has_paint(self._device)
# Per-key only dominates when zone is Static. Under animations, the
# firmware engine owns the visible layer — dim the zone instead.
perkey_active = has_paint and zone_effect_is_static(self._device)
self._dim_perkey = None
if perkey_active:
self._dim_zones = []
self._dim_perkey = self._build_full_perkey_dim_map(perkey_setting, dim_pct)
if self._dim_perkey:
# Push base color to unset cells first so they don't start from stale.
self._init_unset_perkey_zones(perkey_setting)
else:
self._dim_zones = []
for zone in infos.zones:
if 0x01 in (e.ID for e in zone.effects):
start_color = self._get_zone_color(zone)
target_color = self._compute_dim_color(start_color, dim_pct)
self._dim_zones.append((zone, start_color, target_color))
if not self._dim_zones and not self._dim_perkey:
self._state = self.IDLE
return
self._dim_step = 0
self._state = self.DIMMING
self._dim_timer_id = GLib.timeout_add(self._DIM_INTERVAL_MS, self._dim_ramp_step)
if logger.isEnabledFor(logging.DEBUG):
n_zones = len(self._dim_zones)
n_perkey = len(self._dim_perkey) if self._dim_perkey else 0
logger.debug(
"%s: starting dim ramp to %d%% brightness (%d zones, %d per-key%s)",
self._device,
dim_pct,
n_zones,
n_perkey,
", per-key masking zones" if perkey_active else "",
)
def _dim_ramp_step(self):
if self._state != self.DIMMING or not self._device.online:
self._dim_timer_id = None
return False
self._dim_step += 1
t = self._dim_step / self._DIM_STEPS
for zone, start_color, target_color in self._dim_zones:
try:
self._push_static_effect(zone, self._interpolate_color(start_color, target_color, t))
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: dim ramp step failed for zone %s: %s", self._device, zone.index, e)
if self._dim_perkey:
try:
self._push_perkey_dimmed(t)
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: dim ramp step failed for per-key: %s", self._device, e)
if self._dim_step >= self._DIM_STEPS:
self._state = self.IDLE
self._dim_timer_id = None
return False
return True
def _push_static_effect(self, zone, color):
"""Non-persistent Static effect, one zone."""
static_effect = next((e for e in zone.effects if e.ID == 0x01), None)
if static_effect is None:
return
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
params = bytes([r, g, b, 0, 0, 0, 0, 0, 0, 0])
payload = bytes([zone.index, static_effect.index]) + params + b"\x01"
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x10, payload)
def _push_perkey_dimmed(self, t):
"""Push interpolated per-key colors for one dim ramp step.
Groups keys by their interpolated color and uses SetRgbZonesSingleValue
(0x8081 function 6) for efficient bulk writes up to 13 zone IDs per
HID message when multiple keys share the same dimmed color.
"""
# Build color -> [zone_ids] map for this interpolation step
color_groups = {}
for zone_id, (start_color, target_color) in self._dim_perkey.items():
color = self._interpolate_color(start_color, target_color, t)
if color not in color_groups:
color_groups[color] = []
color_groups[color].append(zone_id)
feat = SupportedFeature.PER_KEY_LIGHTING_V2
for color, zone_ids in color_groups.items():
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
# Function 6: SetRgbZonesSingleValue — color(3) + zone_ids (up to 13 per report)
while zone_ids:
batch = zone_ids[:13]
zone_ids = zone_ids[13:]
data = bytes([r, g, b]) + bytes(batch)
self._device.feature_request(feat, 0x60, data)
# Commit the frame
self._device.feature_request(feat, 0x70, b"\x00\x00\x00\x00\x00")
def _apply_animation(self, effect_id):
"""Hand off to a firmware animation. Generic over any effect in
hidpp20.LEDEffects: builds the 10-byte param block from the
effect's param map, sourcing color from the zone and other
params from the persisted _idle_effect."""
from . import hidpp20
infos = getattr(self._device, "led_effects", None)
if not infos or not infos.zones:
self._state = self.IDLE
return
entry = hidpp20.LEDEffects.get(effect_id)
if entry is None:
self._state = self.IDLE
return
param_map = entry[1]
for zone in infos.zones:
effect_info = next((e for e in zone.effects if e.ID == effect_id), None)
if effect_info is None:
continue
color = self._get_zone_color(zone)
params = bytearray(10)
if hidpp20.LEDParam.color in param_map:
offset = param_map[hidpp20.LEDParam.color]
params[offset] = (color >> 16) & 0xFF
params[offset + 1] = (color >> 8) & 0xFF
params[offset + 2] = color & 0xFF
for pname, poff in param_map.items():
if pname == hidpp20.LEDParam.color:
continue
psize = hidpp20.LEDParamSize.get(pname, 1)
user_val = getattr(self._idle_effect, str(pname), None)
if user_val is None:
user_val = effect_info.period or 3000 if pname == hidpp20.LEDParam.period else 0
params[poff : poff + psize] = int(user_val).to_bytes(psize, "big")
if effect_id == 0x01:
params[3] = 0x02 # Static fixed-color marker
payload = bytes([zone.index, effect_info.index]) + bytes(params) + b"\x01"
try:
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x10, payload)
except Exception as exc:
if logger.isEnabledFor(logging.WARNING):
logger.warning(
"%s: failed to apply animation 0x%02x to zone %d: %s",
self._device,
effect_id,
zone.index,
exc,
)
self._state = self.IDLE
# --- Sleep ---
def _start_sleep(self):
"""Enter firmware-managed sleep. Firmware fades from current level."""
self._cancel_dim_timer()
try:
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x80, b"\x01\x03\x00")
self._state = self.SLEEPING
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: RGB entering sleep (firmware power-down)", self._device)
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: failed to enter RGB sleep: %s", self._device, e)
# --- Wake ---
def _wake(self):
"""Restore full lighting from any non-ACTIVE state."""
if self._state == self.ACTIVE:
return
prev_state = self._state
self._cancel_dim_timer()
self._cancel_sleep_timer()
# State must be ACTIVE before _restore_colors() — the paint paths
# translate through it, and writes would otherwise go at the old dim.
self._state = self.ACTIVE
try:
if prev_state == self.SLEEPING:
self._set_power_mode_with_retry(1)
# Re-claim full LED pipeline control
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x50, SW_ACTIVE)
# Firmware engine has re-engaged during sleep — re-arm per-key
# one-shots so the next write re-fires the prep + double-send.
for s in self._device.settings:
if s.name == "per-key-lighting":
s._frame_settled = False
s._prep_pushed = False
break
self._restore_colors()
if logger.isEnabledFor(logging.DEBUG):
state_names = {self.DIMMING: "dimming", self.IDLE: "idle", self.SLEEPING: "sleep"}
logger.debug("%s: RGB woken from %s", self._device, state_names.get(prev_state, "unknown"))
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: failed to wake RGB LEDs: %s", self._device, e)
def _cancel_dim_timer(self):
if self._dim_timer_id is not None:
GLib.source_remove(self._dim_timer_id)
self._dim_timer_id = None
def _get_zone_color(self, zone):
location = int(zone.location)
setting_name = f"rgb_zone_{location}"
for s in self._device.settings:
if s.name == setting_name and s._value is not None:
return getattr(s._value, "color", 0xFFFFFF)
return 0xFFFFFF
def _get_zone_base_color(self):
"""Color used as the base for unset per-key cells. Black when the
zone effect is marked ignore, the saved zone color otherwise."""
return effective_zone_base_color(self._device)
@staticmethod
def _has_real_perkey_colors(perkey_setting):
if not perkey_setting._value:
return False
no_change = special_keys.COLORSPLUS["No change"]
return any(color != no_change and isinstance(color, int) and color >= 0 for color in perkey_setting._value.values())
def _build_full_perkey_dim_map(self, perkey_setting, dim_pct):
"""{zone_id: (start, target)} for every zone — user-set keys from
their color, unset from the zone base."""
no_change = special_keys.COLORSPLUS["No change"]
zone_base = self._get_zone_base_color()
user_colors = {int(k): c for k, c in perkey_setting._value.items() if c != no_change and isinstance(c, int) and c >= 0}
return {
int(k): (start, self._compute_dim_color(start, dim_pct))
for k in perkey_setting._validator.choices
for start in (user_colors.get(int(k), zone_base),)
}
def _init_unset_perkey_zones(self, perkey_setting):
"""Push the zone base color to per-key cells the user hasn't painted —
avoids the white-default flash when per-key takes over the buffer."""
no_change = special_keys.COLORSPLUS["No change"]
zone_base = self._get_zone_base_color()
r = (zone_base >> 16) & 0xFF
g = (zone_base >> 8) & 0xFF
b = zone_base & 0xFF
user_set = {int(k) for k, c in perkey_setting._value.items() if c != no_change and isinstance(c, int) and c >= 0}
unset_zones = [int(k) for k in perkey_setting._validator.choices if int(k) not in user_set]
if not unset_zones:
return
feat = SupportedFeature.PER_KEY_LIGHTING_V2
remaining = list(unset_zones)
try:
while remaining:
batch = remaining[:13]
remaining = remaining[13:]
self._device.feature_request(feat, 0x60, bytes([r, g, b]) + bytes(batch))
self._device.feature_request(feat, 0x70, b"\x00\x00\x00\x00\x00")
except exceptions.FeatureCallError as e:
logger.warning("%s: per-key zone init failed (device busy?): %s", self._device, e)
@staticmethod
def _compute_dim_color(color, dim_pct):
r = ((color >> 16) & 0xFF) * dim_pct // 100
g = ((color >> 8) & 0xFF) * dim_pct // 100
b = (color & 0xFF) * dim_pct // 100
return (r << 16) | (g << 8) | b
@staticmethod
def _interpolate_color(start, target, t):
r_s, g_s, b_s = (start >> 16) & 0xFF, (start >> 8) & 0xFF, start & 0xFF
r_t, g_t, b_t = (target >> 16) & 0xFF, (target >> 8) & 0xFF, target & 0xFF
r = int(r_s + (r_t - r_s) * t)
g = int(g_s + (g_t - g_s) * t)
b = int(b_s + (b_t - b_s) * t)
return (r << 16) | (g << 8) | b
def _current_dim_pct(self):
"""100 unless we're in Dim mode — animations run at firmware brightness."""
if self._idle_effect_id() != 0x80:
return 100
return int(getattr(self._idle_effect, "intensity", 50) or 50)
def translate_color(self, color):
"""Map a saved (undimmed) per-key color to what should be displayed
on the device right now, given the current power-management state.
Returns None to signal SLEEPING caller should persist and skip the
wire write; _restore_colors on wake will re-push the saved value."""
# Static idle is a color swap, not a brightness change — user-painted
# cells render their saved color unchanged, and the unset-cell
# substitution happens upstream via effective_zone_base_color.
if self._state == self.IDLE and self._idle_effect_id() == 0x01:
return color
return translate_color_for_display(color, self._state, self._current_dim_pct(), self._dim_step, self._DIM_STEPS)
def notify_perkey_changed(self, zone_id, new_color):
"""Resync a per-key zone's dim ramp entry to a user-repainted color."""
if self._state != self.DIMMING or not self._dim_perkey or zone_id not in self._dim_perkey:
return
self._dim_perkey[zone_id] = (
new_color,
self._compute_dim_color(new_color, self._current_dim_pct()),
)
def notify_perkey_bulk_changed(self, color_map):
"""Bulk notify_perkey_changed, skipping 'No change' entries."""
if self._state != self.DIMMING or not self._dim_perkey:
return
no_change = special_keys.COLORSPLUS["No change"]
for zone_id, color in color_map.items():
if color == no_change or not isinstance(color, int) or color < 0:
continue
self.notify_perkey_changed(int(zone_id), int(color))
def notify_zone_changed(self, cluster_index, new_color):
"""Resync a zone-effect dim ramp entry to a user-repainted color."""
if self._state != self.DIMMING or not self._dim_zones:
return
dim_pct = self._current_dim_pct()
for i, (zone, _start, _target) in enumerate(self._dim_zones):
if int(zone.index) == int(cluster_index):
self._dim_zones[i] = (zone, new_color, self._compute_dim_color(new_color, dim_pct))
return
def _set_power_mode_with_retry(self, mode):
"""First command after wake may fail; retry."""
params = bytes([0x01, mode, 0x00])
for attempt in range(3):
try:
self._device.feature_request(SupportedFeature.RGB_EFFECTS, 0x80, params)
return
except Exception:
if attempt == 2:
raise
import time as _time
_time.sleep(0.1)
def _is_ignored(self, setting_name):
"""True if marked ignore via the lock icon."""
persister = getattr(self._device, "persister", None)
if persister is None:
return False
return persister.get_sensitivity(setting_name) == settings.SENSITIVITY_IGNORE
def _restore_colors(self):
"""Re-push lighting state after waking. Per-key dominates only when
zone is Static under animations, the zone wire push goes through
and per-key is skipped."""
_perkey_setting, has_paint = perkey_has_paint(self._device)
zone_static = zone_effect_is_static(self._device)
perkey_dominates = has_paint and zone_static
for s in self._device.settings:
if s._value is None:
continue
if self._is_ignored(s.name):
continue
if s.name == "per-key-lighting":
if not self._has_real_perkey_colors(s):
continue
if not zone_static:
continue # firmware animation owns the visible layer
elif s.name.startswith("rgb_zone_"):
if perkey_dominates:
continue
else:
continue
try:
s.write(s._value, save=False)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: restored %s after wake", self._device, s.name)
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: failed to restore %s: %s", self._device, s.name, e)

View File

@ -27,7 +27,6 @@ from solaar.i18n import _
from . import common
from . import hidpp20_constants
from . import settings_validator
from .centurion_constants import CenturionCoreFeature
from .common import NamedInt
logger = logging.getLogger(__name__)
@ -36,18 +35,14 @@ SENSITIVITY_IGNORE = "ignore"
class Kind(IntEnum):
NONE = 0
TOGGLE = 0x01
CHOICE = 0x02
RANGE = 0x04
MAP_CHOICE = 0x0A
MULTIPLE_TOGGLE = 0x10
PACKED_RANGE = 0x20
GRAPHIC_EQ = 0x21
MULTIPLE_RANGE = 0x40
HETERO = 0x80
MAP_RANGE = 0x102
COLOR = 0x200
class Setting:
@ -60,16 +55,6 @@ class Setting:
rw_options = {}
validator_class = None
validator_options = {}
display = True # display setting in UI
# Set False for settings whose value cannot be read back from the device
# (e.g. PerKeyLighting — the 0x8081 protocol has no GetIndividualRgbZones).
# `solaar show` uses this to suppress the "(live)" output line that would
# otherwise print a fabricated value misleadingly.
live_readable = True
# Optional UI editor override as "module.path:ClassName". Resolved by the
# config panel before the Kind dispatch. Kept as a string so this module
# stays free of GTK imports — the FE/BE seam is preserved.
editor_class: str | None = None
def __init__(self, device, rw, validator):
self._device = device
@ -181,16 +166,8 @@ class Setting:
logger.debug("%s: prepare write(%s) => %r", self.name, value, data_bytes)
reply = self._rw.write(self._device, data_bytes)
# HID++ 2.0 "set" operations often return an empty ACK (b"").
# Treating empty bytes as failure (`not reply`) would misreport
# successful writes as errors to the GUI. Only report failure
# when the transport actually returned None (error or timeout).
if reply is None:
logger.info(
"%s: write on %s returned no reply (transport error/timeout)",
self.name,
self._device,
)
if not reply:
# tell whomever is calling that the write failed
return None
return value
@ -646,7 +623,7 @@ class FeatureRW:
read_prefix=b"",
no_reply=False,
):
assert isinstance(feature, (hidpp20_constants.SupportedFeature, CenturionCoreFeature))
assert isinstance(feature, hidpp20_constants.SupportedFeature)
self.feature = feature
self.read_fnid = read_fnid
self.write_fnid = write_fnid
@ -683,7 +660,7 @@ class FeatureRWMap(FeatureRW):
key_byte_count=default_key_byte_count,
no_reply=False,
):
assert isinstance(feature, (hidpp20_constants.SupportedFeature, CenturionCoreFeature))
assert isinstance(feature, hidpp20_constants.SupportedFeature)
self.feature = feature
self.read_fnid = read_fnid
self.write_fnid = write_fnid

View File

@ -1,206 +0,0 @@
## Copyright (C) 2025 Solaar contributors
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
## A new way of supporting settings, using a feature-specifi device class to store, read, and write relevant information
## The setting uses the device class to interact with the device feature.
## The setting uses a persist class to keep track of the setting.
## Interface:
import logging
from .settings import Kind
logger = logging.getLogger(__name__)
class Setting:
name = None # Solaar internal name for the setting
label = None # Solaar user name for the setting (translatable)
description = None # Solaar extra desciption for the setting (translatable)
feature = None # Logitech feature that the setting uses
min_version = 0 # Minimum version of the feature needed
setup = None # method name on Device class to get the device object
get = None # method name on the device object to get the setting value
set = None # method name on the device object to set the setting value
acceptable = None # method name on the device object to check for acceptable values
choices_universe = None # All possible acceptable keys, for settings with keys
kind = Kind.NONE # What GUI interface to use
persist = True # Whether to remember the setting
display = True # display setting in UI
_device = None # The device that this setting is for
_device_object = None # The object that interacts with the feature for the device
_value = None # Stored value as maintained by Solaar, used for persistence
def __init__(self, device, device_object):
self._device = device
self._device_object = device_object
@classmethod
def build(cls, device):
cls.check_properties(cls)
device_object = getattr(device, cls.setup)()
if device_object:
setting = cls(device, device_object)
return setting
@classmethod
def check_properties(cl, cls):
assert cls.name and cls.label and cls.description, "New settings require a name, label, and description"
assert cls.feature, "New settings require a feature"
assert cls.setup, "New settings require a setup device method"
assert cls.get and cls.set and cls.acceptable, "New settings require get, set, and acceptable methods"
def setup_from_class(self, clss):
"""Copy settings methods for a new setting from a settting class"""
self.name = clss.name
self.label = clss.label
self.description = clss.description
self.feature = clss.feature
self.min_version = clss.min_version
self.setup = clss.setup
self.get = clss.get
self.set = clss.set
self.acceptable = clss.acceptable
self.choices_universe = clss.choices_universe
self.kind = clss.kind
self.persist = clss.persist
def _pre_read(self, cached):
"""Get information from and save information to the persister"""
# Get the persister map if available and not done already
if self.persist and self._value is None and getattr(self._device, "persister", None):
self._value = self._device.persister.get(self.name)
# If this is new save its current value for the next time
if cached and self._value is not None:
if getattr(self._device, "persister", None) and self.name not in self._device.persister:
self._device.persister[self.name] = self._value if self.persist else None
def read(self, cached=True):
"""Get all the data for the setting. If cached is True the data in the _value can be used."""
self._pre_read(cached)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: setting read %r from %s", self.name, self._value, self._device)
if cached and self._value is not None:
return self._value
if cached:
self._value = getattr(self._device_object, self.get)()
return self._value
if self._device.online:
self._value = getattr(self._device_object.query(), self.get)()
return self._value
def write(self, value, save=True):
"""Write the value to the device. If saved is True also save in the persister"""
pass ## fill out
def apply(self):
"""Write saved data to the device, using persisted data if available"""
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: apply (%s)", self.name, self._device)
value = None
try:
value = self.read(self.persist) # Don't use persisted value if setting doesn't persist
if self.persist and value is not None: # If setting doesn't persist no need to write value just read
self.write(value, save=False)
except Exception as e:
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: error applying %s so ignore it (%s): %s", self.name, value, self._device, repr(e))
@property
def range(self):
if self.kind == Kind.RANGE:
return self.min_value, self.max_value
def val_to_string(self, value):
return str(value)
## key mapping from symbols to values????
class Settings(Setting):
"""A setting descriptor for multiple keys.
Supported by a class that provides the interface to the device, see ForceSensingButtonArray in hidpp20.py
Picks out a field from the mapped device feature objects."""
# setup creates a dictionary with entries for all the keys
# _value is a map from keys to values
# get, set, and acceptable are methods of dict value objects, not of the device object itself #### FIX THIS! MAYBE??
def __init__(self, device, device_object):
super().__init__(device, device_object)
self._value = {}
def read(self, cached=True):
self._pre_read(cached)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: settings read %r from %s", self.name, self._value, self._device)
for key in self._device_object:
self.read_key(key, cached)
return self._value
def read_key(self, key, cached=True):
"""Get the data for the key. If cached is True the data in the device_object can be used."""
self._pre_read(cached)
if key not in self._device_object:
logger.error("%s: settings illegal read key %r for %s", self.name, key, self._device)
return None
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: settings key %r read %r from %s", self.name, key, self._value, self._device)
if cached and key in self._value and self._value[key] is not None:
return self._value[key]
if cached:
data = self._device_object[key]
self._value[key] = getattr(data, self.get)()
return self._value[key]
if self._device.online:
data = self._device_object.query_key(key)
self._value[key] = getattr(data, self.get)()
return self._value[key]
def write(self, value, save=True):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: settings read %r from %s", self.name, self._value, self._device)
if isinstance(value, dict):
for key, val in value.items():
self.write_key_value(key, val, save)
else: # to mimic interface for non-dict setting
key = next(iter(self._device_object))
self.write_key_value(key, value, save)
return value
def write_key_value(self, key, value, save=True):
"""Write the data for the key. If saved is True also save in the persister"""
if key not in self._device_object:
logger.error("%s: settings illegal write key %r for %s", self.name, key, self._device)
return None
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: settings write key %r value %r to %s", self.name, key, value, self._device)
if self._device.online:
if self._device_object[key] is None:
self.read_key(key)
if self._device_object[key] is None:
logger.error("%s: settings illegal write key %r for %s", self.name, key, self._device)
return None
if not getattr(self._device_object[key], self.acceptable)(value):
logger.error("%s: settings illegal write key %r value %r for %s", self.name, key, value, self._device)
return None
self._value[key] = value
if self._device.persister and self.persist and save:
self._device.persister[self.name][key] = value
getattr(self._device_object[key], self.set)(value)
return value

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,6 @@ from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from enum import IntEnum
from logitech_receiver import common
@ -532,14 +531,12 @@ class RangeValidator(Validator):
kwargs["max_value"] = setting_class.max_value
return cls(**kwargs)
def __init__(self, min_value=0, max_value=255, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b"", signed=False):
def __init__(self, min_value=0, max_value=255, byte_count=1):
assert max_value > min_value
self.min_value = min_value
self.max_value = max_value
self.read_skip_byte_count = read_skip_byte_count
self.write_prefix_bytes = write_prefix_bytes
self._signed = signed
self.needs_current_value = True # read and check before write (needed for ADC power and probably a good idea anyway)
self._byte_count = math.ceil(math.log(max_value + 1, 256))
if byte_count:
assert self._byte_count <= byte_count
@ -547,9 +544,7 @@ class RangeValidator(Validator):
assert self._byte_count < 8
def validate_read(self, reply_bytes):
reply_value = common.bytes2int(
reply_bytes[self.read_skip_byte_count : self.read_skip_byte_count + self._byte_count], signed=self._signed
)
reply_value = common.bytes2int(reply_bytes[: self._byte_count])
assert reply_value >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
assert reply_value <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
return reply_value
@ -558,14 +553,14 @@ class RangeValidator(Validator):
if new_value < self.min_value or new_value > self.max_value:
raise ValueError(f"invalid choice {new_value!r}")
current_value = self.validate_read(current_value) if current_value is not None else None
to_write = self.write_prefix_bytes + common.int2bytes(new_value, self._byte_count, signed=self._signed)
to_write = common.int2bytes(new_value, self._byte_count)
# current value is known and same as value to be written return None to signal not to write it
return None if current_value is not None and current_value == new_value else to_write
def acceptable(self, args, current):
arg = args[0]
# None if len(args) != 1 or type(arg) != int or arg < self.min_value or arg > self.max_value else args)
return None if len(args) != 1 or not isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args
return None if len(args) != 1 or isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args
def compare(self, args, current):
if len(args) == 1:
@ -584,8 +579,7 @@ class HeteroValidator(Validator):
return cls(**kwargs)
def __init__(self, data_class=None, options=None, readable=True):
# options=None for purely host-side settings — data_class handles bytes[0] as the ID.
assert data_class is not None
assert data_class is not None and options is not None
self.data_class = data_class
self.options = options
self.readable = readable
@ -748,114 +742,3 @@ class MultipleRangeValidator(Validator):
def compare(self, args, current):
logger.warning("compare not implemented for multiple range settings")
return False
@dataclass(frozen=True)
class Range:
"""Inclusive integer range used as the value side of a MapRangeValidator.
`byte_count` is the wire encoding width. `signed` selects two's-complement.
`value_type` is the int factory used to wrap values returned from
`validate_read` defaults to `int`, but settings that store RGB colors
pass `common.ColorInt` so the values self-format as ``0xrrggbb`` in
`solaar show` output and the YAML config file.
Settings whose value space is a continuous integer range (e.g. per-key RGB
colors as 24-bit ints) use this in place of a NamedInts choice list.
"""
min: int
max: int
byte_count: int = 1
signed: bool = False
value_type: type = int
def contains(self, value: int) -> bool:
return isinstance(value, int) and self.min <= value <= self.max
class MapRangeValidator(Validator):
"""Map of keys → integer in a per-key Range. Open value space (no choice list).
Reports `kind = Kind.MAP_CHOICE` so the existing config-panel/CLI/rule-engine
dispatch keeps routing without new branches; consumers that need to tell
"choice list" from "open range" check `isinstance(setting.choices[k], Range)`.
TODO: complete `Kind.MAP_RANGE` infrastructure (UI dispatch, generic rule-UI
handling, generalize `cli/config.py:299`) and migrate this validator's
`kind` over. Today MAP_RANGE is only honored by `ForceSensing` via the
`settings_new` framework; bridging both frameworks is a separate task.
"""
kind = Kind.MAP_CHOICE
def __init__(self, choices_map, key_byte_count=1, write_prefix_bytes=b""):
assert isinstance(choices_map, dict)
for k, v in choices_map.items():
assert isinstance(k, NamedInt), f"MapRangeValidator key must be NamedInt, got {type(k).__name__}"
assert isinstance(v, Range), f"MapRangeValidator value must be Range, got {type(v).__name__}"
self.choices = choices_map
self.needs_current_value = False
self._key_byte_count = key_byte_count
self._write_prefix_bytes = write_prefix_bytes
def to_string(self, value) -> str:
if not isinstance(value, dict):
return str(value)
# Persisted dicts loaded from YAML come back as plain ints regardless
# of the choice's `value_type`. Re-wrap raw ints through the configured
# value_type (e.g. ColorInt) so they self-format consistently here and
# in `solaar show`. Skip wrapping for NamedInt sentinels / subclasses
# (`type(v) is int` is the exact-match guard).
rng_by_int_key = {int(k): rng for k, rng in self.choices.items()}
def _fmt(k):
v = value[k]
rng = rng_by_int_key.get(int(k))
if rng is not None and type(v) is int and rng.value_type is not int: # noqa: E721
try:
v = rng.value_type(v)
except Exception:
pass
return f"{k}:{v}"
return "{" + ", ".join(_fmt(k) for k in sorted(value)) + "}"
def validate_read(self, reply_bytes, key):
rng = self.choices.get(key)
if rng is None:
return None
end = self._key_byte_count + rng.byte_count
return rng.value_type(common.bytes2int(reply_bytes[self._key_byte_count : end], signed=rng.signed))
def prepare_key(self, key):
return int(key).to_bytes(self._key_byte_count, "big")
def prepare_write(self, key, new_value):
rng = self.choices.get(key)
if rng is None:
logger.error("invalid key %r for map-range setting", key)
return None
if not rng.contains(new_value):
logger.error("value %r out of range [%d, %d] for key %s", new_value, rng.min, rng.max, key)
return None
return self._write_prefix_bytes + int(new_value).to_bytes(rng.byte_count, "big", signed=rng.signed)
def acceptable(self, args, current):
if not isinstance(args, list) or len(args) != 2:
return None
key = next((k for k in self.choices if int(k) == int(args[0])), None)
if key is None:
return None
rng = self.choices[key]
if not rng.contains(args[1]):
return None
return [int(key), int(args[1])]
def compare(self, args, current):
if not isinstance(args, list) or len(args) != 2 or not isinstance(current, dict):
return False
key = next((k for k in self.choices if int(k) == int(args[0])), None)
if key is None:
return False
return current.get(int(key)) == args[1]

View File

@ -39,7 +39,7 @@ def _create_parser():
)
subparsers = parser.add_subparsers(title="actions", help="command-line action to perform")
sp = subparsers.add_parser("show", description="Show information about device or all devices.")
sp = subparsers.add_parser("show", help="show information about devices")
sp.add_argument(
"device",
nargs="?",
@ -49,7 +49,7 @@ def _create_parser():
)
sp.set_defaults(action="show")
sp = subparsers.add_parser("probe", description="Probe a receiver (debugging use only).")
sp = subparsers.add_parser("probe", help="probe a receiver (debugging use only)")
sp.add_argument(
"receiver", nargs="?", help="select receiver by name substring or serial number when more than one is present"
)
@ -57,26 +57,25 @@ def _create_parser():
sp = subparsers.add_parser(
"profiles",
description="Print or load YAML dump of profiles.",
help="read or write onboard profiles",
epilog="Only works on active devices.",
)
sp.add_argument(
"device",
help="device to read or load profiles; may be a device number (1..6), a serial number, "
"or a substring of a device's name",
help="device to read or write profiles of; may be a device number (1..6), a serial number, "
"a substring of a device's name",
)
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles to load")
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles")
sp.set_defaults(action="profiles")
sp = subparsers.add_parser(
"config",
description="Print or load device-specific settings. Only some settings can be loaded. "
"Loading complex settings uses the same syntax as in ~/.config/solaar/config.yaml",
help="read/write device-specific settings",
epilog="Please note that configuration only works on active devices.",
)
sp.add_argument(
"device",
help="device to configure; may be a device number (1..6), a serial number, or a substring of a device's name",
help="device to configure; may be a device number (1..6), a serial number, " "or a substring of a device's name",
)
sp.add_argument("setting", nargs="?", help="device-specific setting; leave empty to list available settings")
sp.add_argument("value_key", nargs="?", help="new value for the setting or key for keyed settings")
@ -86,7 +85,7 @@ def _create_parser():
sp = subparsers.add_parser(
"pair",
description="Pair a new device with a receiver. The device has to be compatible with the receiver.",
help="pair a new device",
epilog="The Logitech Unifying Receiver supports up to 6 paired devices at the same time.",
)
sp.add_argument(
@ -94,30 +93,10 @@ def _create_parser():
)
sp.set_defaults(action="pair")
sp = subparsers.add_parser("unpair", description="Unpair a device from its receiver. Not all receivers allow unpairing.")
sp = subparsers.add_parser("unpair", help="unpair a device")
sp.add_argument(
"device",
nargs="?",
help="device to unpair; may be a device number (1..6), a serial number, "
"or a substring of a device's name. Omit when using --slot.",
)
sp.add_argument(
"--receiver",
help="select receiver by name substring or serial number when more than one is present; "
"required with --slot if multiple receivers are attached.",
)
sp.add_argument(
"--slot",
type=int,
help="force-unpair a specific slot number directly, even if Solaar has no cached device there "
"or the device is currently reachable. Lightspeed receivers only. The slot contents are "
"printed before the write so you can confirm what is about to be cleared.",
)
sp.add_argument(
"--dry-run",
action="store_true",
help="with --slot, run all safety checks but do not issue the unpair register write. "
"Use to verify the active-device guard before committing to a real write.",
help="device to unpair; may be a device number (1..6), a serial number, " "or a substring of a device's name.",
)
sp.set_defaults(action="unpair")
@ -148,14 +127,7 @@ def _receivers_and_devices(dev_path=None):
continue
try:
if dev_info.isDevice:
if getattr(dev_info, "centurion", False):
d = device.create_centurion_receiver(base, dev_info)
if d is not None:
d.notify_devices()
else:
d = device.create_device(base, dev_info)
else:
d = device.create_device(base, dev_info)
d = device.create_device(base, dev_info)
else:
d = receiver.create_receiver(base, dev_info)

View File

@ -20,28 +20,12 @@ from logitech_receiver import settings
from logitech_receiver import settings_templates
from logitech_receiver.common import NamedInts
from logitech_receiver.settings_templates import SettingsProtocol
from logitech_receiver.settings_validator import Range
from solaar import configuration
APP_ID = "io.github.pwr_solaar.solaar"
def _parse_int_or_hex(s) -> int | None:
"""Parse 0xRRGGBB / #RRGGBB / decimal int. Returns None on bad input."""
if not isinstance(s, str):
return None
s = s.strip()
try:
if s.startswith("#"):
return int(s[1:], 16)
if s.lower().startswith("0x"):
return int(s, 16)
return int(s, 10)
except ValueError:
return None
def _print_setting(s, verbose=True):
print("#", s.label)
if verbose:
@ -86,11 +70,7 @@ def _print_setting_keyed(s, key, verbose=True):
if k is None:
print(s.name, "=? (key not found)")
else:
value_space = s.choices[k]
if isinstance(value_space, Range):
print(f"# possible values: integer in [{value_space.min}, {value_space.max}] (decimal or 0xHEX)")
else:
print("# possible values: one of [", ", ".join(str(v) for v in value_space), "]")
print("# possible values: one of [", ", ".join(str(v) for v in s.choices[k]), "]")
value = s.read(cached=False)
if value is None:
print(s.name, "= ? (failed to read from device)")
@ -230,8 +210,7 @@ def run(receivers, args, _find_receiver, find_device):
if remote:
argl = ["config", dev.serial or dev.unitId, setting.name]
argl.extend([a for a in [args.value_key, args.extra_subkey, args.extra2] if a is not None])
args = yaml.dump(argl)
application.run([args])
application.run(yaml.safe_dump(argl))
else:
if dev.persister and setting.persist:
dev.persister[setting.name] = setting._value
@ -265,21 +244,12 @@ def set(dev, setting: SettingsProtocol, args, save):
k = next((k for k in setting.choices.keys() if key == k), None)
if k is None and ikey is not None:
k = next((k for k in setting.choices.keys() if ikey == k), None)
if k is None:
raise Exception(f"{setting.name}: key '{key}' not in setting")
value_space = setting.choices[k]
if isinstance(value_space, Range):
ivalue = _parse_int_or_hex(args.extra_subkey)
if ivalue is None or not value_space.contains(ivalue):
raise Exception(
f"{setting.name}: value '{args.extra_subkey}' must be an integer in "
f"[{value_space.min}, {value_space.max}] (decimal or 0xHEX / #HEX)"
)
value = ivalue
if k is not None:
value = select_choice(args.extra_subkey, setting.choices[k], setting, key)
args.extra_subkey = int(value)
args.value_key = str(int(k))
else:
value = select_choice(args.extra_subkey, value_space, setting, key)
args.extra_subkey = int(value)
args.value_key = str(int(k))
raise Exception(f"{setting.name}: key '{key}' not in setting")
message = f"Setting {setting.name} of {dev.name} key {k!r} to {value!r}"
result = setting.write_key_value(int(k), value, save=save)
@ -325,25 +295,7 @@ def set(dev, setting: SettingsProtocol, args, save):
result = setting.write_key_value(int(k), item, save=save)
value = item
elif setting.kind == settings.Kind.MAP_RANGE:
if args.extra_subkey is None:
_print_setting_keyed(setting, args.value_key)
return None, None, None
key = int(args.value_key)
value = int(args.extra_subkey)
if key not in setting._device_object:
raise Exception(f"{setting.name}: key '{key}' not in setting")
message = f"Setting {setting.name} of {dev.name} key {key} to {value}"
result = setting.write_key_value(key, value, save=save)
elif setting.kind == settings.Kind.HETERO:
value = yaml.safe_load(args.value_key)
args.value_key = value
message = f"Setting {setting.name} of {dev.name} to {value}"
result = setting.write(value, save=save)
else:
print(f"Setting {setting.name}, with kind {setting.kind.name}, not implemented")
raise Exception("NotImplemented")
return result, message, value

View File

@ -38,7 +38,7 @@ def run(receivers, args, find_receiver, _ignore):
assert receiver
# check if it's necessary to set the notification flags
old_notification_flags = _hidpp10.get_notification_flags(receiver)
old_notification_flags = _hidpp10.get_notification_flags(receiver) or 0
if not (old_notification_flags & hidpp10_constants.NotificationFlag.WIRELESS):
_hidpp10.set_notification_flags(receiver, old_notification_flags | hidpp10_constants.NotificationFlag.WIRELESS)
@ -79,36 +79,33 @@ def run(receivers, args, find_receiver, _ignore):
name = receiver.pairing.device_name
authentication = receiver.pairing.device_authentication
kind = receiver.pairing.device_kind
if authentication is None: # no compatible device stepped forward
print("No Bolt-compatible device requested pairing.")
print(f"Bolt Pairing: discovered {name}")
receiver.pair_device(
address=address,
authentication=authentication,
entropy=20 if kind == hidpp10_constants.DEVICE_KIND.keyboard else 10,
)
pairing_start = time()
patience = 5 # the discovering notification may come slightly later, so be patient
while receiver.pairing.lock_open or time() - pairing_start < patience:
if receiver.pairing.device_passkey:
break
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
if authentication & 0x01:
print(f"Bolt Pairing: type passkey {receiver.pairing.device_passkey} and then press the enter key")
else:
print(f"Bolt Pairing: discovered {name}")
receiver.pair_device(
address=address,
authentication=authentication,
entropy=20 if kind == hidpp10_constants.DEVICE_KIND.keyboard else 10,
)
pairing_start = time()
patience = 5 # the discovering notification may come slightly later, so be patient
while receiver.pairing.lock_open or time() - pairing_start < patience:
if receiver.pairing.device_passkey:
break
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
if authentication & 0x01:
print(f"Bolt Pairing: type passkey {receiver.pairing.device_passkey} and then press the enter key")
else:
passkey = f"{int(receiver.pairing.device_passkey):010b}"
passkey = ", ".join(["right" if bit == "1" else "left" for bit in passkey])
print(f"Bolt Pairing: press {passkey}")
print("and then press left and right buttons simultaneously")
while receiver.pairing.lock_open:
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
passkey = f"{int(receiver.pairing.device_passkey):010b}"
passkey = ", ".join(["right" if bit == "1" else "left" for bit in passkey])
print(f"Bolt Pairing: press {passkey}")
print("and then press left and right buttons simultaneously")
while receiver.pairing.lock_open:
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
else:
receiver.set_lock(False, timeout=timeout)

View File

@ -38,10 +38,8 @@ def run(receivers, args, find_receiver, find_device):
if not dev:
raise Exception(f"no online device found matching '{device_name}'")
if not dev.online:
print(f"Device {dev.name} is offline.")
elif not dev.profiles:
print(f"Device {dev.name} has no onboard profiles that Solaar supports.")
if not (dev.online and dev.profiles):
print(f"Device {dev.name} is either offline or has no onboard profiles")
elif not profiles_file:
print(f"#Dumping profiles from {dev.name}")
print(yaml.dump(dev.profiles))

View File

@ -25,7 +25,6 @@ from logitech_receiver import settings_templates
from logitech_receiver.common import LOGITECH_VENDOR_ID
from logitech_receiver.common import NamedInt
from logitech_receiver.common import strhex
from logitech_receiver.device import CenturionReceiver
from logitech_receiver.hidpp20_constants import SupportedFeature
from solaar import NAME
@ -36,91 +35,36 @@ _hidpp20 = hidpp20.Hidpp20()
def _print_receiver(receiver):
is_centurion = isinstance(receiver, CenturionReceiver)
paired_count = receiver.count()
print(receiver.name)
print(" Device path :", receiver.path)
print(f" USB id : {LOGITECH_VENDOR_ID:04x}:{receiver.product_id}")
if is_centurion:
print(" Protocol : Centurion")
if receiver.serial:
print(" Serial :", receiver.serial)
if not is_centurion:
pending = hidpp10.get_configuration_pending_flags(receiver)
if pending:
print(f" C Pending : {pending:02x}")
print(" Serial :", receiver.serial)
pending = hidpp10.get_configuration_pending_flags(receiver)
if pending:
print(f" C Pending : {pending:02x}")
if receiver.firmware:
for f in receiver.firmware:
print(" %-11s: %s" % (f.kind, f.version))
if is_centurion:
print(" Has", paired_count, f"device(s) out of a maximum of {int(receiver.max_devices)}.")
else:
print(" Has", paired_count, f"paired device(s) out of a maximum of {int(receiver.max_devices)}.")
if receiver.remaining_pairings() and receiver.remaining_pairings() >= 0:
print(f" Has {int(receiver.remaining_pairings())} successful pairing(s) remaining.")
print(" Has", paired_count, f"paired device(s) out of a maximum of {int(receiver.max_devices)}.")
if receiver.remaining_pairings() and receiver.remaining_pairings() >= 0:
print(f" Has {int(receiver.remaining_pairings())} successful pairing(s) remaining.")
if is_centurion:
_print_centurion_dongle_features(receiver)
else:
notification_flags = _hidpp10.get_notification_flags(receiver)
if notification_flags is not None:
if notification_flags:
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags.value:06X})")
else:
print(" Notifications: (none)")
activity = receiver.read_register(hidpp10_constants.Registers.DEVICES_ACTIVITY)
if activity:
activity = [(d, ord(activity[d - 1 : d])) for d in range(1, receiver.max_devices)]
activity_text = ", ".join(f"{int(d)}={int(a)}" for d, a in activity if a > 0)
print(" Device activity counters:", activity_text or "(empty)")
def _print_centurion_dongle_features(receiver):
"""Print dongle-level features, probed independently on the dongle hardware."""
features = receiver.dongle_features
if not features:
return
print(f" Supports {len(features)} dongle features:")
for feature, feat_id, index in features:
display_name = "CENTPP BRIDGE" if feat_id == 0x0003 else feature
feat_bytes = feat_id.to_bytes(2, byteorder="big")
try:
flags_resp = receiver.request(0x0000, feat_bytes[0], feat_bytes[1])
except Exception:
flags_resp = None
if flags_resp is not None and len(flags_resp) >= 2:
flags = flags_resp[1]
flag_names = common.flag_names(hidpp20_constants.FeatureFlag, flags)
print(" %2d: %-22s {%04X} %s " % (index, display_name, feat_id, ", ".join(flag_names)))
notification_flags = _hidpp10.get_notification_flags(receiver)
if notification_flags is not None:
if notification_flags:
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X})")
else:
print(" %2d: %-22s {%04X}" % (index, display_name, feat_id))
if feature == SupportedFeature.CENTURION_DEVICE_INFO:
fw_list = _hidpp20.get_firmware_centurion(receiver)
serial = _hidpp20.get_serial_centurion(receiver)
hw_info = _hidpp20.get_hardware_info_centurion(receiver)
if fw_list:
for fw in fw_list:
print(f" Firmware: {(str(fw.kind) + ' ' + fw.name).strip()} {fw.version}")
if serial and serial.strip() and serial.strip().isprintable():
print(f" Serial: {serial}")
if hw_info:
model_id, hw_rev, product_id = hw_info
print(f" Hardware: model {model_id}" f" rev {hw_rev} product {product_id:04X}")
print(" Notifications: (none)")
_LED_CAPS_BITS = ((0x0001, "color"), (0x0002, "fade"), (0x0004, "period"), (0x0010, "direction"), (0xC000, "fw"))
def _decode_led_caps(caps):
names = [name for mask, name in _LED_CAPS_BITS if caps & mask]
other = caps & ~sum(m for m, _ in _LED_CAPS_BITS)
if other:
names.append(f"+{other:#06x}")
return f"0x{caps:04x}=" + ("+".join(names) if names else "none")
activity = receiver.read_register(hidpp10_constants.Registers.DEVICES_ACTIVITY)
if activity:
activity = [(d, ord(activity[d - 1 : d])) for d in range(1, receiver.max_devices)]
activity_text = ", ".join(f"{int(d)}={int(a)}" for d, a in activity if a > 0)
print(" Device activity counters:", activity_text or "(empty)")
def _battery_text(level) -> str:
@ -147,11 +91,9 @@ def _battery_line(dev):
def _print_device(dev, num=None):
assert dev is not None
is_centurion = getattr(dev, "centurion", False)
is_centurion_child = is_centurion and isinstance(getattr(dev, "receiver", None), CenturionReceiver)
# try to ping the device to see if it actually exists and to wake it up
try:
online = dev.ping()
dev.ping()
except exceptions.NoSuchDevice:
print(f" {num}: Device not found" or dev.number)
return
@ -160,29 +102,18 @@ def _print_device(dev, num=None):
print(f" {int(num or dev.number)}: {dev.name}")
else:
print(f"{dev.name}")
if not online:
print(" Device is offline.")
return
# Centurion child has no separate hidraw path — show receiver's path
device_path = dev.path or (dev.receiver.path if is_centurion_child else None)
print(" Device path :", device_path)
if dev.wpid and not is_centurion_child:
print(" Device path :", dev.path)
if dev.wpid:
print(f" WPID : {dev.wpid}")
if dev.product_id:
print(f" USB id : {LOGITECH_VENDOR_ID:04x}:{dev.product_id}")
print(" Codename :", dev.codename)
print(" Kind :", dev.kind)
if dev.protocol:
proto_name = "Centurion" if is_centurion else "HID++"
cent_proto = getattr(dev, "_centurion_protocol", None)
if cent_proto:
print(f" Protocol : {proto_name} {cent_proto[0]}.{cent_proto[1]}")
else:
print(f" Protocol : {proto_name} {dev.protocol:1.1f}")
print(f" Protocol : HID++ {dev.protocol:1.1f}")
else:
print(" Protocol : unknown (device is offline)")
if not is_centurion and dev.polling_rate:
if dev.polling_rate:
print(" Report Rate :", dev.polling_rate)
print(" Serial number:", dev.serial)
if dev.modelId:
@ -196,13 +127,12 @@ def _print_device(dev, num=None):
if dev.power_switch_location:
print(f" The power switch is located on the {dev.power_switch_location}.")
# Skip HID++ 1.0 register reads for centurion devices — they don't support these
if dev.online and not is_centurion:
if dev.online:
notification_flags = _hidpp10.get_notification_flags(dev)
if notification_flags is not None:
if notification_flags:
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags.value:06X}).")
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X}).")
else:
print(" Notifications: (none).")
device_features = _hidpp10.get_device_features(dev)
@ -214,56 +144,21 @@ def _print_device(dev, num=None):
print(" Features: (none)")
if dev.online and dev.features:
is_centurion = getattr(dev, "centurion", False)
parent_count = dev.features.count
sub_count = getattr(dev.features, "_sub_feature_count", 0)
# For centurion child devices, dongle features are shown on the receiver —
# only show sub-device (headset) features here.
if is_centurion_child and sub_count > 0:
print(f" Supports {sub_count} HID++ 2.0 features:")
elif is_centurion and sub_count > 0:
print(f" Supports {parent_count} dongle + {sub_count} headset features:")
else:
print(f" Supports {len(dev.features)} HID++ 2.0 features:")
print(f" Supports {len(dev.features)} HID++ 2.0 features:")
dev_settings = []
settings_templates.check_feature_settings(dev, dev_settings)
feature_num = 0
in_sub_device = False
for feature, index in dev.features.enumerate():
if is_centurion and not in_sub_device and feature_num >= parent_count:
in_sub_device = True
if not is_centurion_child:
print(" Headset (via CentPPBridge):")
feature_num += 1
# For centurion child, skip dongle features (already shown on the receiver)
if is_centurion_child and not in_sub_device:
continue
if isinstance(feature, str):
feature_bytes = bytes.fromhex(feature[-4:])
else:
feature_bytes = feature.to_bytes(2, byteorder="little")
feature_int = int.from_bytes(feature_bytes, byteorder="little")
display_name = feature
if is_centurion_child and in_sub_device:
# Use cached version — skip slow bridge ROOT queries
version = dev.features.get_feature_version(feature_int) or 0
print(" %2d: %-22s {%04X} V%s" % (index, display_name, feature_int, version))
else:
try:
flags = dev.request(0x0000, feature_bytes)
except Exception:
flags = None
if flags is not None:
flags = ord(flags[1:2])
flag_names = common.flag_names(hidpp20_constants.FeatureFlag, flags)
version = dev.features.get_feature_version(feature_int)
version = version if version else 0
print(
" %2d: %-22s {%04X} V%s %s "
% (index, display_name, feature_int, version, ", ".join(flag_names))
)
else:
print(" %2d: %-22s {%04X}" % (index, display_name, feature_int))
flags = dev.request(0x0000, feature_bytes)
flags = 0 if flags is None else ord(flags[1:2])
flags = common.flag_names(hidpp20_constants.FeatureFlag, flags)
version = dev.features.get_feature_version(feature_int)
version = version if version else 0
print(" %2d: %-22s {%04X} V%s %s " % (index, feature, feature_int, version, ", ".join(flags)))
if feature == SupportedFeature.HIRES_WHEEL:
wheel = _hidpp20.get_hires_wheel(dev)
if wheel:
@ -331,25 +226,7 @@ def _print_device(dev, num=None):
print(f" Kind: {_hidpp20.get_kind(dev)}")
elif feature == SupportedFeature.DEVICE_FRIENDLY_NAME:
print(f" Friendly Name: {_hidpp20.get_friendly_name(dev)}")
elif feature == SupportedFeature.CENTURION_DEVICE_INFO:
if in_sub_device:
# Use cached device properties to avoid redundant bridge requests
fw_list = dev.firmware
serial = dev.serial
hw_info = _hidpp20.get_hardware_info_centurion_sub(dev)
else:
fw_list = _hidpp20.get_firmware_centurion(dev)
serial = _hidpp20.get_serial_centurion(dev)
hw_info = _hidpp20.get_hardware_info_centurion(dev)
if fw_list:
for fw in fw_list:
print(f" Firmware: {(str(fw.kind) + ' ' + fw.name).strip()} {fw.version}")
if serial and serial.strip() and serial.strip().isprintable():
print(f" Serial: {serial}")
if hw_info:
model_id, hw_rev, product_id = hw_info
print(f" Hardware: model {model_id}" f" rev {hw_rev} product {product_id:04X}")
elif isinstance(feature, SupportedFeature) and feature == SupportedFeature.DEVICE_FW_VERSION:
elif feature == SupportedFeature.DEVICE_FW_VERSION:
for fw in _hidpp20.get_firmware(dev):
extras = strhex(fw.extras) if fw.extras else ""
print(f" Firmware: {fw.kind} {fw.name} {fw.version} {extras}")
@ -370,28 +247,6 @@ def _print_device(dev, num=None):
else:
mode = "On-Board"
print(f" Device Mode: {mode}")
elif feature == SupportedFeature.HEADSET_ONBOARD_EQ:
bands = hidpp20.get_onboard_eq_params(dev)
if bands:
print(f" EQ: {', '.join(f'{f}Hz:{g:+d}dB' for f, g, _q in bands)}")
elif feature == SupportedFeature.RGB_EFFECTS or feature == SupportedFeature.COLOR_LED_EFFECTS:
try:
infos = dev.led_effects
except Exception as e:
print(f" Effect enumeration failed: {e}")
infos = None
if infos and infos.zones:
for zone in infos.zones:
print(f" Zone {int(zone.index)} ({zone.location}): {len(zone.effects)} effect(s)")
for e in zone.effects:
entry = hidpp20.LEDEffects.get(e.ID)
name = entry[0].name if entry else f"Unknown(0x{e.ID:02x})"
caps = _decode_led_caps(e.capabilities)
params = ", ".join(str(p) for p in entry[1]) if entry and entry[1] else ""
print(
f" [{e.index}] 0x{e.ID:02x} {name:<14} "
f"caps {caps:<28} default {e.period}ms params: {params}"
)
elif hidpp20.battery_functions.get(feature, None):
print("", end=" ")
_battery_line(dev)
@ -404,19 +259,13 @@ def _print_device(dev, num=None):
):
v = setting.val_to_string(setting._device.persister.get(setting.name))
print(f" {setting.label} (saved): {v}")
# Settings whose value cannot be read back from the device
# (e.g. PerKeyLighting — 0x8081 has no GetIndividualRgbZones)
# suppress the live-read line; the saved line above is the
# authoritative record. See Setting.live_readable.
if getattr(setting, "live_readable", True):
try:
v = setting.read(False)
v = setting.val_to_string(v)
except exceptions.FeatureCallError as e:
v = "HID++ error " + str(e)
except AssertionError as e:
v = "AssertionError " + str(e)
print(f" {setting.label} : {v}")
try:
v = setting.val_to_string(setting.read(False))
except exceptions.FeatureCallError as e:
v = "HID++ error " + str(e)
except AssertionError as e:
v = "AssertionError " + str(e)
print(f" {setting.label} : {v}")
if dev.online and dev.keys:
print(f" Has {len(dev.keys)} reprogrammable keys:")
@ -470,7 +319,7 @@ def run(devices, args, find_receiver, find_device):
if device_name == "all":
for d in devices:
if isinstance(d, (receiver.Receiver, CenturionReceiver)):
if isinstance(d, receiver.Receiver):
_print_receiver(d)
count = d.count()
if count:
@ -482,8 +331,8 @@ def run(devices, args, find_receiver, find_device):
break
print("")
else:
_print_device(d)
print("")
_print_device(d)
return
dev = find_receiver(devices, device_name)

View File

@ -17,12 +17,7 @@
def run(receivers, args, find_receiver, find_device):
assert receivers
if getattr(args, "slot", None) is not None:
_run_slot_unpair(receivers, args, find_receiver)
return
assert args.device, "unpair requires a device name, or use --slot"
assert args.device
device_name = args.device.lower()
dev = next(find_device(receivers, device_name), None)
@ -41,50 +36,3 @@ def run(receivers, args, find_receiver, find_device):
print(f"Unpaired {int(number)}: {dev.name} ({codename}) [{wpid}:{serial}]")
except Exception as e:
raise e
def _run_slot_unpair(receivers, args, find_receiver):
if args.receiver:
rcv = find_receiver(receivers, args.receiver.lower())
if not rcv:
raise Exception(f"no receiver found matching '{args.receiver}'")
elif len(receivers) == 1:
rcv = receivers[0]
else:
names = ", ".join(f"{r.name} [{r.serial}]" for r in receivers)
raise Exception(f"multiple receivers present, pass --receiver to pick one (found: {names})")
if rcv.receiver_kind != "lightspeed":
raise Exception(
f"--slot unpair is currently only supported on Lightspeed receivers "
f"(this is a {rcv.receiver_kind or 'unknown'} receiver: {rcv.name})"
)
slot = int(args.slot)
max_slots = rcv.max_devices or 1
if slot < 1 or slot > max_slots:
raise Exception(f"--slot {slot} out of range (valid: 1..{max_slots} on {rcv.name})")
# Populate the cache from the receiver's pairing registers so we can report
# what the slot currently holds. Truthy cache does NOT imply the device is
# reachable on RF — it only means the pairing registers are readable.
list(rcv)
cached = rcv._devices.get(slot)
if cached:
slot_desc = f"{cached.name} [{cached.wpid}:{cached.serial}]"
elif slot in rcv._devices:
slot_desc = "cached None sentinel (pairing info unreadable)"
else:
slot_desc = "no pairing info cached"
print(f"Slot {slot} on {rcv.name} [{rcv.serial}]: {slot_desc}")
if getattr(args, "dry_run", False):
print(f"[dry-run] would force-unpair slot {slot} — no register write issued")
return
ok = rcv.force_unpair_slot(slot)
if ok:
print(f"Slot {slot} unpair register write acknowledged by receiver")
else:
print(f"Slot {slot} unpair register write was not acknowledged (may be a no-op)")

View File

@ -231,16 +231,8 @@ yaml.add_representer(NamedInt, named_int_representer)
# So new entries are not created for unseen off-line receiver-connected devices
def persister(device):
def match(wpid, serial, modelId, unitId, c):
return (
(wpid and wpid == c.get(_KEY_WPID) and serial and serial == c.get(_KEY_SERIAL))
or (modelId and modelId == c.get(_KEY_MODEL_ID) and unitId and unitId == c.get(_KEY_UNIT_ID))
or (
c.get(_KEY_WPID) is None
and c.get(_KEY_SERIAL) is None
and c.get(_KEY_UNIT_ID) is None
and modelId
and modelId == c.get(_KEY_MODEL_ID)
)
return (wpid and wpid == c.get(_KEY_WPID) and serial and serial == c.get(_KEY_SERIAL)) or (
modelId and modelId == c.get(_KEY_MODEL_ID) and unitId and unitId == c.get(_KEY_UNIT_ID)
)
with configuration_lock:
@ -248,8 +240,8 @@ def persister(device):
_load()
entry = None
# some devices report modelId and unitId as zero so use name and serial for them
modelId = device.modelId if device.modelId != "000000000000" else device._name if device._name else None
unitId = device.unitId if device.unitId != "00000000" else device._serial if device._serial else None
modelId = device.modelId if device.modelId != "000000000000" else device._name if device.modelId else None
unitId = device.unitId if device.modelId != "000000000000" else device._serial if device.unitId else None
for c in _config:
if isinstance(c, _DeviceEntry) and match(device.wpid, device._serial, modelId, unitId, c):
entry = c

View File

@ -58,10 +58,7 @@ temp = tempfile.NamedTemporaryFile(prefix="Solaar_", mode="w", delete=True)
def create_parser():
arg_parser = argparse.ArgumentParser(
prog=NAME.lower(),
description="Solaar is a program to manage many Logitech devices, "
"changing how they operate and maintaining the changes whenever the device connects.",
epilog="For more information see https://pwr-solaar.github.io/Solaar",
prog=NAME.lower(), epilog="For more information see https://pwr-solaar.github.io/Solaar"
)
arg_parser.add_argument(
"-d",
@ -76,7 +73,7 @@ def create_parser():
action="store",
dest="hidraw_path",
metavar="PATH",
help="device or receiver path to use if needed. Example: /dev/hidraw2",
help="unifying receiver to use; the first detected receiver if unspecified. Example: /dev/hidraw2",
)
arg_parser.add_argument(
"--restart-on-wake-up",

View File

@ -77,7 +77,7 @@ class SolaarListener(listener.EventsListener):
def has_started(self):
logger.info("%s: notifications listener has started (%s)", self.receiver, self.receiver.handle)
nfs = self.receiver.enable_connection_notifications()
if not self.receiver.isDevice and (not nfs or not (nfs & hidpp10_constants.NotificationFlag.WIRELESS)):
if not self.receiver.isDevice and not ((nfs if nfs else 0) & hidpp10_constants.NotificationFlag.WIRELESS.value):
logger.warning(
"Receiver on %s might not support connection notifications, GUI might not show its devices",
self.receiver.path,
@ -115,6 +115,7 @@ class SolaarListener(listener.EventsListener):
reason or "",
)
else:
device.ping()
logger.info(
"status_changed %r: %s %s (%X) %s",
device,
@ -148,18 +149,6 @@ class SolaarListener(listener.EventsListener):
def _notifications_handler(self, n):
assert self.receiver
if n.devnumber == 0xFF:
# For CenturionReceiver, intercept bridge notifications and dispatch to child device
from logitech_receiver.device import CenturionReceiver
if isinstance(self.receiver, CenturionReceiver):
if self.receiver._pending:
ihandle = int(self.receiver.handle)
state = base._centurion_handles.get(ihandle)
if state and state.device_addr is not None:
self.receiver._complete_deferred_init()
self._status_changed(self.receiver)
self._handle_centurion_notification(n)
return
# a receiver notification
notifications.process(self.receiver, n)
return
@ -239,102 +228,6 @@ class SolaarListener(listener.EventsListener):
elif dev.online is None:
dev.ping()
def _handle_centurion_notification(self, n):
"""Handle notifications from a CenturionReceiver dongle.
Bridge events have sub_id == bridge_index. The event function number
is in bits 7-4 of n.address:
- Function 0: ConnectionStateChangedEvent sub-device connect/disconnect
- Function 1: MessageEvent wrapped sub-device HID++ notification
ConnectionStateChangedEvent payload (same format as getConnectionInfo):
n.data[0]: high nibble = connection type, low nibble = len_hi
n.data[1]: len_lo
n.data[2+]: sub-device descriptors (if any)
Empty sub-device list (length=0) means disconnected.
MessageEvent data layout:
n.data[0:2] = dev_id<<4|len_hi, len_lo
n.data[2] = sub_cpl (0x00 for both responses and notifications)
n.data[3] = sub_feat_idx
n.data[4] = sub_func_sw (sw_id=0 for unsolicited notifications)
n.data[5:] = payload
"""
child = self.receiver._devices.get(1)
if not child:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("CenturionReceiver: notification ignored (no child device): %s", n)
return
bridge_idx = getattr(child, "_centurion_bridge_index", None)
if bridge_idx is None or n.sub_id != bridge_idx:
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"CenturionReceiver: non-bridge notification sub_id=%d addr=0x%02X data=%s",
n.sub_id,
n.address,
n.data[:12].hex() if n.data else "",
)
return
event_func = (n.address >> 4) & 0x0F
if event_func == 0:
# ConnectionStateChangedEvent — parse sub-device list length
if len(n.data) < 2:
return
data_len = ((n.data[0] & 0x0F) << 8) | n.data[1]
if data_len > 0 and not child.online:
if logger.isEnabledFor(logging.INFO):
logger.info("CenturionReceiver: headset connected (ConnectionStateChangedEvent, len=%d)", data_len)
child.changed(active=True)
self._status_changed(child)
elif data_len == 0 and child.online:
if logger.isEnabledFor(logging.INFO):
logger.info("CenturionReceiver: headset disconnected (ConnectionStateChangedEvent, len=0)")
child.changed(active=False)
self._status_changed(child)
return
if event_func == 1:
# MessageEvent — unwrap sub-device notification
# n.data layout: [dev_id<<4|len_hi, len_lo, sub_cpl, sub_feat_idx, sub_func_sw, payload...]
if len(n.data) < 5:
return
# A MessageEvent from the headset proves it's online. If we missed the
# ConnectionStateChangedEvent (e.g. cold-start power-on), bring it online now.
if not child.online:
if logger.isEnabledFor(logging.INFO):
logger.info("CenturionReceiver: headset online (MessageEvent received while offline)")
child.changed(active=True)
self._status_changed(child)
sub_feat_idx = n.data[3]
sub_func_sw = n.data[4]
payload = n.data[5:]
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"CenturionReceiver: bridge MessageEvent sub_feat=%d func=0x%02X payload=%s -> child %s",
sub_feat_idx,
sub_func_sw,
payload[:8].hex() if payload else "",
child,
)
# Create synthetic notification and dispatch directly to feature processing.
# Sub-device features use 0x100 offset in FeaturesArray.inverse.
synthetic = base.HIDPPNotification(n.report_id, child.number, sub_feat_idx + 0x100, sub_func_sw, payload)
child.online = True
if child.features:
notifications._process_feature_notification(child, synthetic)
return
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"CenturionReceiver: unhandled bridge event func=%d addr=0x%02X data=%s",
event_func,
n.address,
n.data[:12].hex() if n.data else "",
)
def __str__(self):
return f"<SolaarListener({self.receiver.path},{self.receiver.handle})>"
@ -362,38 +255,18 @@ def _cleanup_bluez_dbus(device: Device):
_all_listeners = {} # all known receiver listeners, listeners that stop on their own may remain here
def _post_attach_device(device):
"""Shared post-create_device wiring: hook configuration up to the new
Device, and if it's BT-paired, install the bluez-dbus connect watcher
so disconnect/reconnect events propagate to the UI without a restart.
Both the Centurion-direct fallback (no-dongle device, e.g. BT-paired
G522 / PRO X 2) and the regular non-Centurion device path call this
previously only the latter wired the bluez watcher, so a BT-paired
Centurion device wouldn't see reconnect events."""
configuration.attach_to(device)
if device.bluetooth and device.hid_serial:
dbus.watch_bluez_connect(device.hid_serial, partial(_process_bluez_dbus, device))
device.cleanups.append(_cleanup_bluez_dbus)
def _start(device_info: DeviceInfo):
assert _status_callback and _setting_callback
if not device_info.isDevice:
receiver_ = logitech_receiver.receiver.create_receiver(base, device_info, _setting_callback)
elif getattr(device_info, "centurion", False):
receiver_ = logitech_receiver.device.create_centurion_receiver(base, device_info, _setting_callback)
if receiver_ is None:
# No bridge found — treat as a direct-connected centurion device
# (wired headset, or BT-paired headset with no LIGHTSPEED dongle).
receiver_ = logitech_receiver.device.create_device(base, device_info, _setting_callback)
if receiver_:
_post_attach_device(receiver_)
else:
receiver_ = logitech_receiver.device.create_device(base, device_info, _setting_callback)
if receiver_:
_post_attach_device(receiver_)
configuration.attach_to(receiver_)
if receiver_.bluetooth and receiver_.hid_serial:
dbus.watch_bluez_connect(receiver_.hid_serial, partial(_process_bluez_dbus, receiver_))
receiver_.cleanups.append(_cleanup_bluez_dbus)
if receiver_:
rl = SolaarListener(receiver_, _status_callback)
@ -441,13 +314,10 @@ def ping_all(resuming=False):
for dev in listener_thread.receiver:
if resuming:
dev._active = None # ensure that settings are pushed
if dev.ping():
dev.changed(active=True, push=True)
listener_thread._status_changed(dev)
count -= 1
try: # sometimes the device is not set up already, it should come back later
if dev.ping():
dev.changed(active=True, push=True)
listener_thread._status_changed(dev)
except exceptions.NoSuchDevice:
logger.debug("can't ping device on resume: %s", dev)
if not count:
break
@ -472,7 +342,7 @@ def _process_add(device_info: DeviceInfo, retry):
except OSError as e:
if e.errno == errno.EACCES:
try:
output = subprocess.check_output(["getfacl", "-p", device_info.path], text=True)
output = subprocess.check_output(["/usr/bin/getfacl", "-p", device_info.path], text=True)
logger.warning("Missing permissions on %s\n%s.", device_info.path, output)
except Exception:
pass

View File

@ -122,9 +122,7 @@ def run_loop(
def _status_changed(device, alert, reason, refresh=False):
if device is None:
logger.debug("status changed on nil device: %s (%s) %s", device, alert, reason)
return
assert device is not None
logger.debug("status changed: %s (%s) %s", device, alert, reason)
if alert is None:
alert = Alert.NONE

View File

@ -56,12 +56,11 @@ class AboutModel:
"El Jinete Sin Cabeza (Español)",
"Ferdina Kusumah (Indonesia)",
"John Erling Blad (Norwegian Bokmål, Norwegian Nynorsk)",
"Oleksandr Afanasiev (Ukrainian)",
]
def get_credit_sections(self) -> List[Tuple[str, List[str]]]:
return [
(_("Additional Programming"), ["Filipe Laíns", "Peter F. Patel-Schneider", "Ken Sanislo"]),
(_("Additional Programming"), ["Filipe Laíns", "Peter F. Patel-Schneider"]),
(_("GUI design"), ["Julien Gascard", "Daniel Pavel"]),
(
_("Testing"),

View File

@ -98,10 +98,6 @@ def unpair(window, device):
device_number = device.number
try:
# force=True ensures the unpair register write is issued even on
# re_pairs receivers (Lightspeed, Nano); otherwise _unpair_device
# short-circuits to cache-invalidation only and the slot stays
# bound on the hardware.
receiver._unpair_device(device_number, True)
del receiver[device_number]
except Exception:
common.error_dialog(common.ErrorReason.UNPAIR, device)

View File

@ -16,6 +16,7 @@
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import logging
import traceback
from enum import Enum
from threading import Timer
@ -24,7 +25,6 @@ import gi
from logitech_receiver import hidpp20
from logitech_receiver import settings
from logitech_receiver import settings_templates
from solaar.i18n import _
from solaar.i18n import ngettext
@ -47,7 +47,6 @@ class GtkSignal(Enum):
NOTIFY_ACTIVE = "notify::active"
TOGGLED = "toggled"
VALUE_CHANGED = "value-changed"
COLOR_SET = "color-set"
def _read_async(setting, force_read, sbox, device_is_online, sensitive):
@ -57,8 +56,7 @@ def _read_async(setting, force_read, sbox, device_is_online, sensitive):
except Exception as e:
v = None
logger.warning("%s: error reading so use None (%s): %s", s.name, s._device, repr(e))
null_okay = not getattr(getattr(s, "_validator", None), "readable", True)
GLib.idle_add(_update_setting_item, sb, v, online, sensitive, null_okay, priority=99)
GLib.idle_add(_update_setting_item, sb, v, online, sensitive, True, priority=99)
ui_async(_do_read, setting, force_read, sbox, device_is_online, sensitive)
@ -72,6 +70,7 @@ def _write_async(setting, value, sbox, sensitive=True, key=None):
v = setting.write_key_value(key, v)
v = {key: v}
except Exception:
traceback.print_exc()
v = None
if sb:
GLib.idle_add(_update_setting_item, sb, v, True, sensitive, priority=99)
@ -146,20 +145,8 @@ class SliderControl(Gtk.Scale, Control):
self.set_round_digits(0)
self.set_digits(0)
self.set_increments(1, 5)
# Halving tick marks are an intensity-slider feature only.
if self.sbox.setting.name == "brightness_control":
validator = getattr(self.sbox.setting, "_validator", None)
steps = getattr(validator, "steps", 0) if validator is not None else 0
if steps:
for mark in settings_templates.halving_marks(validator.max_value, steps):
self.add_mark(mark, Gtk.PositionType.BOTTOM, None)
self.connect(GtkSignal.VALUE_CHANGED.value, self.changed)
def set_value(self, value):
if isinstance(value, dict):
value = next(iter(value.values()))
return super().set_value(value)
def get_value(self):
return int(super().get_value())
@ -554,94 +541,7 @@ class PackedRangeControl(MultipleRangeControl):
self._button.set_tooltip_text(b)
class GraphicEQControl(MultipleControl):
def setup(self, setting):
self._items = []
validator = setting._validator
row = Gtk.ListBoxRow()
hbox = Gtk.HBox(homogeneous=True, spacing=8)
for item in range(validator.count):
vbox = Gtk.VBox(homogeneous=False, spacing=2)
scale = Gtk.Scale.new_with_range(Gtk.Orientation.VERTICAL, validator.min_value, validator.max_value, 1)
scale.set_inverted(True)
scale.set_round_digits(0)
scale.set_digits(0)
scale.set_draw_value(True)
scale.connect("format-value", lambda s, v: f"{int(v)} dB")
scale.set_has_origin(True)
scale.set_size_request(-1, 150)
scale.add_mark(0, Gtk.PositionType.LEFT, "0")
scale.connect(GtkSignal.VALUE_CHANGED.value, self._changed, validator.keys[item])
lbl = Gtk.Label(label=str(validator.keys[item]))
lbl.set_line_wrap(True)
lbl.set_justify(Gtk.Justification.CENTER)
vbox.pack_start(scale, True, True, 0)
vbox.pack_end(lbl, False, False, 0)
vbox._setting_item = validator.keys[item]
vbox.control = scale
hbox.pack_start(vbox, True, True, 0)
self._items.append(vbox)
row.add(hbox)
self.add(row)
def _changed(self, control, item):
if control.get_sensitive():
if hasattr(control, "_timer"):
control._timer.cancel()
control._timer = Timer(0.5, lambda: GLib.idle_add(self._write, control, item))
control._timer.start()
def _write(self, control, item):
control._timer.cancel()
delattr(control, "_timer")
new_state = int(control.get_value())
value = self.sbox.setting._value
if not isinstance(value, dict):
return
if value.get(int(item)) != new_state:
value[int(item)] = new_state
_write_async(self.sbox.setting, value[int(item)], self.sbox, key=int(item))
def set_value(self, value):
if value is None:
return
b = ""
n = len(self._items)
stored = self.sbox.setting._value if isinstance(self.sbox.setting._value, dict) else {}
for vbox in self._items:
item = vbox._setting_item
v = value.get(int(item))
if v is not None:
vbox.control.set_value(v)
else:
v = stored.get(int(item), 0)
b += f"{str(item)}: ({str(v)}) "
lbl_text = ngettext("%d value", "%d values", n) % n
self._button.set_label(lbl_text)
self._button.set_tooltip_text(b)
# control with an ID key that determines what else to show
class _HeteroToggleSwitch(Gtk.Switch):
"""Gtk.Switch with int-valued get/set_value for HeteroKeyControl.
Maps switch True/False to the field's wire on/off integer values so the
surrounding control machinery (changed handler, get_value, set_value) can
stay int-based like every other field kind.
"""
def __init__(self, on_value: int = 1, off_value: int = 2, **kwargs):
super().__init__(**kwargs)
self._on = int(on_value)
self._off = int(off_value)
def get_value(self) -> int:
return self._on if self.get_state() else self._off
def set_value(self, value) -> None:
self.set_state(int(value) == self._on)
class HeteroKeyControl(Gtk.HBox, Control):
def __init__(self, sbox, delegate=None):
super().__init__(homogeneous=False, spacing=6)
@ -662,36 +562,12 @@ class HeteroKeyControl(Gtk.HBox, Control):
item_box.set_active(0)
item_box.connect(GtkSignal.CHANGED.value, self.changed)
self.pack_start(item_box, False, False, 0)
elif item["kind"] == settings.Kind.TOGGLE:
# Right-align like standard TOGGLE settings — pack_end so the
# switch hugs the right edge while other fields stay left.
item_box = _HeteroToggleSwitch(
on_value=item.get("on_value", 1),
off_value=item.get("off_value", 2),
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
item_box.connect(GtkSignal.NOTIFY_ACTIVE.value, self.changed)
self.pack_end(item_box, False, False, 0)
elif item["kind"] == settings.Kind.COLOR:
item_box = Gtk.ColorButton()
item_box.connect(GtkSignal.COLOR_SET.value, self.changed)
self.pack_start(item_box, False, False, 0)
elif item["kind"] == settings.Kind.RANGE:
item_box = Scale()
item_box.set_range(item["min"], item["max"])
item_box.set_round_digits(0)
item_box.set_digits(0)
item_box.set_increments(1, 5)
# Halving tick marks are an intensity-slider feature only.
if item.get("halving") and str(item.get("name")) == str(hidpp20.LEDParam.intensity):
steps = getattr(sbox.setting._device, "_brightness_steps", 0) or settings_templates.auto_step_count(
item["max"]
)
for mark in settings_templates.halving_marks(item["max"], steps):
item_box.add_mark(mark, Gtk.PositionType.BOTTOM, None)
if item.get("display_seconds", False):
item_box.connect("format-value", lambda _s, v: f"{int(v) / 1000:.2f}s")
item_box.connect(GtkSignal.VALUE_CHANGED.value, self.changed)
self.pack_start(item_box, True, True, 0)
item_box.set_visible(False)
@ -700,34 +576,20 @@ class HeteroKeyControl(Gtk.HBox, Control):
def get_value(self):
result = {}
for k, (_lblbox, box) in self._items.items():
if isinstance(box, Gtk.ColorButton):
rgba = box.get_rgba()
r = int(rgba.red * 255)
g = int(rgba.green * 255)
b = int(rgba.blue * 255)
result[str(k)] = (r << 16) | (g << 8) | b
else:
result[str(k)] = box.get_value()
data_class = getattr(self.sbox.setting._validator, "data_class", hidpp20.LEDEffectSetting)
result = data_class(**result)
result[str(k)] = box.get_value()
result = hidpp20.LEDEffectSetting(**result)
return result
def set_value(self, value):
self.set_sensitive(False)
id_ = value.ID if value is not None else 0
self._apply_id_ranges(id_)
if value is not None:
for k, v in value.__dict__.items():
if k in self._items:
(lblbox, box) = self._items[k]
if isinstance(box, Gtk.ColorButton):
rgba = Gdk.RGBA()
color_string = f"#{v:06X}" # e.g. "#FF0000"
rgba.parse(color_string)
box.set_rgba(rgba)
else:
box.set_value(v)
self.setup_visibles(id_)
box.set_value(v)
else:
self.sbox._failed.set_visible(True)
self.setup_visibles(value.ID if value is not None else 0)
def setup_visibles(self, id_):
fields = self.sbox.setting.fields_map[id_][1] if id_ in self.sbox.setting.fields_map else {}
@ -737,61 +599,15 @@ class HeteroKeyControl(Gtk.HBox, Control):
lblbox.set_visible(visible)
box.set_visible(visible)
def changed(self, control, *_args):
# *_args swallows the extra GParamSpec passed by Gtk.Switch's
# "notify::active" signal — other field signals pass just (widget,).
def changed(self, control):
if self.get_sensitive() and control.get_sensitive():
if "ID" in self._items and control == self._items["ID"][1]:
new_id = int(self._items["ID"][1].get_value())
self.setup_visibles(new_id)
self._apply_id_ranges(new_id)
self._apply_id_defaults(new_id)
self.setup_visibles(int(self._items["ID"][1].get_value()))
if hasattr(control, "_timer"):
control._timer.cancel()
control._timer = Timer(0.3, lambda: GLib.idle_add(self._write, control))
control._timer.start()
def _apply_id_ranges(self, id_):
"""Reset every RANGE widget to its field's global min/max, then apply
per-effect overrides from fields_map[id_][3]. Reset-first ensures
switching from an override (e.g. Ripple 2-200) to an effect without
one restores the global range instead of inheriting the narrow one."""
fields_map = getattr(self.sbox.setting, "fields_map", None)
entry = fields_map.get(id_) if fields_map else None
ranges = entry[3] if entry and len(entry) > 3 else {}
for field in self.sbox.setting.possible_fields:
if field.get("kind") != settings.Kind.RANGE:
continue
name = str(field["name"])
if name not in self._items:
continue
_, box = self._items[name]
lo, hi = ranges.get(field["name"], (field.get("min", 0), field.get("max", 0)))
box.set_range(lo, hi)
def _apply_id_defaults(self, id_):
"""Apply fields_map[id_][2] defaults to RANGE widgets sitting at min."""
fields_map = getattr(self.sbox.setting, "fields_map", None)
if not fields_map or id_ not in fields_map:
return
entry = fields_map[id_]
if len(entry) < 3:
return
defaults = entry[2]
ranges = entry[3] if len(entry) > 3 else {}
field_by_name = {str(f["name"]): f for f in self.sbox.setting.possible_fields}
for param_name, default_value in defaults.items():
name = str(param_name)
if name not in self._items:
continue
field = field_by_name.get(name)
if field is None or field.get("kind") != settings.Kind.RANGE:
continue
_, box = self._items[name]
effective_min = ranges[param_name][0] if param_name in ranges else field.get("min", 0)
if box.get_value() == effective_min:
box.set_value(default_value)
def _write(self, control):
control._timer.cancel()
delattr(control, "_timer")
@ -811,144 +627,6 @@ _icons_allowables = {v: k for k, v in _allowables_icons.items()}
# clicking on the lock icon changes from changeable to unchangeable to ignore
# Settings whose operation depends on LED Control being set to Solaar.
# Zone settings (rgb_zone_*) are matched by prefix because their name carries
# the zone index (rgb_zone_1, rgb_zone_2, ...).
_SW_CONTROL_DEPENDENT_NAMES = ("rgb_idle_timeout", "rgb_idle_effect", "rgb_sleep_timeout")
_SW_CONTROL_DEPENDENT_PREFIXES = ("rgb_zone_",)
# headset_led_control = whether Solaar holds the live-coloring claim (off lets
# another app drive the LEDs). The 0x0620 per-zone painting and the 0x0621
# onboard effect are both live LED control, so both need the claim; per-zone
# additionally needs the onboard effect on Static (the per-key analog of
# needs-rgb_control + zone-Static). The 0x0622 signature effects are stored
# settings (startup/shutdown colors) and stay ungated.
_HEADSET_LED_DEPENDENT_NAMES = ("headset_per_zone_lighting", "headset-onboard-effect")
def _sw_control_blocked(device):
"""True when LED Control is not Solaar. Reads from setting._value first,
then the persister, so the gate is right at panel load before live reads
have populated. Accepts either the current bool (BooleanValidator) or the
legacy int 3/0 (older ChoicesValidator persister entries)."""
persister = getattr(device, "persister", None)
if persister is None:
return False
value = None
for s in getattr(device, "settings", []) or []:
if s.name == "rgb_control":
value = s._value
break
if value is None:
value = persister.get("rgb_control")
if value is None:
return False
# Solaar = bool True or legacy int 3; everything else (False, 0, …) blocks.
return value not in (True, 3)
def _headset_led_blocked(device):
"""True when the headset's LED Control is off (Device/firmware mode).
Reads setting._value first, then the persister; accepts the current bool
or a legacy int 0/1 from the old ChoicesValidator persister entries."""
persister = getattr(device, "persister", None)
if persister is None:
return False
value = None
for s in getattr(device, "settings", []) or []:
if s.name == "headset_led_control":
value = s._value
break
if value is None:
value = persister.get("headset_led_control")
if value is None:
return False
return value not in (True, 1)
def _cluster_effect_blocks_perzone(device):
"""True when the headset's 0x0621 onboard effect is not Fixed — a
non-Fixed cluster animation masks the per-zone buffer. Mirrors
`_zone_effect_blocks_perkey`. False when the device has no
onboard-effect setting (nothing to mask against)."""
persister = getattr(device, "persister", None)
value = None
for s in getattr(device, "settings", []) or []:
if s.name == "headset-onboard-effect":
value = s._value if s._value is not None else (persister.get(s.name) if persister else None)
break
else:
return False
if value is None:
return False
return int(getattr(value, "ID", 0)) != 0
def _zone_effect_blocks_perkey(device):
"""True when any zone effect's saved ID is not Static (0x01) — zone
animations mask the per-key buffer regardless of SW control state."""
persister = getattr(device, "persister", None)
if persister is None:
return False
for s in getattr(device, "settings", []) or []:
if not s.name.startswith("rgb_zone_"):
continue
v = s._value if s._value is not None else persister.get(s.name)
if v is None:
continue
if int(getattr(v, "ID", 0) or 0) != 0x01:
return True
return False
def _set_row_sensitive(device, name, can_function):
"""Apply sensitivity to a single setting's control row. Combines the
user's lock-icon opt-in (persister sensitivity) with the can-function
gate so neither alone can override the other."""
device_id = (device.receiver.path if device.receiver else device.path, device.number)
sbox = _items.get((device_id[0], device_id[1], name))
if sbox is None or not hasattr(sbox, "_control"):
return
persister = getattr(device, "persister", None)
user_allowed = persister.get_sensitivity(name) if persister else True
sbox._control.set_sensitive(user_allowed is True and can_function)
def _gate_blocks(device, name):
"""Single source of truth for "is this setting's row gated off?". Used by
`_apply_rgb_gates` to grey rows and by `_update_setting_item` so async-read
completions can't undo the grey-out when their callbacks land later."""
if name in _SW_CONTROL_DEPENDENT_NAMES or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES):
return _sw_control_blocked(device)
if name == "per-key-lighting":
return _sw_control_blocked(device) or _zone_effect_blocks_perkey(device)
if name in _HEADSET_LED_DEPENDENT_NAMES:
if _headset_led_blocked(device):
return True
# Per-zone painting additionally needs the onboard effect on Static.
return name == "headset_per_zone_lighting" and _cluster_effect_blocks_perzone(device)
return False
def _apply_rgb_gates(device):
"""Grey out RGB settings whose prerequisites aren't met. Visual-only:
leaves persister _sensitive flags (user lock-icon opt-ins) intact.
- rgb_zone_* and rgb_idle_*/rgb_sleep_timeout need LED Control = Solaar
(rgb_control == 3).
- per-key-lighting needs LED Control = Solaar AND every zone effect on
Static (0x01), because non-Static zone animations mask per-key writes.
"""
for s in getattr(device, "settings", []) or []:
name = s.name
if (
name in _SW_CONTROL_DEPENDENT_NAMES
or any(name.startswith(p) for p in _SW_CONTROL_DEPENDENT_PREFIXES)
or name == "per-key-lighting"
or name in _HEADSET_LED_DEPENDENT_NAMES
):
_set_row_sensitive(device, name, not _gate_blocks(device, name))
def _change_click(button, sbox):
icon = button.get_children()[0]
icon_name, _ = icon.get_icon_name()
@ -966,37 +644,6 @@ def _change_click(button, sbox):
_write_async(setting, persisted, sbox)
else:
_read_async(setting, True, sbox, bool(sbox.setting._device.online), sbox._control.get_sensitive())
elif new_allowed == settings.SENSITIVITY_IGNORE and sbox.setting.name == "per-key-lighting":
# User just opted out of per-key lighting. The firmware effect engine
# is currently in its OOR "direct mode" slot showing the per-key buffer
# (entered when the prep sequence wrote effectIdx=numEffects via
# SetEffectByIndex on 0x8071). Writing a regular in-range effectIdx
# with persist=1 displaces that slot and the saved zone effect becomes
# the visible layer again. See LOGITECH_HIDPP2_PROTOCOL.md
# "Per-key prep sequence" and 0x8071 SetEffectByIndex persist=1
# requirement.
device = sbox.setting._device
for s in device.settings:
if s.name.startswith("rgb_zone_") and s._value is not None:
_write_async(s, s._value, None)
break # one zone-effect write is enough to flip the engine
if sbox.setting.name.startswith("rgb_zone_"):
# Toggling zone-effect sensitivity changes the effective base color
# for per-key unset cells (zone color ↔ black). When per-key is
# opted-in, repaint it so the unset cells pick up the new base.
from logitech_receiver import rgb_power
device = sbox.setting._device
perkey, has_paint = rgb_power.perkey_has_paint(device)
if has_paint:
_write_async(perkey, perkey._value, None)
# The lock icon on rgb_control, any zone, per-key, or headset_led_control
# can change whether a dependent row is functional — re-evaluate the gate.
name = sbox.setting.name
if name in ("rgb_control", "per-key-lighting", "headset_led_control", "headset-onboard-effect") or name.startswith(
"rgb_zone_"
):
_apply_rgb_gates(sbox.setting._device)
return True
@ -1008,8 +655,6 @@ def _change_icon(allowed, icon):
def _create_sbox(s, _device):
if not s.display:
return
sbox = Gtk.HBox(homogeneous=False, spacing=6)
sbox.setting = s
sbox.kind = s.kind
@ -1033,24 +678,7 @@ def _create_sbox(s, _device):
change.set_sensitive(True)
change.connect(GtkSignal.CLICKED.value, _change_click, sbox)
editor_path = getattr(s, "editor_class", None)
if editor_path:
try:
mod_name, _sep, cls_name = editor_path.partition(":")
import importlib
mod = importlib.import_module(mod_name)
cls = getattr(mod, cls_name)
control = cls(sbox)
except Exception as e:
logger.warning("setting %s editor_class %r failed (%s); falling back to default", s.name, editor_path, repr(e))
control = None
else:
control = None
if control is not None:
pass
elif s.kind == settings.Kind.TOGGLE:
if s.kind == settings.Kind.TOGGLE:
control = ToggleControl(sbox)
elif s.kind == settings.Kind.RANGE:
control = SliderControl(sbox)
@ -1064,8 +692,6 @@ def _create_sbox(s, _device):
control = MultipleRangeControl(sbox, change)
elif s.kind == settings.Kind.PACKED_RANGE:
control = PackedRangeControl(sbox, change)
elif s.kind == settings.Kind.GRAPHIC_EQ:
control = GraphicEQControl(sbox, change)
elif s.kind == settings.Kind.HETERO:
control = HeteroKeyControl(sbox, change)
else:
@ -1084,10 +710,8 @@ def _create_sbox(s, _device):
def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=False):
sbox._spinner.stop()
sensitive = sbox._change_icon._allowed if sensitive is None else sensitive
name = sbox.setting.name
can_function = not _gate_blocks(sbox.setting._device, name)
if value is None and not null_okay:
sbox._control.set_sensitive(sensitive is True and can_function)
sbox._control.set_sensitive(sensitive is True)
_change_icon(sensitive, sbox._change_icon)
sbox._failed.set_visible(is_online)
return
@ -1097,12 +721,8 @@ def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=
sbox._control.set_value(value)
except TypeError as e:
logger.warning("%s: error setting control value (%s): %s", sbox.setting.name, sbox.setting._device, repr(e))
sbox._control.set_sensitive(sensitive is True and can_function)
sbox._control.set_sensitive(sensitive is True)
_change_icon(sensitive, sbox._change_icon)
# rgb_control / rgb_zone_* gate per-key; headset_led_control and the
# headset-onboard-effect gate the per-zone row — re-evaluate on a change.
if name in ("rgb_control", "headset_led_control", "headset-onboard-effect") or name.startswith("rgb_zone_"):
_apply_rgb_gates(sbox.setting._device)
def _disable_listbox_highlight_bg(lb):
@ -1162,7 +782,6 @@ def update(device, is_online=None):
sensitive = device.persister.get_sensitivity(s.name) if device.persister else True
_read_async(s, False, sbox, is_online, sensitive)
_apply_rgb_gates(device)
_box.set_visible(True)

View File

@ -42,7 +42,6 @@ from logitech_receiver.common import UnsortedNamedInts
from logitech_receiver.settings import Kind
from logitech_receiver.settings import Setting
from logitech_receiver.settings_templates import SETTINGS
from logitech_receiver.settings_validator import Range
from solaar.i18n import _
from solaar.ui import rule_actions
@ -1676,15 +1675,6 @@ class _SettingWithValueUI:
if kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE):
self.value_field.make_toggle()
elif kind in (Kind.CHOICE, Kind.MAP_CHOICE):
# Open-value-space MAP_CHOICE settings (per-key RGB) have a Range
# rather than a NamedInts value list — there's no meaningful value
# picker to render in the rule editor, so fall through to unsupported.
if kind == Kind.MAP_CHOICE and device_setting:
val = device_setting._validator
choices = getattr(val, "choices", None)
if isinstance(choices, dict) and any(isinstance(v, Range) for v in choices.values()):
self.value_field.make_unsupported()
return
all_values, extra = self._all_choices(device_setting or setting_name)
self.value_field.make_choice(all_values, extra)
supported_values = None

View File

@ -29,16 +29,16 @@ TRAY_OKAY = "solaar"
TRAY_ATTENTION = "solaar-attention"
_default_theme = None
_has_level_icons = False
_has_padded_level_icons = False
def _init_icon_paths():
global _default_theme, _has_level_icons, _has_padded_level_icons
global _default_theme
if _default_theme:
return
_default_theme = Gtk.IconTheme.get_default()
logger.debug("icon theme paths: %s", _default_theme.get_search_path())
if gtk.battery_icons_style == "symbolic":
global TRAY_OKAY
TRAY_OKAY = TRAY_INIT # use monochrome tray icon
@ -49,10 +49,6 @@ def _init_icon_paths():
if not _default_theme.has_icon("battery-good"):
logger.warning("failed to detect icons")
gtk.battery_icons_style = "solaar"
suffix = "-symbolic" if gtk.battery_icons_style == "symbolic" else ""
_has_level_icons = _default_theme.has_icon(f"battery-level-50{suffix}")
_has_padded_level_icons = not _has_level_icons and _default_theme.has_icon(f"battery-050{suffix}")
logger.debug("battery level icons available: %s (padded scheme: %s)", _has_level_icons, _has_padded_level_icons)
def battery(level=None, charging=False):
@ -72,45 +68,16 @@ def _first_res(val, pairs):
def _battery_icon_name(level, charging):
_init_icon_paths()
suffix = "-symbolic" if gtk.battery_icons_style == "symbolic" else ""
if level is None or level < 0:
return f"battery-missing{suffix}"
return "battery-missing" + ("-symbolic" if gtk.battery_icons_style == "symbolic" else "")
rounded = min(100, max(0, round(level / 10) * 10))
# Try precise level icons (battery-level-N or battery-0N0 naming scheme)
if _has_level_icons or _has_padded_level_icons:
if charging and rounded == 100:
charging_str = "-charged"
elif charging:
charging_str = "-charging"
else:
charging_str = ""
if _has_level_icons:
icon_name = f"battery-level-{rounded}{charging_str}{suffix}"
else:
icon_name = f"battery-{rounded:03}{charging_str}{suffix}"
if _default_theme.has_icon(icon_name):
logger.debug("battery level icon for %s:%s = %s", level, charging, icon_name)
return icon_name
# Fall back to semantic names
level_name = _first_res(level, ((90, "full"), (60, "good"), (20, "low"), (5, "caution"), (0, "empty")))
if level_name:
if charging:
charging_str = "-charging"
else:
charging_str = ""
icon_name = f"battery-{level_name}{charging_str}{suffix}"
if _default_theme.has_icon(icon_name):
logger.debug("battery semantic icon for %s:%s = %s", level, charging, icon_name)
return icon_name
# Last resort: plain battery icon
icon_name = f"battery{suffix}"
logger.debug("battery generic icon for %s:%s = %s", level, charging, icon_name)
return icon_name
level_name = _first_res(level, ((90, "full"), (30, "good"), (20, "low"), (5, "caution"), (0, "empty")))
return "battery-%s%s%s" % (
level_name,
"-charging" if charging else "",
"-symbolic" if gtk.battery_icons_style == "symbolic" else "",
)
def lux(level=None):

View File

@ -1,22 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from .layout import Cell
from .layout import Layout
from .layouts import layout_for
from .layouts import register_layout
__all__ = ("Cell", "Layout", "layout_for", "register_layout")

View File

@ -1,133 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Theme-aware loader for Solaar's per-key UI icons.
Loads SVG icons from ``share/solaar/icons/`` and recolors them at load
time to match the active GTK theme's text foreground, by substituting
``currentColor`` in the SVG before passing it to GdkPixbuf. GTK's stock
symbolic loader is bypassed because it only recolors specific palette
fill stand-ins and ignores ``stroke="currentColor"``.
"""
from __future__ import annotations
import logging
from pathlib import Path
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import GdkPixbuf # NOQA: E402
from gi.repository import Gio # NOQA: E402
from gi.repository import Gtk # NOQA: E402
logger = logging.getLogger(__name__)
ICON_PIXEL_SIZE = 22
_search_path_added = False
def ensure_icon_path() -> None:
"""Register share/solaar/icons with the default GtkIconTheme so our
custom symbolic tool icons resolve by name. Idempotent."""
global _search_path_added
if _search_path_added:
return
theme = Gtk.IconTheme.get_default()
existing = set(theme.get_search_path() or [])
# _icons.py: lib/solaar/ui/perkey/_icons.py -> parents[4] = repo root
candidates = [
Path(__file__).resolve().parents[4] / "share" / "solaar" / "icons",
]
for c in candidates:
if c.is_dir() and str(c) not in existing:
theme.append_search_path(str(c))
_search_path_added = True
def themed_icon_image(icon_name: str, style_widget: Gtk.Widget) -> Gtk.Image | None:
"""Load a Solaar tool icon and recolor it to match the given widget's
text foreground color, so the icons follow the active GTK theme
(light / dark / custom). Returns None if the icon can't be loaded.
"""
ensure_icon_path()
theme = Gtk.IconTheme.get_default()
icon_info = theme.lookup_icon(icon_name, ICON_PIXEL_SIZE, Gtk.IconLookupFlags.FORCE_SIZE)
if icon_info is None:
return None
path = icon_info.get_filename()
if not path:
return None
fg = style_widget.get_style_context().get_color(Gtk.StateFlags.NORMAL)
color = f"#{int(fg.red * 255):02x}{int(fg.green * 255):02x}{int(fg.blue * 255):02x}"
try:
with open(path, "r", encoding="utf-8") as f:
svg = f.read()
svg = svg.replace("currentColor", color)
stream = Gio.MemoryInputStream.new_from_data(svg.encode("utf-8"))
pixbuf = GdkPixbuf.Pixbuf.new_from_stream_at_scale(stream, ICON_PIXEL_SIZE, ICON_PIXEL_SIZE, True)
return Gtk.Image.new_from_pixbuf(pixbuf)
except Exception as e:
logger.debug("recolor failed for %s: %s", icon_name, e)
return None
def _fg_color_key(widget: Gtk.Widget) -> tuple[float, float, float]:
fg = widget.get_style_context().get_color(Gtk.StateFlags.NORMAL)
return (round(fg.red, 3), round(fg.green, 3), round(fg.blue, 3))
def attach_themed_icon(button: Gtk.Container, icon_name: str) -> int | None:
"""Add a themed icon to `button` and re-render it whenever the active
GTK theme changes the button's foreground color. Returns the
style-updated signal handler ID, or None if the icon couldn't be
loaded (in which case the button is left unchanged so the caller can
fall back to a text label).
Listening to the button's own ``style-updated`` signal — instead of
``Gtk.Settings notify::gtk-theme-name`` means we read the
foreground color *after* GTK has re-resolved CSS for the new theme.
Subscribing to the Settings notify fires too early; it returns the
stale (pre-switch) color and produces icons that all settle on the
previous theme's tone. We guard the rebuild with a per-button color
key so unrelated style updates (hover, focus, active) don't trigger
needless re-renders.
"""
image = themed_icon_image(icon_name, button)
if image is None:
return None
button.add(image)
image.show()
state = {"color_key": _fg_color_key(button)}
def _refresh(_widget) -> None:
new_key = _fg_color_key(button)
if new_key == state["color_key"]:
return
state["color_key"] = new_key
new_image = themed_icon_image(icon_name, button)
if new_image is None:
return
old = button.get_child()
if old is not None:
button.remove(old)
button.add(new_image)
new_image.show()
return button.connect("style-updated", _refresh)

View File

@ -1,63 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Bind a Layout to a sink's reported zone list.
Cells whose `zone_id` the device reports are marked bound. Cells whose zone
the device does not report stay disabled (greyed). Device-reported zones not
covered by any cell get synthesized as strip cells using the sink's labels —
this catches G-keys, logo, media keys and any device-specific extras.
"""
from __future__ import annotations
from collections.abc import Callable
from .layout import BoundCell
from .layout import BoundLayout
from .layout import Cell
from .layout import Layout
def bind(layout: Layout, zones: list[int], label_for: Callable[[int], str]) -> BoundLayout:
reported = set(zones)
claimed: set[int] = set()
matrix: list[BoundCell] = []
strip: list[BoundCell] = []
for c in layout.matrix_cells():
bound = c.zone_id in reported
if bound:
claimed.add(c.zone_id)
matrix.append(BoundCell(cell=c, bound=bound))
for c in layout.strip_cells():
bound = c.zone_id in reported
if bound:
claimed.add(c.zone_id)
strip.append(BoundCell(cell=c, bound=bound))
unmapped_all = tuple(z for z in zones if z not in claimed)
# Filter unmapped zones through the layout's curated allowlist. Without
# this, firmware-reported phantoms (G515 reports 47, 97, 99-103, 254)
# would surface as paintable strip cells that don't address any LED.
if layout.extra_zones is None:
showable = unmapped_all
else:
showable = tuple(z for z in unmapped_all if z in layout.extra_zones)
next_col = max((bc.cell.col for bc in strip), default=-1) + 1
for z in showable:
synth = Cell(zone_id=z, row=0, col=next_col, group="strip", label=label_for(z))
strip.append(BoundCell(cell=synth, bound=True))
next_col += 1
return BoundLayout(matrix=tuple(matrix), strip=tuple(strip), unmapped=unmapped_all)

View File

@ -1,452 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Cairo-rendered keyboard canvas. Renders a BoundLayout as colored rectangles
and dispatches paint events through a configurable Tool to the editor.
"""
from __future__ import annotations
import logging
from enum import Enum
from typing import Callable
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk # NOQA: E402
from gi.repository import GObject # NOQA: E402
from gi.repository import Gtk # NOQA: E402
from .layout import BoundCell # NOQA: E402
from .layout import BoundLayout # NOQA: E402
from .layout import Cell # NOQA: E402
from .tools import TOOLS # NOQA: E402
from .tools import ToolContext # NOQA: E402
logger = logging.getLogger(__name__)
class GtkSignal(Enum):
DRAW = "draw"
BUTTON_PRESS_EVENT = "button-press-event"
BUTTON_RELEASE_EVENT = "button-release-event"
MOTION_NOTIFY_EVENT = "motion-notify-event"
LEAVE_NOTIFY_EVENT = "leave-notify-event"
CELL_PX = 36
GUTTER_PX = 4
STRIP_GAP_PX = 16
PADDING_PX = 8
class KeyboardCanvas(Gtk.DrawingArea):
__gsignals__ = {
# Emitted on stroke release. delta is dict[zone_id, color].
"paint": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
}
def __init__(self) -> None:
super().__init__()
self._bound: BoundLayout | None = None
self._colors: dict[int, int] = {} # zone_id -> packed RGB or -1 (unset)
self._active_color: int = 0xFF0000
self._gradient_colors_source: Callable[[], tuple[int, int]] | None = None
self._zone_base_color: int | None = None
self._tool_name: str = "single"
self._press_cell: BoundCell | None = None
self._motion_cell: BoundCell | None = None
self._brush_path: list[int] = []
self._dragging: bool = False
self.set_can_focus(True)
self.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.LEAVE_NOTIFY_MASK
)
self.connect(GtkSignal.DRAW.value, self._on_draw)
self.connect(GtkSignal.BUTTON_PRESS_EVENT.value, self._on_press)
self.connect(GtkSignal.BUTTON_RELEASE_EVENT.value, self._on_release)
self.connect(GtkSignal.MOTION_NOTIFY_EVENT.value, self._on_motion)
self.connect(GtkSignal.LEAVE_NOTIFY_EVENT.value, self._on_leave)
# ---- public API ----
def set_layout(self, bound: BoundLayout) -> None:
self._bound = bound
self._update_size()
self.queue_draw()
def set_colors(self, colors: dict[int, int]) -> None:
self._colors = dict(colors)
self.queue_draw()
def update_colors(self, deltas: dict[int, int]) -> None:
self._colors.update(deltas)
self.queue_draw()
def set_active_color(self, color: int) -> None:
self._active_color = int(color)
def set_gradient_colors_source(self, source: Callable[[], tuple[int, int]] | None) -> None:
self._gradient_colors_source = source
def set_zone_base_color(self, color: int | None) -> None:
self._zone_base_color = None if color is None else int(color)
self.queue_draw()
def set_tool(self, name: str) -> None:
if name in TOOLS:
self._tool_name = name
# ---- size / hit-test ----
def _matrix_size(self) -> tuple[int, int]:
if not self._bound:
return 0, 0
max_col = 0
max_row = 0
for bc in self._bound.matrix:
c = bc.cell
max_col = max(max_col, c.col + int(round(c.width)))
max_row = max(max_row, c.row + int(round(c.height)))
return max_row, max_col
def _strip_size(self) -> int:
if not self._bound:
return 0
return len(self._bound.strip)
def _update_size(self) -> None:
rows, cols = self._matrix_size()
strip_n = self._strip_size()
w = PADDING_PX * 2 + cols * CELL_PX + max(0, cols - 1) * GUTTER_PX
matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
strip_h = (CELL_PX + STRIP_GAP_PX) if strip_n else 0
h = PADDING_PX * 2 + matrix_h + strip_h
# widen if strip is wider than matrix
if strip_n:
sw = PADDING_PX * 2 + strip_n * CELL_PX + max(0, strip_n - 1) * GUTTER_PX
w = max(w, sw)
self.set_size_request(w, h)
def _cell_rect(self, bc: BoundCell) -> tuple[float, float, float, float]:
c = bc.cell
if self._bound is not None and bc in self._bound.strip:
# strip cells: laid out in a flat row beneath the matrix
rows, _cols = self._matrix_size()
matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
strip_idx = self._bound.strip.index(bc)
x = PADDING_PX + strip_idx * (CELL_PX + GUTTER_PX)
y = PADDING_PX + matrix_h + STRIP_GAP_PX
return (x, y, CELL_PX, CELL_PX)
x = PADDING_PX + c.col * (CELL_PX + GUTTER_PX)
y = PADDING_PX + c.row * (CELL_PX + GUTTER_PX)
w = c.width * CELL_PX + max(0.0, c.width - 1.0) * GUTTER_PX
h = c.height * CELL_PX + max(0.0, c.height - 1.0) * GUTTER_PX
return (x, y, w, h)
def _cell_at(self, x: float, y: float) -> BoundCell | None:
if not self._bound:
return None
for bc in list(self._bound.matrix) + list(self._bound.strip):
cx, cy, cw, ch = self._cell_rect(bc)
if cx <= x < cx + cw and cy <= y < cy + ch:
return bc
# Phantom anchor for gaps in the matrix grid — gives rect/gradient
# drags a valid endpoint where no real cell exists.
rows, cols = self._matrix_size()
matrix_w = cols * CELL_PX + max(0, cols - 1) * GUTTER_PX
matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
if PADDING_PX <= x < PADDING_PX + matrix_w and PADDING_PX <= y < PADDING_PX + matrix_h:
col = int((x - PADDING_PX) // (CELL_PX + GUTTER_PX))
row = int((y - PADDING_PX) // (CELL_PX + GUTTER_PX))
if 0 <= col < cols and 0 <= row < rows:
return BoundCell(cell=Cell(zone_id=-1, row=row, col=col), bound=False)
return None
# ---- draw ----
def _on_draw(self, _widget, cr) -> bool:
if not self._bound:
return False
for bc in self._bound.matrix:
self._draw_cell(cr, bc)
for bc in self._bound.strip:
self._draw_cell(cr, bc)
if self._dragging and self._press_cell and self._motion_cell:
tool = TOOLS.get(self._tool_name)
if tool is not None:
if tool.overlay_shape == "rect":
self._draw_rect_overlay(cr, self._press_cell, self._motion_cell)
elif tool.overlay_shape == "line":
self._draw_line_overlay(cr, self._press_cell, self._motion_cell)
return False
def _draw_cell(self, cr, bc: BoundCell) -> None:
x, y, w, h = self._cell_rect(bc)
color = self._colors.get(bc.cell.zone_id, -1)
# background
if not bc.bound:
cr.set_source_rgba(0.18, 0.18, 0.20, 1.0)
elif color is None or color < 0:
self._fill_checker(cr, x, y, w, h)
cr.set_source_rgba(0, 0, 0, 0) # no overlay fill
else:
r = ((color >> 16) & 0xFF) / 255.0
g = ((color >> 8) & 0xFF) / 255.0
b = (color & 0xFF) / 255.0
cr.set_source_rgba(r, g, b, 1.0)
if bc.bound and (color is not None and color >= 0):
self._round_rect(cr, x, y, w, h, 4)
cr.fill_preserve()
elif not bc.bound:
self._round_rect(cr, x, y, w, h, 4)
cr.fill_preserve()
else:
self._round_rect(cr, x, y, w, h, 4)
# border
cr.set_source_rgba(0, 0, 0, 0.55)
cr.set_line_width(1.0)
cr.stroke()
# label
label = bc.cell.label or str(bc.cell.zone_id)
cr.set_source_rgba(*self._label_color(color, bc.bound))
cr.select_font_face("Sans")
cr.set_font_size(11.0 if len(label) <= 3 else 9.0)
try:
extents = cr.text_extents(label)
tx = x + (w - extents.width) / 2 - extents.x_bearing
ty = y + (h + extents.height) / 2 - extents.y_bearing - extents.height
cr.move_to(tx, ty)
cr.show_text(label)
except Exception as e:
logger.debug("text rendering failed for %r: %s", label, e)
def _fill_checker(self, cr, x, y, w, h) -> None:
# Diagonal hash for "no change" cells. The background is the zone
# base color (what these cells actually display on the keyboard).
# Stripes alternate darker / lighter than base in equal measure so
# the cell's perceived average stays at the base color, instead of
# being uniformly biased toward black or white as a single-overlay
# stripe would.
cr.save()
self._round_rect(cr, x, y, w, h, 4)
cr.clip()
base = self._zone_base_color
if base is not None and base >= 0:
r = ((base >> 16) & 0xFF) / 255.0
g = ((base >> 8) & 0xFF) / 255.0
b = (base & 0xFF) / 255.0
cr.set_source_rgba(r, g, b, 1.0)
cr.rectangle(x, y, w, h)
cr.fill()
# Per-channel ±offset. When a channel is too close to 0 or 1 to
# fit the full offset, halve the offset on the constrained side
# (per spec: average drifts at the limits, but stays centered on
# base elsewhere).
offset = 0.22
def _shift(v: float) -> tuple[float, float]:
down_off = offset if (v - offset) >= 0.0 else offset / 2.0
up_off = offset if (v + offset) <= 1.0 else offset / 2.0
return max(0.0, v - down_off), min(1.0, v + up_off)
rd, ru = _shift(r)
gd, gu = _shift(g)
bd, bu = _shift(b)
# Interleave darker and lighter stripes (period = step per set,
# other-color set offset by step/2). Equal coverage of the two
# colors keeps the perceived average at base.
cr.set_line_width(1.5)
step = 6
half = step // 2
d_max = int(w + h)
cr.set_source_rgba(rd, gd, bd, 1.0)
d = -int(h)
while d <= d_max:
cr.move_to(x + d, y + h)
cr.line_to(x + d + h, y)
cr.stroke()
d += step
cr.set_source_rgba(ru, gu, bu, 1.0)
d = -int(h) + half
while d <= d_max:
cr.move_to(x + d, y + h)
cr.line_to(x + d + h, y)
cr.stroke()
d += step
else:
# No zone base color known — fall back to a neutral dark bg with
# medium-gray stripes; "average = base" doesn't apply since there
# is no expected color to preserve.
cr.set_source_rgba(0.30, 0.30, 0.32, 1.0)
cr.rectangle(x, y, w, h)
cr.fill()
cr.set_source_rgba(0.55, 0.55, 0.60, 1.0)
cr.set_line_width(1.5)
step = 5
d_max = int(w + h)
d = -int(h)
while d <= d_max:
cr.move_to(x + d, y + h)
cr.line_to(x + d + h, y)
cr.stroke()
d += step
cr.restore()
def _round_rect(self, cr, x, y, w, h, r) -> None:
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -1.5708, 0)
cr.arc(x + w - r, y + h - r, r, 0, 1.5708)
cr.arc(x + r, y + h - r, r, 1.5708, 3.1416)
cr.arc(x + r, y + r, r, 3.1416, 4.7124)
cr.close_path()
def _label_color(self, color: int, bound: bool) -> tuple[float, float, float, float]:
if not bound:
return (0.50, 0.50, 0.52, 1.0)
if color is None or color < 0:
return (0.85, 0.85, 0.88, 1.0)
# luminance heuristic
r = ((color >> 16) & 0xFF) / 255.0
g = ((color >> 8) & 0xFF) / 255.0
b = (color & 0xFF) / 255.0
lum = 0.299 * r + 0.587 * g + 0.114 * b
return (0, 0, 0, 1.0) if lum > 0.55 else (1, 1, 1, 1.0)
def _draw_rect_overlay(self, cr, a: BoundCell, b: BoundCell) -> None:
ax, ay, aw, ah = self._cell_rect(a)
bx, by, bw, bh = self._cell_rect(b)
x0 = min(ax, bx) - 2
y0 = min(ay, by) - 2
x1 = max(ax + aw, bx + bw) + 2
y1 = max(ay + ah, by + bh) + 2
cr.set_source_rgba(0.30, 0.65, 1.0, 0.85)
cr.set_line_width(1.5)
cr.set_dash([4.0, 3.0])
cr.rectangle(x0, y0, x1 - x0, y1 - y0)
cr.stroke()
cr.set_dash([])
def _draw_line_overlay(self, cr, a: BoundCell, b: BoundCell) -> None:
ax, ay, aw, ah = self._cell_rect(a)
bx, by, bw, bh = self._cell_rect(b)
ax_c, ay_c = ax + aw / 2, ay + ah / 2
bx_c, by_c = bx + bw / 2, by + bh / 2
cr.set_source_rgba(0.30, 0.65, 1.0, 0.95)
cr.set_line_width(2.0)
cr.set_dash([5.0, 3.0])
cr.move_to(ax_c, ay_c)
cr.line_to(bx_c, by_c)
cr.stroke()
cr.set_dash([])
# endpoint dots — solid so the anchors read clearly
for cx, cy in ((ax_c, ay_c), (bx_c, by_c)):
cr.arc(cx, cy, 4.0, 0, 6.283)
cr.fill()
# ---- input ----
def _on_press(self, _w, event: Gdk.EventButton) -> bool:
if event.button != 1:
return False
bc = self._cell_at(event.x, event.y)
if bc is None:
return False
tool = TOOLS.get(self._tool_name)
# Endpoint tools (rect/gradient) anchor on cell centers regardless of
# bind state; brush/bucket need a real key to paint/flood.
if not (tool and tool.overlay_shape) and not bc.bound:
return False
self._press_cell = bc
self._motion_cell = bc
self._dragging = True
self._brush_path = [bc.cell.zone_id]
if tool is not None and tool.is_brush:
self.update_colors({bc.cell.zone_id: self._active_color})
else:
self.queue_draw()
return True
def _on_motion(self, _w, event: Gdk.EventMotion) -> bool:
if not self._dragging:
return False
bc = self._cell_at(event.x, event.y)
if bc is None:
return False
tool = TOOLS.get(self._tool_name)
if not (tool and tool.overlay_shape) and not bc.bound:
return False
if bc is self._motion_cell:
return False
self._motion_cell = bc
if tool is not None and tool.is_brush:
if bc.cell.zone_id not in self._brush_path:
self._brush_path.append(bc.cell.zone_id)
self.update_colors({bc.cell.zone_id: self._active_color})
else:
self.queue_draw()
return True
def _on_release(self, _w, event: Gdk.EventButton) -> bool:
if event.button != 1 or not self._dragging:
return False
self._dragging = False
if self._press_cell is None:
return False
if self._bound:
bound_zones = {bc.cell.zone_id: bc for bc in list(self._bound.matrix) + list(self._bound.strip)}
strip_zones = frozenset(bc.cell.zone_id for bc in self._bound.strip)
else:
bound_zones = {}
strip_zones = frozenset()
if self._tool_name == "gradient" and self._gradient_colors_source is not None:
grad_active, grad_previous = self._gradient_colors_source()
ctx = ToolContext(
active_color=int(grad_active),
last_color=int(grad_previous),
cells_by_zone=bound_zones,
strip_zones=strip_zones,
current_colors=dict(self._colors),
)
else:
ctx = ToolContext(
active_color=self._active_color,
last_color=self._active_color,
cells_by_zone=bound_zones,
strip_zones=strip_zones,
current_colors=dict(self._colors),
)
tool = TOOLS.get(self._tool_name)
delta: dict[int, int] = {}
if tool is not None:
delta = tool.compute(self._press_cell, self._motion_cell, list(self._brush_path), ctx)
self._press_cell = None
self._motion_cell = None
self._brush_path = []
self.queue_draw()
if delta:
self.update_colors(delta)
self.emit("paint", delta)
return True
def _on_leave(self, _w, _event) -> bool:
# don't cancel drags on leave; let the user re-enter
return False

View File

@ -1,264 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Inline placeholder control replacing MapChoiceControl for opted-in settings.
Renders a summary line + button. Click opens the per-key editor dialog,
backed by a SettingSink adapter that bridges the editor protocol to the
Solaar Setting object. The editor never touches the Setting directly.
"""
from __future__ import annotations
import logging
from enum import Enum
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # NOQA: E402
from solaar.i18n import _ # NOQA: E402
from . import dialog as dialog_mod # NOQA: E402
from .layouts import layout_for # NOQA: E402
logger = logging.getLogger(__name__)
class GtkSignal(Enum):
CLICKED = "clicked"
# Sentinel matching special_keys.COLORSPLUS["No change"].
NO_CHANGE = -1
class _SettingSink:
"""Bridge between a Solaar Setting and the editor's PerKeyColorSink protocol."""
def __init__(self, setting, sbox) -> None:
self._setting = setting
self._sbox = sbox
self._listeners: list = []
@property
def title(self) -> str:
device = getattr(self._setting, "_device", None)
name = getattr(device, "name", None) or getattr(device, "codename", None) or ""
return name or self._setting.label
@property
def zones(self) -> list[int]:
return [int(k) for k in self._setting.choices]
@property
def current(self) -> dict[int, int]:
return dict(self._setting._value or {})
def label(self, zone: int) -> str:
for k in self._setting.choices:
if int(k) == int(zone):
return str(k)
return f"KEY {zone}"
def write_one(self, zone: int, color: int) -> None:
if self._setting._value is None:
self._setting._value = {}
self._setting._value[int(zone)] = int(color)
# Lazy import to avoid a circular module-load between config_panel and perkey.
from solaar.ui.config_panel import _write_async
_write_async(self._setting, int(color), self._sbox, key=int(zone))
self._notify()
def write_bulk(self, deltas: dict[int, int]) -> None:
if not deltas:
return
if self._setting._value is None:
self._setting._value = {}
merged = dict(self._setting._value)
merged.update({int(k): int(v) for k, v in deltas.items()})
self._setting._value = merged
from solaar.ui.config_panel import _write_async
_write_async(self._setting, merged, self._sbox, key=None)
self._notify()
def subscribe(self, listener):
self._listeners.append(listener)
def unsubscribe() -> None:
# Idempotent: the editor calls this on shutdown, but the listener
# may already be gone if the sink itself was torn down first.
try:
self._listeners.remove(listener)
except ValueError:
pass
return unsubscribe
def _palette_key(self) -> str:
return f"_palette:{self._setting.name}"
def palette_state(self) -> tuple[int, int] | None:
device = getattr(self._setting, "_device", None)
persister = getattr(device, "persister", None)
if persister is None:
return None
entry = persister.get(self._palette_key())
if not isinstance(entry, dict):
return None
active = entry.get("active")
previous = entry.get("previous", active)
if not isinstance(active, int) or not isinstance(previous, int):
return None
return (int(active), int(previous))
def set_palette_state(self, active: int, previous: int) -> None:
device = getattr(self._setting, "_device", None)
persister = getattr(device, "persister", None)
if persister is None:
return
persister[self._palette_key()] = {"active": int(active), "previous": int(previous)}
def zone_base_color(self) -> int | None:
"""Color used to render per-key unset cells in the editor. Matches
rgb_power.effective_zone_base_color: black when zone is ignored,
the saved zone color otherwise."""
device = getattr(self._setting, "_device", None)
if device is None:
return None
from logitech_receiver import rgb_power
return int(rgb_power.effective_zone_base_color(device))
def _notify(self) -> None:
snapshot = self.current
for cb in list(self._listeners):
try:
cb(snapshot)
except Exception as e:
logger.warning("perkey listener raised: %s", e)
def push_external_value(self, value) -> None:
"""Called from the inline control when the framework reports a value change."""
if isinstance(value, dict):
self._notify()
class PerKeyControl(Gtk.Box):
"""Replaces MapChoiceControl for per-key color settings.
Ducktypes the four `Control` methods (`set_sensitive`, `set_value`,
`get_value`, `layout`) used by `_create_sbox` / `_update_setting_item`.
"""
def __init__(self, sbox) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self.sbox = sbox
self._setting = sbox.setting
self._value: dict | None = None
self._sink = _SettingSink(self._setting, sbox)
self._summary = Gtk.Label(label=_("(not loaded)"))
self._summary.set_xalign(0.0)
self.pack_start(self._summary, True, True, 0)
self._open_btn = Gtk.Button(label=_("Open editor…"))
self._open_btn.set_tooltip_text(_("Paint key colors on a keyboard layout"))
self._open_btn.connect(GtkSignal.CLICKED.value, self._on_open)
self.pack_end(self._open_btn, False, False, 0)
# ---- Control protocol ----
def set_sensitive(self, sensitive: bool) -> None:
super().set_sensitive(bool(sensitive))
self._open_btn.set_sensitive(bool(sensitive))
def set_value(self, value) -> None:
if value is None:
return
if not isinstance(value, dict):
return
# _write_async wraps single-key writes as `{key: written_value}` so
# MapChoiceControl can update one combo cell. We need to keep the
# full picture for the summary count, so merge instead of replace
# when a partial dict comes in.
existing = self._setting._value if isinstance(self._setting._value, dict) else None
if existing and len(value) < len(existing):
merged = dict(existing)
merged.update(value)
self._value = merged
else:
self._value = value
self._sink.push_external_value(self._value)
self._refresh_summary()
def get_value(self):
return self._value
def layout(self, sbox, label, change, spinner, failed) -> bool:
# Match the standard Control packing order so our button sits where
# every other setting's widget sits, just left of spinner/change-icon.
sbox.pack_start(label, False, False, 0)
sbox.pack_end(change, False, False, 0)
sbox.pack_end(self, False, False, 0)
sbox.pack_end(spinner, False, False, 0)
sbox.pack_end(failed, False, False, 0)
return self
# ---- internal ----
def _refresh_summary(self) -> None:
if not isinstance(self._value, dict):
self._summary.set_text(_("(no zones)"))
return
total = len(self._value)
painted = sum(1 for v in self._value.values() if isinstance(v, int) and v != NO_CHANGE and v >= 0)
self._summary.set_text(_("{painted} / {total} keys painted").format(painted=painted, total=total))
def _on_open(self, _btn) -> None:
feature = getattr(self._setting, "feature", None)
feature_int = int(feature) if feature is not None else 0
device = getattr(self._setting, "_device", None)
kind_obj = getattr(device, "kind", None)
kind_str = str(kind_obj).lower() if kind_obj is not None else ""
hint = {
"kind": kind_str if kind_str else None,
"wpid": getattr(device, "wpid", None),
"codename": getattr(device, "codename", None),
"name": getattr(device, "name", None),
"keyboard_layout": getattr(device, "keyboard_layout", None),
"zones": list(self._sink.zones),
"zone_count": len(self._sink.zones),
}
layout = layout_for(feature_int, hint)
# Stable per-device key so the same physical device on USB and on
# the receiver shares a single dialog. unitId is read from the
# device firmware (via DeviceInformation) and is the same across
# transports; serial is per-pairing-slot. id(self._sink) is a
# last-resort fallback that should never be hit in practice.
key = (
getattr(device, "unitId", None)
or getattr(device, "serial", None)
or getattr(device, "hid_serial", None)
or getattr(device, "codename", None)
or id(self._sink)
)
dlg = dialog_mod.get_dialog(key)
dlg.present(self._sink, layout)

View File

@ -1,119 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Per-device dialogs hosting a PerKeyEditor.
One dialog instance is kept per device key (firmware unit-id, falling
back to other stable identifiers see ``get_dialog``). The same
physical device on different transports (receiver vs direct USB) shares
a key so it doesn't open two windows.
"""
from __future__ import annotations
from enum import Enum
from typing import Hashable
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # NOQA: E402
from solaar.i18n import _ # NOQA: E402
from .editor import PerKeyEditor # NOQA: E402
from .layout import Layout # NOQA: E402
from .protocol import PerKeyColorSink # NOQA: E402
class GtkSignal(Enum):
DELETE_EVENT = "delete-event"
_dialogs: dict[Hashable, "PerKeyEditorDialog"] = {}
class PerKeyEditorDialog:
def __init__(self, key: Hashable) -> None:
self._key = key
self._window: Gtk.Window | None = None
self._wrapper: Gtk.Box | None = None
self._editor: PerKeyEditor | None = None
self._sink: PerKeyColorSink | None = None
def _on_delete(self, _w, _e) -> bool:
self._destroy()
_dialogs.pop(self._key, None)
return True
def _destroy(self) -> None:
if self._editor is not None:
self._editor.shutdown()
self._editor = None
if self._window is not None:
self._window.destroy()
self._window = None
self._wrapper = None
self._sink = None
def present(self, sink: PerKeyColorSink, layout: Layout | None) -> None:
# Re-opening for the same sink while the window is already open:
# just raise it (no rebuild flicker, preserves any in-progress
# interaction state).
if self._window is not None and self._sink is sink:
self._window.present()
return
# Otherwise build a fresh window. We always recreate rather than
# swap content in place because Gtk.Window.resize() after first
# show is unreliable across X11/Wayland WMs — the WM often keeps
# the original geometry — and a new window picks up the layout's
# natural size cleanly on first show.
self._destroy()
self._sink = sink
self._window = Gtk.Window()
self._window.set_title(_("Per-key Lighting") + "" + sink.title)
self._window.connect(GtkSignal.DELETE_EVENT.value, self._on_delete)
self._wrapper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._wrapper.set_border_width(8)
self._window.add(self._wrapper)
self._editor = PerKeyEditor(sink, layout)
self._wrapper.pack_start(self._editor, True, True, 0)
self._wrapper.show_all()
# Ask GTK what the wrapper actually wants to be — the canvas's
# size_request propagates up through ScrolledWindow + editor VBox
# (toolbar + scrolled canvas) + the wrapper's border, so the
# natural size already accounts for every layout contribution.
_min, nat = self._wrapper.get_preferred_size()
if nat.width > 0 and nat.height > 0:
self._window.resize(nat.width, nat.height)
self._window.present()
def get_dialog(key: Hashable) -> PerKeyEditorDialog:
"""Return the dialog for `key`, creating one if none is open.
`key` should be a stable per-device identifier. The caller (control.py)
builds it from `device.unitId` first that's read from the device
firmware via the DeviceInformation feature and is the same regardless
of whether the device is on a receiver or plugged directly via USB,
so the same physical device doesn't open two windows when its
transport changes.
"""
d = _dialogs.get(key)
if d is None:
d = PerKeyEditorDialog(key)
_dialogs[key] = d
return d

View File

@ -1,206 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Editor widget: combines toolbar + palette + canvas into one VBox.
The editor consumes only the PerKeyColorSink protocol no device imports,
no Setting imports preserving the FE/BE seam.
"""
from __future__ import annotations
import logging
from enum import Enum
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk # NOQA: E402
from solaar.i18n import _ # NOQA: E402
from . import binding # NOQA: E402
from ._icons import attach_themed_icon # NOQA: E402
from .canvas import KeyboardCanvas # NOQA: E402
from .layout import Layout # NOQA: E402
from .palette import GradientSwatch # NOQA: E402
from .palette import Palette # NOQA: E402
from .protocol import PerKeyColorSink # NOQA: E402
logger = logging.getLogger(__name__)
class GtkSignal(Enum):
COLOR_CHANGED = "color-changed"
PAINT = "paint"
TOGGLED = "toggled"
_TOOL_LABELS = {
"single": (_("Brush"), _("Click or drag to paint individual keys")),
"rect": (_("Rect"), _("Drag to select a rectangle of keys, painted on release")),
"bucket": (_("Fill"), _("Flood-fill connected keys of the same color with the active color")),
}
_TOOL_TOOLTIPS = {
"gradient": _("Drag to fade from previous color to active color"),
}
_TOOL_ICON_NAMES = {
"single": "solaar-tool-brush-symbolic",
"rect": "solaar-tool-rect-symbolic",
"bucket": "solaar-tool-bucket-symbolic",
}
class PerKeyEditor(Gtk.Box):
def __init__(self, sink: PerKeyColorSink, layout: Layout | None = None) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self._sink = sink
self._layout = layout
self._unsubscribe = None
# toolbar row
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
self._tool_buttons: dict[str, Gtk.RadioButton] = {}
self._gradient_swatch: GradientSwatch | None = None
first: Gtk.RadioButton | None = None
supported = layout.supported_tools if layout else ("single", "rect", "bucket", "gradient")
for name in supported:
if name == "gradient":
btn = Gtk.RadioButton.new_from_widget(first)
btn.set_mode(False)
self._gradient_swatch = GradientSwatch()
btn.add(self._gradient_swatch)
btn.set_tooltip_text(_TOOL_TOOLTIPS["gradient"])
else:
label, tip = _TOOL_LABELS.get(name, (name, ""))
icon_name = _TOOL_ICON_NAMES.get(name)
btn = Gtk.RadioButton.new_from_widget(first)
btn.set_mode(False) # render as toggle button rather than radio
if icon_name and attach_themed_icon(btn, icon_name) is not None:
btn.set_tooltip_text(tip or label)
btn.get_accessible().set_name(label)
else:
btn.set_label(label)
btn.set_tooltip_text(tip)
btn.connect(GtkSignal.TOGGLED.value, self._on_tool_toggled, name)
if first is None:
first = btn
toolbar.pack_start(btn, False, False, 0)
self._tool_buttons[name] = btn
initial_active, initial_previous = 0xFF0000, 0xFF0000
try:
persisted = sink.palette_state()
except Exception as e:
logger.debug("palette_state read failed: %s", e)
persisted = None
if persisted is not None:
initial_active, initial_previous = persisted
self._palette = Palette(active=initial_active, previous=initial_previous)
self._palette.connect(GtkSignal.COLOR_CHANGED.value, self._on_color_changed)
toolbar.pack_end(self._palette, False, False, 0)
if self._gradient_swatch is not None:
self._gradient_swatch.update(self._palette.get_color(), self._palette.get_last_color())
self.pack_start(toolbar, False, False, 0)
# canvas inside a scrolled window so wide layouts can scroll if the
# window is shrunk below content size. propagate_natural_size lets the
# window auto-fit small layouts (e.g. an 8-LED mouse) without forcing
# an oversized minimum.
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scroll.set_propagate_natural_width(True)
scroll.set_propagate_natural_height(True)
# Inset frame around the keyboard so it reads as a distinct panel
# rather than floating flat against the dialog background.
scroll.set_shadow_type(Gtk.ShadowType.IN)
self._canvas = KeyboardCanvas()
self._canvas.connect(GtkSignal.PAINT.value, self._on_canvas_paint)
scroll.add(self._canvas)
self.pack_start(scroll, True, True, 0)
self._canvas.set_active_color(self._palette.get_color())
if self._gradient_swatch is not None:
self._canvas.set_gradient_colors_source(self._gradient_swatch.get_colors)
try:
base = sink.zone_base_color()
except Exception as e:
logger.debug("zone_base_color read failed: %s", e)
base = None
self._canvas.set_zone_base_color(base)
self._refresh_layout()
self._sync_from_sink()
self._unsubscribe = sink.subscribe(self._on_sink_update)
def shutdown(self) -> None:
if self._unsubscribe:
try:
self._unsubscribe()
except Exception as e:
logger.debug("perkey sink unsubscribe failed: %s", e)
self._unsubscribe = None
try:
self._palette.shutdown()
except Exception as e:
logger.debug("palette shutdown failed: %s", e)
def _refresh_layout(self) -> None:
if self._layout is None:
# No registered layout: lay out all reported zones as a flat strip.
from .layout import Cell
zones = list(self._sink.zones)
cells = tuple(Cell(zone_id=z, row=0, col=i, group="strip", label=self._sink.label(z)) for i, z in enumerate(zones))
self._layout = Layout(cells=cells, rows=1, cols=max(1, len(zones)), description=f"flat strip ({len(zones)} zones)")
bound = binding.bind(
self._layout,
list(self._sink.zones),
self._sink.label,
)
self._canvas.set_layout(bound)
def _sync_from_sink(self) -> None:
self._canvas.set_colors(dict(self._sink.current))
def _on_sink_update(self, current: dict[int, int]) -> None:
self._canvas.set_colors(dict(current))
def _on_color_changed(self, _palette, color: int) -> None:
self._canvas.set_active_color(color)
# Gradient swatch tracks only real picker colors; toggling unset
# leaves it alone so the gradient setup isn't disturbed.
picker = self._palette.get_picker_color()
if self._gradient_swatch is not None:
self._gradient_swatch.update(picker, self._palette.get_last_color())
try:
self._sink.set_palette_state(picker, self._palette.get_last_color())
except Exception as e:
logger.debug("set_palette_state failed: %s", e)
def _on_tool_toggled(self, btn: Gtk.RadioButton, name: str) -> None:
if btn.get_active():
self._canvas.set_tool(name)
def _on_canvas_paint(self, _canvas, delta: dict) -> None:
if not delta:
return
if len(delta) == 1:
zone, color = next(iter(delta.items()))
self._sink.write_one(int(zone), int(color))
else:
self._sink.write_bulk({int(z): int(c) for z, c in delta.items()})

View File

@ -1,102 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Visual layout primitives for the per-key color editor.
This module is pure data. It does not import GTK and does not import from
`lib.logitech_receiver`. It is therefore relocatable into a shared package
when the frontend/backend split happens.
"""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field
@dataclass(frozen=True)
class Cell:
"""One paintable cell in a layout.
`zone_id` is the firmware identifier the device uses for this LED. It is
matched against the device's reported zone list at bind time; cells with
no matching device zone are drawn disabled.
"""
zone_id: int
row: int
col: int
width: float = 1.0
height: float = 1.0
group: str = "main"
label: str = ""
x: float | None = None
y: float | None = None
@dataclass(frozen=True)
class Layout:
"""A device-class visual layout.
Cells in `strip_groups` are rendered as a flat row beneath the matrix
region, regardless of their row/col fields. Cells outside `strip_groups`
are placed by row/col on the main matrix.
`extra_zones` is a curated allowlist of zone ids that may appear in the
bottom strip when the device reports them but they are not covered by a
layout cell. Zones outside the allowlist are dropped Logitech firmware
bitmaps enumerate phantom/reserved slots (e.g. G515 reports 47, 97, 99-103,
254) that aren't physical keys. Set to `None` to disable filtering.
"""
cells: tuple[Cell, ...]
rows: int
cols: int
strip_groups: tuple[str, ...] = ("strip",)
supported_tools: tuple[str, ...] = ("single", "rect", "bucket", "gradient")
extra_zones: frozenset[int] | None = None
description: str = ""
def matrix_cells(self) -> tuple[Cell, ...]:
return tuple(c for c in self.cells if c.group not in self.strip_groups)
def strip_cells(self) -> tuple[Cell, ...]:
return tuple(c for c in self.cells if c.group in self.strip_groups)
def by_zone(self) -> dict[int, Cell]:
return {c.zone_id: c for c in self.cells}
@dataclass(frozen=True)
class BoundCell:
"""A Cell augmented with bind state, returned by `binding.bind`."""
cell: Cell
bound: bool
@dataclass(frozen=True)
class BoundLayout:
"""Result of binding a Layout against a sink's reported zones.
`matrix` and `strip` are tuples of BoundCell in render order. `unmapped`
holds zones the device reported that no Layout cell claimed; these get
appended to the strip with synthesized cells.
"""
matrix: tuple[BoundCell, ...] = field(default_factory=tuple)
strip: tuple[BoundCell, ...] = field(default_factory=tuple)
unmapped: tuple[int, ...] = field(default_factory=tuple)

View File

@ -1,145 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Registry of per-key layouts, keyed by feature + a device-class match.
Layouts register themselves with a matcher callable. `layout_for(feature, hint)`
returns the first matching layout, or None when no model-specific layout is
known in which case the editor renders a flat strip of all reported zones.
"""
from __future__ import annotations
from collections.abc import Callable
from ..layout import Layout
from . import headset_g522
from . import keyboard_ansi
from . import keyboard_iso_azerty
from . import keyboard_iso_qwerty
from . import keyboard_iso_qwertz
from . import keyboard_jis
from . import mouse_g502x
# (feature_id, matcher, layout). Matcher receives a `hint` dict the editor
# assembles from the device (kind, wpid, codename, name, zones list, etc.).
_REGISTRY: list[tuple[int, Callable[[dict], bool], Layout]] = []
def register_layout(feature: int, matcher: Callable[[dict], bool], layout: Layout) -> None:
_REGISTRY.append((feature, matcher, layout))
def layout_for(feature: int, hint: dict) -> Layout | None:
for f, match, layout in _REGISTRY:
if f == feature and match(hint):
return layout
return None
def _name_contains(*needles: str) -> Callable[[dict], bool]:
"""Build a matcher that returns True if any needle is a substring of the
device's name or codename (case-insensitive). Useful for device-family
layouts where multiple wpids share an LED arrangement.
"""
folded = tuple(n.upper() for n in needles)
def match(hint: dict) -> bool:
for field in ("codename", "name"):
value = hint.get(field)
if not value:
continue
up = str(value).upper()
if any(n in up for n in folded):
return True
return False
return match
# --- Keyboard region routing ---
# Country code → layout family. Codes from HID++ feature 0x4540 KeyboardLayout.
_KEYBOARD_FAMILY_BY_COUNTRY: dict[int, str] = {
1: "ansi",
# ISO QWERTY (UK + ES/IT/PT/BE/Nordic — same shape, different keycap legends)
2: "iso_qwerty",
5: "iso_qwerty",
8: "iso_qwerty",
0x0B: "iso_qwerty",
0x0D: "iso_qwerty",
0x0E: "iso_qwerty",
0x0F: "iso_qwerty",
0x16: "iso_qwerty",
0x1D: "iso_qwerty",
0x21: "iso_qwerty",
0x24: "iso_qwerty",
# ISO QWERTZ (DE/Swiss)
3: "iso_qwertz",
7: "iso_qwertz",
# ISO AZERTY (FR)
4: "iso_azerty",
# JIS
9: "jis",
0x3E: "jis",
}
_FAMILY_LAYOUTS = {
"ansi": (keyboard_ansi.LAYOUT_FULL, keyboard_ansi.LAYOUT_TKL),
"iso_qwerty": (keyboard_iso_qwerty.LAYOUT_FULL, keyboard_iso_qwerty.LAYOUT_TKL),
"iso_qwertz": (keyboard_iso_qwertz.LAYOUT_FULL, keyboard_iso_qwertz.LAYOUT_TKL),
"iso_azerty": (keyboard_iso_azerty.LAYOUT_FULL, keyboard_iso_azerty.LAYOUT_TKL),
"jis": (keyboard_jis.LAYOUT_FULL, keyboard_jis.LAYOUT_TKL),
}
def _has_numpad(hint: dict) -> bool:
"""Numpad presence is read from the device's reported zone bitmap rather
than counting zones G515 reports phantom zones (47, 97, 99-103, 254)
that diverge from the keycap count.
"""
zones = set(hint.get("zones", ()))
return 80 in zones or 95 in zones
def _keyboard_family(hint: dict) -> str:
"""Pick a layout family from the device's HID++ keyboard layout country
code. Defaults to "ansi" when the code is missing or unknown.
"""
code = hint.get("keyboard_layout")
if code is None:
return "ansi"
return _KEYBOARD_FAMILY_BY_COUNTRY.get(int(code), "ansi")
def _keyboard_matcher(family: str, full_size: bool) -> Callable[[dict], bool]:
def match(hint: dict) -> bool:
if hint.get("kind") != "keyboard":
return False
if _has_numpad(hint) != full_size:
return False
return _keyboard_family(hint) == family
return match
# PER_KEY_LIGHTING_V2 = 0x8081
for _family, (_full, _tkl) in _FAMILY_LAYOUTS.items():
register_layout(0x8081, _keyboard_matcher(_family, full_size=True), _full)
register_layout(0x8081, _keyboard_matcher(_family, full_size=False), _tkl)
register_layout(0x8081, _name_contains("G502 X"), mouse_g502x.LAYOUT)
# HEADSET_RGB_HOSTMODE = 0x0620
register_layout(0x0620, _name_contains("G522"), headset_g522.LAYOUT)

View File

@ -1,236 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Shared building blocks for regional keyboard layouts.
Each region (ANSI, ISO_QWERTY, ISO_QWERTZ, ISO_AZERTY, JIS) shares the function
row, nav-cluster, and numpad blocks; only the main alpha block differs (ANSI
includes the row 2 col 13 backslash, ISO doesn't). Regional label overrides on
top of either main block produce the final layout.
Cell positions and groupings adapted from OpenRGB's KeyboardLayoutManager.
Zone IDs are firmware values reported by Logitech HID++ feature 0x8081
(PER_KEY_LIGHTING_V2).
"""
from __future__ import annotations
from ..layout import Cell
from ..layout import Layout
# --- Function row: ESC + F1..F12 (shared across all regions).
FN_ROW: tuple[Cell, ...] = (
Cell(zone_id=38, row=0, col=0, group="fn_row", label="Esc"),
Cell(zone_id=55, row=0, col=2, group="fn_row", label="F1"),
Cell(zone_id=56, row=0, col=3, group="fn_row", label="F2"),
Cell(zone_id=57, row=0, col=4, group="fn_row", label="F3"),
Cell(zone_id=58, row=0, col=5, group="fn_row", label="F4"),
Cell(zone_id=59, row=0, col=6, group="fn_row", label="F5"),
Cell(zone_id=60, row=0, col=7, group="fn_row", label="F6"),
Cell(zone_id=61, row=0, col=8, group="fn_row", label="F7"),
Cell(zone_id=62, row=0, col=9, group="fn_row", label="F8"),
Cell(zone_id=63, row=0, col=10, group="fn_row", label="F9"),
Cell(zone_id=64, row=0, col=11, group="fn_row", label="F10"),
Cell(zone_id=65, row=0, col=12, group="fn_row", label="F11"),
Cell(zone_id=66, row=0, col=13, group="fn_row", label="F12"),
)
# --- Nav cluster + arrows (shared).
EXTRAS: tuple[Cell, ...] = (
Cell(zone_id=67, row=0, col=14, group="extras", label="PrtSc"),
Cell(zone_id=68, row=0, col=15, group="extras", label="ScrLk"),
Cell(zone_id=69, row=0, col=16, group="extras", label="Pause"),
Cell(zone_id=70, row=1, col=14, group="extras", label="Ins"),
Cell(zone_id=71, row=1, col=15, group="extras", label="Home"),
Cell(zone_id=72, row=1, col=16, group="extras", label="PgUp"),
Cell(zone_id=73, row=2, col=14, group="extras", label="Del"),
Cell(zone_id=74, row=2, col=15, group="extras", label="End"),
Cell(zone_id=75, row=2, col=16, group="extras", label="PgDn"),
Cell(zone_id=79, row=4, col=15, group="extras", label=""),
Cell(zone_id=77, row=5, col=14, group="extras", label=""),
Cell(zone_id=78, row=5, col=15, group="extras", label=""),
Cell(zone_id=76, row=5, col=16, group="extras", label=""),
)
# --- Numpad block (only on full-size keyboards).
NUMPAD: tuple[Cell, ...] = (
Cell(zone_id=80, row=1, col=17, group="numpad", label="Num"),
Cell(zone_id=81, row=1, col=18, group="numpad", label="/"),
Cell(zone_id=82, row=1, col=19, group="numpad", label="*"),
Cell(zone_id=83, row=1, col=20, group="numpad", label="-"),
Cell(zone_id=92, row=2, col=17, group="numpad", label="7"),
Cell(zone_id=93, row=2, col=18, group="numpad", label="8"),
Cell(zone_id=94, row=2, col=19, group="numpad", label="9"),
Cell(zone_id=84, row=2, col=20, height=2.0, group="numpad", label="+"),
Cell(zone_id=89, row=3, col=17, group="numpad", label="4"),
Cell(zone_id=90, row=3, col=18, group="numpad", label="5"),
Cell(zone_id=91, row=3, col=19, group="numpad", label="6"),
Cell(zone_id=86, row=4, col=17, group="numpad", label="1"),
Cell(zone_id=87, row=4, col=18, group="numpad", label="2"),
Cell(zone_id=88, row=4, col=19, group="numpad", label="3"),
Cell(zone_id=85, row=4, col=20, height=2.0, group="numpad", label="Enter"),
Cell(zone_id=95, row=5, col=17, width=2.0, group="numpad", label="0"),
Cell(zone_id=96, row=5, col=19, group="numpad", label="."),
)
# --- Main alpha block, ANSI (104-key). Includes row 2 col 13 backslash and
# omits POUND (row 3 col 12) + ISO_BACKSLASH (row 4 col 1).
MAIN_ANSI: tuple[Cell, ...] = (
# Row 1: backtick + numbers + minus/equals + backspace
Cell(zone_id=50, row=1, col=0, group="main", label="`"),
Cell(zone_id=27, row=1, col=1, group="main", label="1"),
Cell(zone_id=28, row=1, col=2, group="main", label="2"),
Cell(zone_id=29, row=1, col=3, group="main", label="3"),
Cell(zone_id=30, row=1, col=4, group="main", label="4"),
Cell(zone_id=31, row=1, col=5, group="main", label="5"),
Cell(zone_id=32, row=1, col=6, group="main", label="6"),
Cell(zone_id=33, row=1, col=7, group="main", label="7"),
Cell(zone_id=34, row=1, col=8, group="main", label="8"),
Cell(zone_id=35, row=1, col=9, group="main", label="9"),
Cell(zone_id=36, row=1, col=10, group="main", label="0"),
Cell(zone_id=42, row=1, col=11, group="main", label="-"),
Cell(zone_id=43, row=1, col=12, group="main", label="="),
Cell(zone_id=39, row=1, col=13, group="main", label="Bksp"),
# Row 2: tab + qwerty + brackets + backslash
Cell(zone_id=40, row=2, col=0, group="main", label="Tab"),
Cell(zone_id=17, row=2, col=1, group="main", label="Q"),
Cell(zone_id=23, row=2, col=2, group="main", label="W"),
Cell(zone_id=5, row=2, col=3, group="main", label="E"),
Cell(zone_id=18, row=2, col=4, group="main", label="R"),
Cell(zone_id=20, row=2, col=5, group="main", label="T"),
Cell(zone_id=25, row=2, col=6, group="main", label="Y"),
Cell(zone_id=21, row=2, col=7, group="main", label="U"),
Cell(zone_id=9, row=2, col=8, group="main", label="I"),
Cell(zone_id=15, row=2, col=9, group="main", label="O"),
Cell(zone_id=16, row=2, col=10, group="main", label="P"),
Cell(zone_id=44, row=2, col=11, group="main", label="["),
Cell(zone_id=45, row=2, col=12, group="main", label="]"),
Cell(zone_id=46, row=2, col=13, group="main", label="\\"),
# Row 3: caps + asdf-row + semi/quote + enter
Cell(zone_id=54, row=3, col=0, group="main", label="Caps"),
Cell(zone_id=1, row=3, col=1, group="main", label="A"),
Cell(zone_id=19, row=3, col=2, group="main", label="S"),
Cell(zone_id=4, row=3, col=3, group="main", label="D"),
Cell(zone_id=6, row=3, col=4, group="main", label="F"),
Cell(zone_id=7, row=3, col=5, group="main", label="G"),
Cell(zone_id=8, row=3, col=6, group="main", label="H"),
Cell(zone_id=10, row=3, col=7, group="main", label="J"),
Cell(zone_id=11, row=3, col=8, group="main", label="K"),
Cell(zone_id=12, row=3, col=9, group="main", label="L"),
Cell(zone_id=48, row=3, col=10, group="main", label=";"),
Cell(zone_id=49, row=3, col=11, group="main", label="'"),
Cell(zone_id=37, row=3, col=13, group="main", label="Enter"),
# Row 4: shift + zxcv-row + comma/period/slash + rshift
Cell(zone_id=105, row=4, col=0, group="main", label="Shift"),
Cell(zone_id=26, row=4, col=2, group="main", label="Z"),
Cell(zone_id=24, row=4, col=3, group="main", label="X"),
Cell(zone_id=3, row=4, col=4, group="main", label="C"),
Cell(zone_id=22, row=4, col=5, group="main", label="V"),
Cell(zone_id=2, row=4, col=6, group="main", label="B"),
Cell(zone_id=14, row=4, col=7, group="main", label="N"),
Cell(zone_id=13, row=4, col=8, group="main", label="M"),
Cell(zone_id=51, row=4, col=9, group="main", label=","),
Cell(zone_id=52, row=4, col=10, group="main", label="."),
Cell(zone_id=53, row=4, col=11, group="main", label="/"),
Cell(zone_id=109, row=4, col=13, group="main", label="Shift"),
# Row 5: bottom row. Space spans cols 3..9 visually.
Cell(zone_id=104, row=5, col=0, group="main", label="Ctrl"),
Cell(zone_id=107, row=5, col=1, group="main", label="Win"),
Cell(zone_id=106, row=5, col=2, group="main", label="Alt"),
Cell(zone_id=41, row=5, col=3, width=7.0, group="main", label="Space"),
Cell(zone_id=110, row=5, col=10, group="main", label="AltGr"),
Cell(zone_id=111, row=5, col=11, group="main", label="Win"),
Cell(zone_id=98, row=5, col=12, group="main", label="Menu"),
Cell(zone_id=108, row=5, col=13, group="main", label="Ctrl"),
)
# --- Main alpha block, ISO. Drops the row 2 col 13 backslash (zone 46 is the
# upper half of the L-shape Enter on ISO, addressed by zone 37) and adds
# the two ISO-only keys: POUND (zone 47) at row 3 col 12 between ' and
# Enter, and ISO_BACKSLASH (zone 97) at row 4 col 1 between Shift and Z.
# Regional layouts override the labels to match local keycaps (# / < on
# QWERTZ, # / \ on UK QWERTY, * / < on AZERTY).
_ISO_EXTRA_KEYS: tuple[Cell, ...] = (
Cell(zone_id=47, row=3, col=12, group="main", label="#"),
Cell(zone_id=97, row=4, col=1, group="main", label="\\"),
)
MAIN_ISO: tuple[Cell, ...] = tuple(c for c in MAIN_ANSI if not (c.row == 2 and c.col == 13)) + _ISO_EXTRA_KEYS
# --- Curated allowlist for unmapped device zones surfaced in the bottom strip.
# G-keys, logo, media, brightness — the canonical "extras" Logitech firmware
# actually addresses. Phantom zones (e.g. G515's 47, 97, 99-103, 254) drop.
EXTRAS_ALLOWLIST: frozenset[int] = frozenset(
{
153, # Brightness
155, # Play/Pause
156, # Mute
157, # Next
158, # Previous
180, # G1
181, # G2
182, # G3
183, # G4
184, # G5
210, # Logo
}
)
def _relabel(cells: tuple[Cell, ...], overrides: dict[int, str]) -> tuple[Cell, ...]:
"""Return a new tuple where any cell whose zone_id is in `overrides` has
its label replaced. Unaffected cells pass through unchanged.
"""
if not overrides:
return cells
return tuple(
Cell(
zone_id=c.zone_id,
row=c.row,
col=c.col,
width=c.width,
height=c.height,
group=c.group,
label=overrides[c.zone_id] if c.zone_id in overrides else c.label,
x=c.x,
y=c.y,
)
for c in cells
)
def build_layout(
main_cells: tuple[Cell, ...],
*,
include_numpad: bool,
label_overrides: dict[int, str] | None = None,
description: str = "",
) -> Layout:
"""Assemble a regional keyboard layout from a chosen main block + the
shared fn-row / extras / (optionally) numpad blocks. Apply per-zone
label overrides to every cell whose zone matches.
"""
cells = FN_ROW + main_cells + EXTRAS
if include_numpad:
cells = cells + NUMPAD
cells = _relabel(cells, label_overrides or {})
cols = 21 if include_numpad else 17
return Layout(
cells=cells,
rows=6,
cols=cols,
extra_zones=EXTRAS_ALLOWLIST,
description=description,
)

View File

@ -1,53 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""LED layout for the G522 LIGHTSPEED headset.
Eight LEDs in two 2×2 grids one per earcup, viewed from outside:
Left earcup Right earcup
8 7 6 5
4 3 2 1
"""
from __future__ import annotations
from ..layout import Cell
from ..layout import Layout
_CELLS: tuple[Cell, ...] = (
# Left earcup (cols 0-1, outer view): top-left=8, top-right=7, bottom-left=4, bottom-right=3
Cell(zone_id=8, row=0, col=0, group="main", label="8"),
Cell(zone_id=7, row=0, col=1, group="main", label="7"),
Cell(zone_id=4, row=1, col=0, group="main", label="4"),
Cell(zone_id=3, row=1, col=1, group="main", label="3"),
# Right earcup (cols 3-4, outer view): top-left=6, top-right=5, bottom-left=2, bottom-right=1
Cell(zone_id=6, row=0, col=3, group="main", label="6"),
Cell(zone_id=5, row=0, col=4, group="main", label="5"),
Cell(zone_id=2, row=1, col=3, group="main", label="2"),
Cell(zone_id=1, row=1, col=4, group="main", label="1"),
)
LAYOUT: Layout = Layout(
cells=_CELLS,
rows=2,
cols=5,
description="Logitech G522 LIGHTSPEED headset (8 LEDs, 4 per earcup)",
)

View File

@ -1,42 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""ANSI QWERTY keyboard layouts (full 104-key and TKL).
Cell positions and groupings derived from OpenRGB's KeyboardLayoutManager
(KeyboardLayoutManager.cpp), Copyright (C) Chris M (Dr_No), licensed under
GPL-2.0-or-later. This file ports the static ANSI data only; the runtime
opcode interpreter for regional overlays is intentionally not included.
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ANSI
from ._keyboard_base import build_layout
LAYOUT_FULL: Layout = build_layout(
MAIN_ANSI,
include_numpad=True,
description="ANSI QWERTY 104-key full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ANSI,
include_numpad=False,
description="ANSI QWERTY tenkeyless",
)

View File

@ -1,77 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""ISO AZERTY layout (FR).
ISO shape plus French label overrides AQ, WZ, M repositioned, French
digit-row symbols (& é " ' ( - è _ ç à ). Adapted from OpenRGB.
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ISO
from ._keyboard_base import build_layout
# zone_id → French label
_OVERRIDES: dict[int, str] = {
# Row 1 (digit row → French symbols)
50: "²", # backtick → super-2
27: "&", # 1
28: "é", # 2
29: '"', # 3
30: "'", # 4
31: "(", # 5
32: "-", # 6
33: "è", # 7
34: "_", # 8
35: "ç", # 9
36: "à", # 0
42: ")", # minus → close-paren
# Row 2 — Q/A and W/Z swaps, brackets relabeled
17: "A", # Q-position → A
23: "Z", # W-position → Z
44: "^", # [-position → caret
45: "$", # ]-position → dollar
# Row 3 — A → Q; M moves up to ; position
1: "Q", # A-position → Q
48: "M", # ;-position → M
49: "ù", # '-position → ù
# Row 4 — Z-position becomes W; comma row shifts
26: "W", # Z-position → W
13: ",", # M-position → comma
51: ";", # ,-position → semicolon
52: ":", # .-position → colon
53: "!", # /-position → exclamation
47: "*", # POUND key (row 3 col 12) — French * / µ
97: "<", # ISO_BACKSLASH (row 4 col 1), between Shift and W
}
LAYOUT_FULL: Layout = build_layout(
MAIN_ISO,
include_numpad=True,
label_overrides=_OVERRIDES,
description="ISO AZERTY (FR) full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ISO,
include_numpad=False,
label_overrides=_OVERRIDES,
description="ISO AZERTY (FR) tenkeyless",
)

View File

@ -1,45 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""ISO_QWERTY keyboard layouts (UK English ISO + other QWERTY ISO regions).
Same English keycap legends as ANSI; differs only in shape the row 2 col 13
backslash on ANSI doesn't exist on ISO (that position is the upper half of the
L-shape Enter, addressed by zone 37). Used for UK and any other region whose
country code maps to "iso_qwerty" without a more specific layout (Spanish,
Italian, Portuguese, Belgian, Nordic those keyboards have the same shape
as UK ISO; only their physical keycap legends differ, which our painter
doesn't reproduce verbatim).
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ISO
from ._keyboard_base import build_layout
LAYOUT_FULL: Layout = build_layout(
MAIN_ISO,
include_numpad=True,
description="ISO QWERTY 103-key full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ISO,
include_numpad=False,
description="ISO QWERTY tenkeyless",
)

View File

@ -1,59 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""ISO QWERTZ layout (DE / CH).
ISO shape plus German label overrides (Y/Z swap, Ü/Ö/Ä/ß placement).
Adapted from OpenRGB.
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ISO
from ._keyboard_base import build_layout
# zone_id → German label
_OVERRIDES: dict[int, str] = {
50: "^", # row 1 col 0 — caret/degree (DE keycap)
42: "ß", # row 1 col 11 — eszett
43: "´", # row 1 col 12 — acute accent
25: "Z", # row 2 col 6 — Y/Z swap
44: "Ü", # row 2 col 11
45: "+", # row 2 col 12
48: "Ö", # row 3 col 10
49: "Ä", # row 3 col 11
26: "Y", # row 4 col 2 — Y/Z swap
53: "-", # row 4 col 11
47: "#", # POUND key (row 3 col 12), between Ä and Enter
97: "<", # ISO_BACKSLASH (row 4 col 1), between Shift and Y
}
LAYOUT_FULL: Layout = build_layout(
MAIN_ISO,
include_numpad=True,
label_overrides=_OVERRIDES,
description="ISO QWERTZ (DE/CH) full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ISO,
include_numpad=False,
label_overrides=_OVERRIDES,
description="ISO QWERTZ (DE/CH) tenkeyless",
)

View File

@ -1,52 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""JIS layout (JP).
ISO shape with Japanese keycap relabels for the bracket / colon positions.
Adapted from OpenRGB. JIS keyboards also have additional kana-control keys
near the spacebar (henkan/muhenkan/kana) that aren't represented here —
matches OpenRGB's coverage.
"""
from __future__ import annotations
from ..layout import Layout
from ._keyboard_base import MAIN_ISO
from ._keyboard_base import build_layout
# zone_id → JIS label
_OVERRIDES: dict[int, str] = {
44: "@", # row 2 col 11 — bracket-position becomes at-sign
45: "[", # row 2 col 12 — bracket shifts left
49: ":", # row 3 col 11 — quote-position becomes colon
}
LAYOUT_FULL: Layout = build_layout(
MAIN_ISO,
include_numpad=True,
label_overrides=_OVERRIDES,
description="JIS (JP) full-size",
)
LAYOUT_TKL: Layout = build_layout(
MAIN_ISO,
include_numpad=False,
label_overrides=_OVERRIDES,
description="JIS (JP) tenkeyless",
)

View File

@ -1,49 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""LED layout for the G502 X family (G502 X, G502 X PLUS, G502 X LIGHTSPEED).
Eight LEDs reported as zones 1..8 by the firmware. Positions may need
revision per actual hardware.
Row 0: 3 . . . . . 2
Row 1: . 4 8 7 6 5 .
Row 2: . . . . . . 1
"""
from __future__ import annotations
from ..layout import Cell
from ..layout import Layout
_CELLS: tuple[Cell, ...] = (
Cell(zone_id=1, row=2, col=6, group="main", label="1"),
Cell(zone_id=2, row=0, col=6, group="main", label="2"),
Cell(zone_id=3, row=0, col=0, group="main", label="3"),
Cell(zone_id=4, row=1, col=1, group="main", label="4"),
Cell(zone_id=5, row=1, col=5, group="main", label="5"),
Cell(zone_id=6, row=1, col=4, group="main", label="6"),
Cell(zone_id=7, row=1, col=3, group="main", label="7"),
Cell(zone_id=8, row=1, col=2, group="main", label="8"),
)
LAYOUT: Layout = Layout(
cells=_CELLS,
rows=3,
cols=7,
description="Logitech G502 X family",
)

View File

@ -1,235 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Palette: active-color picker + a small gradient swatch widget.
The picker (`Palette`) is just a wrapped `Gtk.ColorButton` that emits
`color-changed` and remembers the previous active color. The previous
color is surfaced visually by the gradient tool button, not in the palette
itself see `GradientSwatch` below, used by `editor.py`.
"""
from __future__ import annotations
from enum import Enum
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk # NOQA: E402
from gi.repository import GObject # NOQA: E402
from gi.repository import Gtk # NOQA: E402
from solaar.i18n import _ # NOQA: E402
from ._icons import attach_themed_icon # NOQA: E402
_UNSET_ICON_NAME = "solaar-tool-palette-off-symbolic"
class GtkSignal(Enum):
DRAW = "draw"
COLOR_SET = "color-set"
TOGGLED = "toggled"
def _rgb_to_int(rgba: Gdk.RGBA) -> int:
r = max(0, min(255, int(round(rgba.red * 255))))
g = max(0, min(255, int(round(rgba.green * 255))))
b = max(0, min(255, int(round(rgba.blue * 255))))
return (r << 16) | (g << 8) | b
def _int_to_rgba(c: int) -> Gdk.RGBA:
rgba = Gdk.RGBA()
if c is None or c < 0:
rgba.red = rgba.green = rgba.blue = 0.5
rgba.alpha = 1.0
return rgba
rgba.red = ((c >> 16) & 0xFF) / 255.0
rgba.green = ((c >> 8) & 0xFF) / 255.0
rgba.blue = (c & 0xFF) / 255.0
rgba.alpha = 1.0
return rgba
# Sentinel for "no change" / unset paint. Matches special_keys.COLORSPLUS["No change"].
UNSET_COLOR = -1
class Palette(Gtk.Box):
__gsignals__ = {
"color-changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
}
def __init__(self, active: int = 0xFF0000, previous: int = 0xFF0000) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
# _color/_last_color are always real RGB values; the unset toggle is
# a separate channel so the gradient swatch (which mirrors these) is
# unaffected by switching to "no change" paint mode.
self._color: int = int(active)
self._last_color: int = int(previous)
self._unset_mode: bool = False
self._color_btn = Gtk.ColorButton()
self._color_btn.set_use_alpha(False)
self._color_btn.set_rgba(_int_to_rgba(self._color))
self._color_btn.set_tooltip_text(_("Active color"))
self._color_btn.connect(GtkSignal.COLOR_SET.value, self._on_color_set)
self.pack_start(self._color_btn, False, False, 0)
self._unset_btn = Gtk.ToggleButton()
self._unset_btn.set_tooltip_text(_("Paint as 'no change' — clears the cell to the zone base color"))
unset_label = _("Unset")
if attach_themed_icon(self._unset_btn, _UNSET_ICON_NAME) is not None:
self._unset_btn.get_accessible().set_name(unset_label)
else:
self._unset_btn.set_label(unset_label)
self._unset_btn.connect(GtkSignal.TOGGLED.value, self._on_unset_toggled)
self.pack_start(self._unset_btn, False, False, 0)
def shutdown(self) -> None:
# attach_themed_icon connects to the button's own style-updated
# signal; GTK disconnects it automatically when the button is
# destroyed, so there is nothing to clean up here.
pass
def _on_color_set(self, btn: Gtk.ColorButton) -> None:
c = _rgb_to_int(btn.get_rgba())
unset_was_on = self._unset_mode
if c == self._color and not unset_was_on:
return
if c != self._color:
self._last_color = self._color
self._color = c
if unset_was_on:
self._unset_mode = False
self._unset_btn.set_active(False)
self.emit("color-changed", self.get_color())
def _on_unset_toggled(self, btn: Gtk.ToggleButton) -> None:
new_state = bool(btn.get_active())
if new_state == self._unset_mode:
return
self._unset_mode = new_state
self.emit("color-changed", self.get_color())
def get_color(self) -> int:
return UNSET_COLOR if self._unset_mode else self._color
def get_picker_color(self) -> int:
"""The most recent real RGB pick — independent of the unset toggle.
Use this for visuals that should always reflect actual colors (e.g.
the gradient swatch).
"""
return self._color
def get_last_color(self) -> int:
return self._last_color
def is_unset(self) -> bool:
return self._unset_mode
class GradientSwatch(Gtk.DrawingArea):
"""Small icon: diagonal gradient from `previous` (bottom-left) to `active` (top-right).
Used as the visual on the gradient tool button so the user can see at a
glance which two colors the next gradient stroke will fade between.
"""
SIZE = 22
def __init__(self) -> None:
super().__init__()
self.set_size_request(self.SIZE, self.SIZE)
self._active: int = 0xFF0000
self._previous: int = 0xFF0000
self.connect(GtkSignal.DRAW.value, self._on_draw)
# Re-render when the GTK theme changes, so the rounded-square
# outline (drawn in the theme foreground color) stays in sync
# with the tool icons next to it.
self.connect("style-updated", lambda w: w.queue_draw())
def update(self, active: int, previous: int) -> None:
self._active = int(active)
self._previous = int(previous)
self.queue_draw()
def get_active(self) -> int:
return self._active
def get_previous(self) -> int:
return self._previous
def get_colors(self) -> tuple[int, int]:
"""Return (active, previous) — the colors the gradient tool will paint with."""
return (self._active, self._previous)
@staticmethod
def _rounded_rect_path(cr, x: float, y: float, w: float, h: float, r: float) -> None:
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -1.5708, 0)
cr.arc(x + w - r, y + h - r, r, 0, 1.5708)
cr.arc(x + r, y + h - r, r, 1.5708, 3.1416)
cr.arc(x + r, y + r, r, 3.1416, 4.7124)
cr.close_path()
def _on_draw(self, _w, cr) -> None:
import cairo # local: keeps the module light when GradientSwatch isn't built
def rgb(c: int) -> tuple[float, float, float]:
if c is None or c < 0:
return (0.5, 0.5, 0.5)
return (((c >> 16) & 0xFF) / 255.0, ((c >> 8) & 0xFF) / 255.0, (c & 0xFF) / 255.0)
# Render in Tabler "square" coordinates (24x24 viewBox, rounded
# rect from (3,3) to (21,21), corner radius 2, stroke 2) and let
# cairo scale to the swatch's pixel size. Matches the outline
# style of the tool icons exactly.
cr.save()
cr.scale(self.SIZE / 24.0, self.SIZE / 24.0)
# Build the rounded-square path once, clip+fill the gradient
# inside it, then re-build and stroke the outline in the theme
# foreground color.
self._rounded_rect_path(cr, 3, 3, 18, 18, 2)
cr.save()
cr.clip()
# Top-left (previous, gradient start) → bottom-right (active, end).
# Matches the directional behavior of dragging the line tool TL → BR.
# Endpoints are shifted inward by the arc inset (corner radius * (1
# - 1/sqrt(2)), ~0.586 for r=2) so t=0 lands on the actual visible
# TL corner pixel of the rounded rect — without this, the rendered
# corners sample at t≈0.033/0.967 and the displayed colors are
# ~8 RGB units short of the true endpoint colors.
inset = 2 * (1 - 1 / (2**0.5))
pat = cairo.LinearGradient(3 + inset, 3 + inset, 21 - inset, 21 - inset)
pat.add_color_stop_rgb(0.0, *rgb(self._previous))
pat.add_color_stop_rgb(1.0, *rgb(self._active))
cr.set_source(pat)
cr.rectangle(3, 3, 18, 18)
cr.fill()
cr.restore() # drop clip
self._rounded_rect_path(cr, 3, 3, 18, 18, 2)
fg = self.get_style_context().get_color(Gtk.StateFlags.NORMAL)
cr.set_source_rgba(fg.red, fg.green, fg.blue, fg.alpha)
cr.set_line_width(2)
cr.set_line_join(cairo.LINE_JOIN_ROUND)
cr.set_line_cap(cairo.LINE_CAP_ROUND)
cr.stroke()
cr.restore()

View File

@ -1,73 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Narrow contract between the per-key editor and any color-map setting.
The editor consumes only this protocol, never `lib.logitech_receiver` directly.
This is the seam where a future frontend/backend split would cut cleanly.
"""
from __future__ import annotations
from typing import Callable
from typing import Protocol
class PerKeyColorSink(Protocol):
"""A device's per-key color buffer, exposed without device internals.
Colors are 24-bit packed RGB ints (0xRRGGBB). The sentinel value -1 means
"no change" / "unset" (matches `special_keys.COLORSPLUS["No change"]`).
"""
@property
def title(self) -> str:
...
@property
def zones(self) -> list[int]:
...
@property
def current(self) -> dict[int, int]:
...
def label(self, zone: int) -> str:
...
def write_one(self, zone: int, color: int) -> None:
...
def write_bulk(self, deltas: dict[int, int]) -> None:
...
def subscribe(self, listener: Callable[[dict[int, int]], None]) -> Callable[[], None]:
"""Register a callback for current-value changes; return an unsubscribe handle."""
...
def palette_state(self) -> tuple[int, int] | None:
"""Return the persisted (active_color, previous_color) for this device's palette, or None."""
...
def set_palette_state(self, active: int, previous: int) -> None:
"""Persist the palette's active and previous colors for this device."""
...
def zone_base_color(self) -> int | None:
"""Return the zone base color (what 'no change' cells actually display
on the keyboard), or None if the device has no zone effect.
"""
...

View File

@ -1,223 +0,0 @@
## Copyright (C) 2026 Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Paint tools for the per-key editor.
Each tool is a stateless policy object. The Canvas owns per-stroke state
(press cell, motion cell, brush path) and asks the active tool for the
final delta on release.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
from typing import Protocol
from .layout import BoundCell
from .layout import Cell
@dataclass
class ToolContext:
active_color: int
last_color: int
cells_by_zone: dict[int, BoundCell]
# zone ids that live in the bottom strip (e.g. logo, G-keys); kept separate
# because their on-screen position is decoupled from the matrix grid.
strip_zones: frozenset = frozenset()
# zone_id -> current packed RGB (or -1 sentinel for unset). Used by tools
# that need to compare colors, like the flood-fill bucket.
current_colors: dict = None # type: ignore[assignment]
def __post_init__(self) -> None:
if self.current_colors is None:
self.current_colors = {}
def bound_cells(self) -> list[BoundCell]:
return list(self.cells_by_zone.values())
def matrix_cells(self) -> list[BoundCell]:
cells = [bc for bc in self.cells_by_zone.values() if bc.bound and bc.cell.zone_id not in self.strip_zones]
if cells:
return cells
# No matrix region (e.g. a mouse, where every zone lives in the
# strip). Fall back to all bound cells so directional tools still
# have something to project across.
return [bc for bc in self.cells_by_zone.values() if bc.bound]
def cells_in_bbox(self, a: BoundCell, b: BoundCell) -> list[BoundCell]:
cx_a, cy_a = _cell_center(a.cell)
cx_b, cy_b = _cell_center(b.cell)
x0, x1 = (cx_a, cx_b) if cx_a <= cx_b else (cx_b, cx_a)
y0, y1 = (cy_a, cy_b) if cy_a <= cy_b else (cy_b, cy_a)
result = []
for bc in self.cells_by_zone.values():
if not bc.bound:
continue
cx, cy = _cell_center(bc.cell)
if x0 <= cx <= x1 and y0 <= cy <= y1:
result.append(bc)
return result
def cells_in_bbox_ordered(self, a: BoundCell, b: BoundCell) -> list[BoundCell]:
cells = self.cells_in_bbox(a, b)
return sorted(cells, key=lambda c: (c.cell.row, c.cell.col))
def cells_in_group(self, group: str) -> list[BoundCell]:
return [bc for bc in self.cells_by_zone.values() if bc.bound and bc.cell.group == group]
def _cell_center(cell: Cell) -> tuple[float, float]:
return (cell.col + cell.width / 2.0, cell.row + cell.height / 2.0)
def _cells_touch(a: Cell, b: Cell) -> bool:
"""Bounding-box edge adjacency in grid units. Handles variable widths
(Space spans multiple cols, Numpad+ spans multiple rows).
"""
a_c1, a_r1 = a.col + a.width, a.row + a.height
b_c1, b_r1 = b.col + b.width, b.row + b.height
rows_overlap = a.row < b_r1 and b.row < a_r1
cols_overlap = a.col < b_c1 and b.col < a_c1
if (a_c1 == b.col or b_c1 == a.col) and rows_overlap:
return True
if (a_r1 == b.row or b_r1 == a.row) and cols_overlap:
return True
return False
class Tool(Protocol):
name: str
is_brush: bool
overlay_shape: str # "" | "rect"
def compute(
self,
start: BoundCell | None,
end: BoundCell | None,
path: Iterable[int],
ctx: ToolContext,
) -> dict[int, int]:
...
class SingleTool:
name = "single"
is_brush = True
overlay_shape = ""
def compute(self, start, end, path, ctx):
return {z: ctx.active_color for z in path}
class RectTool:
name = "rect"
is_brush = False
overlay_shape = "rect"
def compute(self, start, end, path, ctx):
if start is None or end is None:
return {}
return {bc.cell.zone_id: ctx.active_color for bc in ctx.cells_in_bbox(start, end)}
class BucketTool:
"""Flood-fill: replace the clicked cell's color in every connected cell
of the same color (4-adjacent on the matrix grid). Strip cells aren't on
the matrix grid, so clicking one paints just that cell.
"""
name = "bucket"
is_brush = False
overlay_shape = ""
def compute(self, start, end, path, ctx):
if start is None:
return {}
new_color = ctx.active_color
target = ctx.current_colors.get(start.cell.zone_id, -1)
if target == new_color:
return {}
if start.cell.zone_id in ctx.strip_zones:
return {start.cell.zone_id: new_color}
# BFS over matrix cells
cells_by_id = {z: bc for z, bc in ctx.cells_by_zone.items() if bc.bound and z not in ctx.strip_zones}
visited = {start.cell.zone_id}
stack = [start]
result: dict[int, int] = {}
while stack:
bc = stack.pop()
result[bc.cell.zone_id] = new_color
for other in cells_by_id.values():
if other.cell.zone_id in visited:
continue
if ctx.current_colors.get(other.cell.zone_id, -1) != target:
continue
if not _cells_touch(bc.cell, other.cell):
continue
visited.add(other.cell.zone_id)
stack.append(other)
return result
class GradientTool:
"""Directional gradient: drag a line from A to B, the whole matrix gets a
gradient at that angle. Cells projecting before A clamp to the previous
color; cells past B clamp to the active color.
"""
name = "gradient"
is_brush = False
overlay_shape = "line"
def compute(self, start, end, path, ctx):
if start is None or end is None:
return {}
ax, ay = _cell_center(start.cell)
bx, by = _cell_center(end.cell)
vx, vy = bx - ax, by - ay
length_sq = vx * vx + vy * vy
if length_sq == 0:
return {start.cell.zone_id: ctx.active_color}
result: dict[int, int] = {}
for bc in ctx.matrix_cells():
cx, cy = _cell_center(bc.cell)
t = ((cx - ax) * vx + (cy - ay) * vy) / length_sq
t = 0.0 if t < 0.0 else 1.0 if t > 1.0 else t
result[bc.cell.zone_id] = _lerp_rgb(ctx.last_color, ctx.active_color, t)
return result
def _lerp_rgb(c0: int, c1: int, t: float) -> int:
if c0 < 0:
c0 = c1
if c1 < 0:
c1 = c0
r0, g0, b0 = (c0 >> 16) & 0xFF, (c0 >> 8) & 0xFF, c0 & 0xFF
r1, g1, b1 = (c1 >> 16) & 0xFF, (c1 >> 8) & 0xFF, c1 & 0xFF
r = int(round(r0 + (r1 - r0) * t))
g = int(round(g0 + (g1 - g0) * t))
b = int(round(b0 + (b1 - b0) * t))
return (r << 16) | (g << 8) | b
TOOLS: dict[str, Tool] = {
"single": SingleTool(),
"rect": RectTool(),
"bucket": BucketTool(),
"gradient": GradientTool(),
}

View File

@ -214,9 +214,7 @@ class MouseClickUI(ActionUI):
)
self.widgets[self.label] = (0, 0, 4, 1)
self.label_b = Gtk.Label(label=_("Button"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True)
self.label_c = Gtk.Label(
label=_("Action (and Count, if click)"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True
)
self.label_c = Gtk.Label(label=_("Count and Action"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True)
self.field_b = CompletionEntry(self.BUTTONS)
self.field_c = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1)
self.field_d = CompletionEntry(self.ACTIONS)
@ -229,8 +227,8 @@ class MouseClickUI(ActionUI):
self.widgets[self.label_b] = (0, 1, 1, 1)
self.widgets[self.field_b] = (1, 1, 1, 1)
self.widgets[self.label_c] = (2, 1, 1, 1)
self.widgets[self.field_c] = (4, 1, 1, 1)
self.widgets[self.field_d] = (3, 1, 1, 1)
self.widgets[self.field_c] = (3, 1, 1, 1)
self.widgets[self.field_d] = (4, 1, 1, 1)
def show(self, component, editable=True):
super().show(component, editable)

View File

@ -420,11 +420,11 @@ def _receiver_row(receiver_path, receiver=None):
def _device_row(receiver_path, device_number, device=None):
assert receiver_path
assert device_number is not None
if receiver_path is None:
return None
receiver_row = _receiver_row(receiver_path, None if device is None else device.receiver)
if device_number == 0xFF or device_number == 0x0: # direct-connected device, receiver row is device row
if receiver_row:
return receiver_row
@ -536,13 +536,7 @@ def _update_details(button):
if device.product_id:
yield _("Product ID"), f"{LOGITECH_VENDOR_ID:04x}:" + device.product_id
hid_version = device.protocol
cent_proto = getattr(device, "_centurion_protocol", None)
if cent_proto:
yield _("Protocol"), f"Centurion {cent_proto[0]}.{cent_proto[1]}"
elif hid_version:
yield _("Protocol"), f"HID++ {hid_version:1.1f}"
else:
yield _("Protocol"), _("Unknown")
yield _("Protocol"), f"HID++ {hid_version:1.1f}" if hid_version else _("Unknown")
if read_all and device.polling_rate:
yield _("Polling rate"), device.polling_rate

View File

@ -1 +1 @@
1.1.20rc2
1.1.15

View File

@ -3,22 +3,22 @@ site_description: Linux Device Manager for Logitech Unifying Receivers and Devic
site_author: pwr-Solaar
repo_url: https://github.com/pwr-Solaar/Solaar
repo_name: Solaar
logo: img/favicon.png
theme:
name: readthedocs
docs_dir: docs
nav:
- Solaar: index.md
- Usage: usage.md
- Capabilities: capabilities.md
- Issues: issues.md
- Rules: rules.md
- Debian: debian.md
- Devices: devices.md
- Features: features.md
- Translation: i18n.md
- Implementation: implementation.md
- Installation: installation.md
- Uninstallation: uninstallation.md
- Translation: i18n.md
- Features: features.md
- Devices: devices.md
- Implementation: implementation.md
- Debian: debian.md
- Rules: rules.md
- Usage: usage.md
plugins:
- search

2063
po/bg.po

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More