Compare commits
135 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
189a50e926 | |
|
|
f68230b83d | |
|
|
7647cea478 | |
|
|
be45e15552 | |
|
|
02fa95e622 | |
|
|
88e7791b32 | |
|
|
87017d1d73 | |
|
|
03b55ed1e2 | |
|
|
ec53ad9f97 | |
|
|
4c709ecb73 | |
|
|
eefa37d83f | |
|
|
cce8808995 | |
|
|
7c7666466a | |
|
|
5f6def437e | |
|
|
653f7aea18 | |
|
|
e557c30ab2 | |
|
|
d37573122f | |
|
|
5aae38f929 | |
|
|
4a7edd75ce | |
|
|
936991e0b4 | |
|
|
275ad64be1 | |
|
|
5b704c5bd7 | |
|
|
c9db25a302 | |
|
|
d800fc4c54 | |
|
|
5853cf5a8e | |
|
|
7593781367 | |
|
|
0baeb87294 | |
|
|
f4345b9ce0 | |
|
|
ac7add6297 | |
|
|
a5d12f9039 | |
|
|
badce9bb07 | |
|
|
e77746818e | |
|
|
9780e730d8 | |
|
|
4d1f9dc6c1 | |
|
|
3e88c73645 | |
|
|
ba32ee6ea0 | |
|
|
e4d543732b | |
|
|
8760789930 | |
|
|
1d1a0915f1 | |
|
|
53e379fd24 | |
|
|
3df2a30f30 | |
|
|
09cb2dddb9 | |
|
|
fc62ef00ad | |
|
|
d159081893 | |
|
|
ad96dd395b | |
|
|
fc19860f76 | |
|
|
3dc121e8b3 | |
|
|
a8e006c948 | |
|
|
b594c7292b | |
|
|
6c99d2f9d1 | |
|
|
dfa1cb7ca5 | |
|
|
c3382b0ba6 | |
|
|
661a186429 | |
|
|
1210d32b93 | |
|
|
cf9a88bcaf | |
|
|
9b627410b6 | |
|
|
671c07d861 | |
|
|
1cb656b2e3 | |
|
|
22a457da9a | |
|
|
1952e9ce98 | |
|
|
d8422d78d1 | |
|
|
4f3583ae10 | |
|
|
ec05c112f0 | |
|
|
3733713d68 | |
|
|
ff9324d346 | |
|
|
b309cfd771 | |
|
|
cd93f7e113 | |
|
|
33952d98fc | |
|
|
e199fb868d | |
|
|
d25da9bdbb | |
|
|
b952d710fe | |
|
|
ba0b45df22 | |
|
|
8b023115d2 | |
|
|
0a6421ef82 | |
|
|
25865994cb | |
|
|
9c80b64b49 | |
|
|
12aabf029b | |
|
|
7d571d855f | |
|
|
5478224cfa | |
|
|
99a403c554 | |
|
|
b9e0cf8235 | |
|
|
a22ae124d9 | |
|
|
230eaf242c | |
|
|
ee25bc76c7 | |
|
|
dc9affe6fb | |
|
|
7520c9cc28 | |
|
|
94e94c1254 | |
|
|
55a67c142c | |
|
|
51532252df | |
|
|
940aae1be1 | |
|
|
4525704793 | |
|
|
f17021e2f0 | |
|
|
9344466949 | |
|
|
8cea17fc46 | |
|
|
30d4d0f65d | |
|
|
310b3af76f | |
|
|
75aadc706c | |
|
|
d919bcbb30 | |
|
|
97dd9467b5 | |
|
|
cbb3106993 | |
|
|
42e0e391b5 | |
|
|
40dcaadec7 | |
|
|
1e756f6438 | |
|
|
a79bb24da5 | |
|
|
7dbb51b05b | |
|
|
d82233b69c | |
|
|
2f3b3c1964 | |
|
|
97311bed5f | |
|
|
6926047020 | |
|
|
0110bbff31 | |
|
|
4bda869542 | |
|
|
ce1adc7b03 | |
|
|
fc68521731 | |
|
|
c87730f1eb | |
|
|
76346cd5aa | |
|
|
705279097f | |
|
|
36377fdd5a | |
|
|
a427c66dc1 | |
|
|
f0c64f5fb3 | |
|
|
a0e19282ec | |
|
|
e999b12246 | |
|
|
d3216ea57a | |
|
|
0b6a5fa108 | |
|
|
ff23601183 | |
|
|
aaabb5d811 | |
|
|
ccf1ac5b6d | |
|
|
46da00e214 | |
|
|
1dd1ace327 | |
|
|
a87ae59a93 | |
|
|
8298db0891 | |
|
|
93e90f4894 | |
|
|
4c63bdb6ee | |
|
|
441d608ca0 | |
|
|
2e549371ef | |
|
|
4d2a42d541 |
|
|
@ -23,6 +23,8 @@ assignees: ''
|
|||
- Distribution:
|
||||
- Kernel version (ex. `uname -srmo`):
|
||||
- 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8, 3.13]
|
||||
python-version: [3.13]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
|
@ -54,7 +54,7 @@ jobs:
|
|||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8, 3.13]
|
||||
python-version: [3.13]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
|
|
|||
90
CHANGELOG.md
90
CHANGELOG.md
|
|
@ -1,3 +1,93 @@
|
|||
# 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
|
||||
|
|
|
|||
4
Makefile
4
Makefile
|
|
@ -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 dn"
|
||||
sudo dnf install gtk3 python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
|
||||
@echo "Installing Solaar dependencies via dnf"
|
||||
sudo dnf install gtk3 python3-devel python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
|
||||
|
||||
install_brew:
|
||||
@echo "Installing Solaar dependencies via brew"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- Push commit to Solaar repository and merge it
|
||||
- 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
# 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'
|
||||
```
|
||||
|
|
@ -1,12 +1,22 @@
|
|||
# Notes on Major Changes in Releases
|
||||
|
||||
## Since 1.1.16
|
||||
## 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'`
|
||||
* 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
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,374 @@
|
|||
# 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.
|
||||
|
|
@ -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++
|
||||
## HID++ and Centurion
|
||||
|
||||
The devices that Solaar handles use Logitech's HID++ protocol.
|
||||
The devices that Solaar handles use Logitech's HID++ and Centurion protocols.
|
||||
|
||||
HID++ is a Logitech-proprietary protocol that extends the standard HID
|
||||
protocol for interfacing with receivers, keyboards, mice, and so on. It allows
|
||||
|
|
@ -43,6 +43,8 @@ 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
|
||||
|
|
@ -56,12 +58,12 @@ Bluetooth product ID.
|
|||
Solaar is able to pair and unpair devices with
|
||||
receivers as supported by the device and receiver.
|
||||
|
||||
For Unifying receivers, pairing adds a new paired device, but
|
||||
For Unifying and Bolt 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 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
|
||||
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
|
||||
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.
|
||||
|
|
@ -69,20 +71,22 @@ 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. In should
|
||||
Only some connections between receivers and devices are possible. It should
|
||||
be possible to connect any device with a Unifying logo on it to any receiver
|
||||
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.
|
||||
with a Unifying logo on it and any device with a Bolt logo on it to any receiver
|
||||
with a Bolt logo on it.
|
||||
|
||||
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 HID++ features and their support see [the features page](features.md).
|
||||
For a list of features and their support see [the features page](features.md).
|
||||
|
||||
Solaar does not do much beyond using the HID++ protocol to change the
|
||||
Solaar does not do much beyond using the protocols 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
|
||||
|
|
@ -112,7 +116,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 HID++
|
||||
Querying a device for its current state can require quite a few
|
||||
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
|
||||
|
|
@ -186,6 +190,52 @@ 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 1–10, 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 1–5, 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 0–5, 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.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ layout: page
|
|||
|
||||
# Supported receivers and devices
|
||||
|
||||
Solaar only supports Logitech receivers and devices that use the Logitech proprietary HID++ protocol.
|
||||
Solaar only supports Logitech receivers and devices that use the Logitech proprietary HID++ and Centurion protocols.
|
||||
|
||||
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++ protocol.
|
||||
as long as the device uses the HID++ or Centurion 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,6 +211,7 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
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.
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
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.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
Solaar version 1.1.3
|
||||
solaar version 1.1.19-25-g7520c9cc
|
||||
|
||||
1: G613 Wireless Mechanical Gaming Keyboard
|
||||
Device path : None
|
||||
|
|
@ -6,69 +6,71 @@ Solaar version 1.1.3
|
|||
Codename : G613
|
||||
Kind : keyboard
|
||||
Protocol : HID++ 4.2
|
||||
Polling rate : 1 ms (1000Hz)
|
||||
Serial number: 0DBC5FF6
|
||||
Report Rate : 1ms
|
||||
Serial number: 710EC3A3
|
||||
Model ID: B34F40650000
|
||||
Unit ID: 9AAB3225
|
||||
Bootloader: BOT 46.00.B0006
|
||||
Firmware: MPK 05.02.B0021
|
||||
Other:
|
||||
Unit ID: 2A923B25
|
||||
1: BOT 46.00.B0006
|
||||
0: MPK 05.02.B0021
|
||||
3:
|
||||
The power switch is located on the unknown.
|
||||
Supports 32 HID++ 2.0 features:
|
||||
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}
|
||||
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
|
||||
Name: G613 Wireless Mechanical Gaming Keyboard
|
||||
Kind: keyboard
|
||||
4: WIRELESS DEVICE STATUS {1D4B}
|
||||
5: RESET {0020}
|
||||
6: DEVICE FRIENDLY NAME {0007}
|
||||
4: WIRELESS DEVICE STATUS {1D4B} V0
|
||||
5: CONFIG CHANGE {0020} V0
|
||||
Configuration: 11000000000000000000000000000000
|
||||
6: DEVICE FRIENDLY NAME {0007} V0
|
||||
Friendly Name: G613
|
||||
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}
|
||||
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
|
||||
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}
|
||||
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
|
||||
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
|
||||
Has 2 reprogrammable keys:
|
||||
0: Host Switch Channel 1 , default: HostSwitch Channel 1 => HostSwitch Channel 1
|
||||
divertable, persistently divertable, pos:1, group:0, group mask:empty
|
||||
0: Host Switch Channel 1 , default: Hostswitch Channel 1 => Hostswitch Channel 1
|
||||
persistently_divertable, divertable, pos:1, group:0, group mask:empty
|
||||
reporting: default
|
||||
1: Host Switch Channel 2 , default: HostSwitch Channel 2 => HostSwitch Channel 2
|
||||
divertable, persistently divertable, pos:2, group:0, group mask:empty
|
||||
1: Host Switch Channel 2 , default: Hostswitch Channel 2 => Hostswitch Channel 2
|
||||
persistently_divertable, divertable, pos:2, group:0, group mask:empty
|
||||
reporting: default
|
||||
Battery: 50%, discharging, next level 20%.
|
||||
Battery: 80%, BatteryStatus.DISCHARGING, next level 50%.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
Solaar version 1.1.5
|
||||
Solaar version 1.1.19
|
||||
|
||||
2: G733 Gaming Headset
|
||||
Device path : /dev/hidraw2
|
||||
G733 Gaming Headset
|
||||
Device path : /dev/hidraw0
|
||||
USB id : 046d:0AB5
|
||||
Codename : G733 Headset
|
||||
Kind : headset
|
||||
|
|
@ -9,24 +9,34 @@ Solaar version 1.1.5
|
|||
Serial number:
|
||||
Model ID: 0AB500000000
|
||||
Unit ID: FFFFFFFF
|
||||
Firmware: U1 37.00.B0131
|
||||
0: 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: Firmware U1 37.00.B0131 0AB5
|
||||
Firmware: 0 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} V0
|
||||
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}
|
||||
5: GKEY {8010} V0
|
||||
Divert G Keys (saved): True
|
||||
Divert G Keys : False
|
||||
6: EQUALIZER {8310} 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}
|
||||
7: SIDETONE {8300} V0
|
||||
Sidetone (saved): 65
|
||||
Sidetone : 65
|
||||
8: ADC MEASUREMENT {1F20} V0
|
||||
Battery status unavailable.
|
||||
Battery status unavailable.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
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.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
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.
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
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++.
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
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.
|
||||
|
|
@ -33,6 +33,7 @@ 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 |
|
||||
|
|
@ -49,6 +50,7 @@ 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
|
||||
|
|
@ -67,9 +69,12 @@ 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
|
||||
|
|
@ -101,6 +106,7 @@ 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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- Finnish: Tomi Leppänen, [Niko Savola][nikosavola]
|
||||
- German: Daniel Frost
|
||||
- Greek: Vangelis Skarmoutsos
|
||||
- Indonesia: [Ferdina Kusumah][feku]
|
||||
|
|
@ -68,6 +68,7 @@ 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
|
||||
|
|
@ -83,3 +84,4 @@ 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
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ 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++) commands.
|
||||
Logitech-proprietary (HID++ and Centurion) 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.
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ First install pip, and then run
|
|||
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
|
||||
|
||||
|
|
@ -52,8 +50,6 @@ 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.
|
||||
|
|
@ -129,8 +125,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -8,12 +8,19 @@ layout: page
|
|||
- 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.
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
|
@ -22,15 +29,11 @@ layout: page
|
|||
"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.
|
||||
- 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 driver sends messages to devices that do not conform with the Logitech HID++ specification
|
||||
- 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.
|
||||
|
||||
|
|
@ -45,7 +48,7 @@ layout: page
|
|||
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
|
||||
- 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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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`
|
||||
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`
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -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 # noqa: C901
|
||||
def __new__(mcs, name: str, bases: Tuple[Any], dic: Dict[str, Any]): # type: ignore
|
||||
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
|
||||
RX = 0x35, "Rz", UsageTypes.DV
|
||||
RZ = 0x35, "Rz", UsageTypes.DV
|
||||
SLIDER = 0x36, "Slider", UsageTypes.DV
|
||||
DIAL = 0x37, "Dial", UsageTypes.DV
|
||||
WHEEL = 0x38, "Wheel", UsageTypes.DV
|
||||
|
|
|
|||
|
|
@ -18,3 +18,5 @@ 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
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ from typing import Callable
|
|||
|
||||
from hidapi.common import DeviceInfo
|
||||
|
||||
LOGITECH_VENDOR_ID = 0x046D
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import gi
|
||||
|
||||
|
|
@ -251,6 +253,12 @@ 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
|
||||
|
|
|
|||
|
|
@ -135,10 +135,11 @@ def _open(args):
|
|||
if vid == LOGITECH_VENDOR_ID:
|
||||
return {"vid": vid}
|
||||
|
||||
device = args.device
|
||||
if args.hidpp and not device:
|
||||
device = args.path
|
||||
d = None
|
||||
if not device:
|
||||
for d in hidapi.enumerate(matchfn):
|
||||
if d.driver == "logitech-djreceiver":
|
||||
if (d.hidpp_short or d.hidpp_long) and (args.id is None or args.id.lower() == d.product_id.lower()):
|
||||
device = d.path
|
||||
break
|
||||
if not device:
|
||||
|
|
@ -146,13 +147,17 @@ 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 handle %r, vendor %r product %r serial %r."
|
||||
% (handle, hidapi.get_manufacturer(handle), hidapi.get_product(handle), hidapi.get_serial(handle))
|
||||
".. 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),
|
||||
)
|
||||
)
|
||||
if args.hidpp:
|
||||
if hidapi.get_manufacturer(handle) is not None and hidapi.get_manufacturer(handle) != b"Logitech":
|
||||
|
|
@ -170,12 +175,10 @@ 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(
|
||||
"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",
|
||||
)
|
||||
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)")
|
||||
return arg_parser.parse_args()
|
||||
|
||||
|
||||
|
|
@ -183,6 +186,17 @@ 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.")
|
||||
|
||||
|
|
@ -232,7 +246,6 @@ def main():
|
|||
time.sleep(1)
|
||||
|
||||
finally:
|
||||
print(f".. Closing handle {handle!r}")
|
||||
hidapi.close(handle)
|
||||
if interactive:
|
||||
readline.write_history_file(args.history)
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ 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 = False
|
||||
hidpp_short = hidpp_long = centurion = False
|
||||
centurion_report_id = None
|
||||
devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor"
|
||||
with fileopen(devfile, "rb") as fd:
|
||||
with warnings.catch_warnings():
|
||||
|
|
@ -111,11 +112,22 @@ 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
|
||||
if not hidpp_short and not hidpp_long:
|
||||
# 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:
|
||||
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,
|
||||
|
|
@ -125,7 +137,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, hidpp_long)
|
||||
filtered_result = filter_func(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short or centurion, hidpp_long or centurion)
|
||||
if not filtered_result:
|
||||
return
|
||||
interface_number = filtered_result.get("usb_interface")
|
||||
|
|
@ -165,6 +177,8 @@ 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
|
||||
|
||||
|
|
@ -403,6 +417,8 @@ 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""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,456 @@
|
|||
## 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
|
||||
|
|
@ -96,6 +96,32 @@ 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
|
||||
|
|
@ -287,6 +313,7 @@ def close(handle):
|
|||
if handle:
|
||||
try:
|
||||
if isinstance(handle, int):
|
||||
_centurion_handles.pop(handle, None)
|
||||
hidapi.close(handle)
|
||||
else:
|
||||
handle.close()
|
||||
|
|
@ -297,6 +324,115 @@ 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
|
||||
(0x00–0xFF), 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.
|
||||
|
||||
|
|
@ -318,6 +454,17 @@ 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]",
|
||||
|
|
@ -329,7 +476,37 @@ def write(handle, devnumber, data, long_message=False):
|
|||
)
|
||||
|
||||
try:
|
||||
hidapi.write(int(handle), wdata)
|
||||
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)
|
||||
except Exception as reason:
|
||||
logger.error("write failed, assuming handle %r no longer available", handle)
|
||||
close(handle)
|
||||
|
|
@ -361,17 +538,17 @@ def _is_relevant_message(data: bytes) -> bool:
|
|||
"""
|
||||
assert isinstance(data, bytes), (repr(data), type(data))
|
||||
|
||||
# mapping from report_id to message length
|
||||
# mapping from report_id to accepted message lengths
|
||||
report_lengths = {
|
||||
HIDPP_SHORT_MESSAGE_ID: SHORT_MESSAGE_SIZE,
|
||||
HIDPP_LONG_MESSAGE_ID: _LONG_MESSAGE_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, _CENTURION_MSG_SIZE),
|
||||
DJ_MESSAGE_ID: (_MEDIUM_MESSAGE_SIZE,),
|
||||
0x21: (_MAX_READ_SIZE,),
|
||||
}
|
||||
|
||||
report_id = ord(data[:1])
|
||||
if report_id in report_lengths:
|
||||
if report_lengths.get(report_id) == len(data):
|
||||
if len(data) in report_lengths[report_id]:
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"unexpected message size: report_id {report_id:02X} message {common.strhex(data)}")
|
||||
|
|
@ -387,15 +564,21 @@ 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(int(handle), _MAX_READ_SIZE, timeout)
|
||||
data = hidapi.read(ihandle, 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])
|
||||
|
|
@ -564,13 +747,17 @@ 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,
|
||||
Hidpp20ErrorCode(error),
|
||||
error_name,
|
||||
)
|
||||
raise exceptions.FeatureCallError(
|
||||
number=devnumber,
|
||||
|
|
@ -641,9 +828,15 @@ 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
|
||||
if reply_data[:2] == request_data[:2] and reply_data[4:5] == request_data[-1:]:
|
||||
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:
|
||||
# HID++ 2.0+ device, currently connected
|
||||
return ord(reply_data[2:3]) + ord(reply_data[3:4]) / 10.0
|
||||
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
|
||||
|
||||
if (
|
||||
report_id == HIDPP_SHORT_MESSAGE_ID
|
||||
|
|
@ -656,8 +849,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 paired device with that number
|
||||
logger.error("(%s) device %d error on ping request: unknown device", handle, devnumber)
|
||||
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)
|
||||
raise exceptions.NoSuchDevice(number=devnumber, request=request_id)
|
||||
|
||||
if notifications_hook:
|
||||
|
|
@ -675,17 +868,21 @@ 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, _MAX_READ_SIZE, 0)
|
||||
data = hidapi.read(ihandle, 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:
|
||||
|
|
@ -697,17 +894,22 @@ 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:
|
||||
"""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
|
||||
"""Return Solaar's HID++ Software ID (fixed, see SOLAAR_SOFTWARE_ID)."""
|
||||
return SOLAAR_SOFTWARE_ID
|
||||
|
|
|
|||
|
|
@ -123,7 +123,8 @@ def _lightspeed_receiver(product_id: int) -> dict:
|
|||
"usb_interface": 2,
|
||||
"receiver_kind": "lightspeed",
|
||||
"name": _("Lightspeed Receiver"),
|
||||
"may_unpair": False,
|
||||
"may_unpair": True,
|
||||
"re_pairs": False,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -174,6 +175,7 @@ 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)
|
||||
|
|
@ -202,6 +204,7 @@ KNOWN_RECEIVERS = {
|
|||
0xC541: LIGHTSPEED_RECEIVER_C541,
|
||||
0xC545: LIGHTSPEED_RECEIVER_C545,
|
||||
0xC547: LIGHTSPEED_RECEIVER_C547,
|
||||
0xC54D: LIGHTSPEED_RECEIVER_C54D,
|
||||
0xC517: EX100_27MHZ_RECEIVER_C517,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,591 @@
|
|||
## 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
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"""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
|
||||
|
|
@ -361,6 +361,53 @@ 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.
|
||||
|
||||
|
|
@ -598,6 +645,8 @@ class BatteryStatus(Flag):
|
|||
SLOW_RECHARGE = 0x04
|
||||
INVALID_BATTERY = 0x05
|
||||
THERMAL_ERROR = 0x06
|
||||
# Solaar internal — not a HID++ protocol value
|
||||
OFFLINE = 0xFF
|
||||
|
||||
|
||||
class BatteryLevelApproximation(IntEnum):
|
||||
|
|
@ -674,3 +723,17 @@ 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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
_D("Wireless Keyboard K360", codename="K360", protocol=2.0, wpid="4004")
|
||||
# incorrect _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=3)
|
||||
_D("K845 Mechanical Keyboard", codename="K845", usbid=0xC341, interface=1)
|
||||
|
||||
# Mice
|
||||
|
||||
|
|
@ -424,6 +424,7 @@ _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)
|
||||
|
|
@ -464,3 +465,5 @@ _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
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from __future__ import annotations
|
|||
|
||||
import errno
|
||||
import logging
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
|
|
@ -29,6 +30,7 @@ from typing import Protocol
|
|||
|
||||
from solaar import configuration
|
||||
|
||||
from . import base
|
||||
from . import descriptors
|
||||
from . import exceptions
|
||||
from . import hidpp10
|
||||
|
|
@ -38,6 +40,8 @@ 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
|
||||
|
||||
|
|
@ -74,6 +78,11 @@ 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,
|
||||
|
|
@ -124,12 +133,24 @@ 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
|
||||
|
|
@ -141,6 +162,7 @@ class 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._profiles = self._backlight = self._settings = None
|
||||
self.registers = []
|
||||
self.notification_flags = None
|
||||
|
|
@ -153,6 +175,7 @@ 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
|
||||
|
||||
|
|
@ -183,13 +206,16 @@ class Device:
|
|||
self.descriptor = (
|
||||
descriptors.get_btid(self.product_id) if self.bluetooth else descriptors.get_usbid(self.product_id)
|
||||
)
|
||||
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
|
||||
# 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()
|
||||
else:
|
||||
number = 0xFF
|
||||
self.number = number
|
||||
self.ping() # determine whether a direct-connected device is online
|
||||
raise e
|
||||
|
||||
if self.descriptor:
|
||||
self._name = self.descriptor.name
|
||||
|
|
@ -200,8 +226,10 @@ class Device:
|
|||
self._protocol = self.descriptor.protocol if self.descriptor.protocol else None
|
||||
self.registers = self.descriptor.registers if self.descriptor.registers else []
|
||||
|
||||
if self._protocol is not None:
|
||||
self.features = None if self._protocol < 2.0 else hidpp20.FeaturesArray(self)
|
||||
# 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)
|
||||
else:
|
||||
self.features = hidpp20.FeaturesArray(self) # may be a 2.0 device; if not, it will fix itself later
|
||||
|
||||
|
|
@ -216,16 +244,33 @@ class Device:
|
|||
@property
|
||||
def protocol(self):
|
||||
if not self._protocol:
|
||||
self.ping()
|
||||
return self._protocol or 0
|
||||
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
|
||||
|
||||
@property
|
||||
def codename(self):
|
||||
if not self._codename:
|
||||
if self.online and self.protocol >= 2.0:
|
||||
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.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
|
||||
if not self._codename and self.receiver:
|
||||
codename = self.receiver.device_codename(self.number)
|
||||
if codename:
|
||||
|
|
@ -237,17 +282,45 @@ class Device:
|
|||
@property
|
||||
def name(self):
|
||||
if not self._name:
|
||||
if self.online and self.protocol >= 2.0:
|
||||
self._name = _hidpp20.get_name(self)
|
||||
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)
|
||||
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:
|
||||
|
|
@ -268,6 +341,8 @@ 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 "?"
|
||||
|
|
@ -275,7 +350,9 @@ class Device:
|
|||
@property
|
||||
def firmware(self) -> tuple[common.FirmwareInfo]:
|
||||
if self._firmware is None and self.online:
|
||||
if self.protocol >= 2.0:
|
||||
if self.centurion:
|
||||
self._firmware = _hidpp20.get_firmware_centurion_sub(self) or _hidpp20.get_firmware_centurion(self)
|
||||
elif self.protocol >= 2.0:
|
||||
self._firmware = _hidpp20.get_firmware(self)
|
||||
else:
|
||||
self._firmware = _hidpp10.get_firmware(self)
|
||||
|
|
@ -283,6 +360,8 @@ class Device:
|
|||
|
||||
@property
|
||||
def serial(self):
|
||||
if not self._serial and self.online and self.centurion:
|
||||
self.get_ids()
|
||||
return self._serial or ""
|
||||
|
||||
@property
|
||||
|
|
@ -300,6 +379,13 @@ 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:
|
||||
|
|
@ -356,6 +442,65 @@ class Device:
|
|||
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)
|
||||
|
||||
|
|
@ -392,6 +537,15 @@ 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
|
||||
|
|
@ -455,15 +609,28 @@ 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: # need to set configuration pending flag in receiver
|
||||
elif was_active and self.receiver and not isinstance(self.receiver, CenturionReceiver):
|
||||
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:
|
||||
|
|
@ -527,9 +694,12 @@ 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),
|
||||
self.number,
|
||||
devnumber,
|
||||
request_id,
|
||||
*params,
|
||||
no_reply=no_reply,
|
||||
|
|
@ -539,11 +709,305 @@ 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)
|
||||
)
|
||||
|
|
@ -554,7 +1018,7 @@ class Device:
|
|||
protocol = None
|
||||
self.online = protocol is not None and self.present
|
||||
if protocol:
|
||||
self._protocol = protocol
|
||||
self._record_ping_protocol(handle, 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
|
||||
|
|
@ -563,12 +1027,16 @@ class Device:
|
|||
pass
|
||||
|
||||
def close(self):
|
||||
handle, self.handle = self.handle, None
|
||||
if self in Device.instances:
|
||||
Device.instances.remove(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)
|
||||
return handle and self.low_level.close(handle)
|
||||
|
||||
def __index__(self):
|
||||
|
|
@ -604,3 +1072,8 @@ 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
"""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)
|
||||
|
|
@ -73,9 +73,10 @@ 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 XTest (under X11) or uinput.
|
||||
# KeyPress, MouseScroll, and MouseClick actions use uinput.
|
||||
# For uinput to work the user must have write access for /dev/uinput.
|
||||
# To get this access run sudo setfacl -m u:${user}:rw /dev/uinput
|
||||
# The Solaar udev rule should set this up
|
||||
# Otherwise 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
|
||||
|
|
@ -85,8 +86,7 @@ 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
|
||||
# Xtest extension to X11 - provides input simulation, partly works under Wayland
|
||||
# Wayland - provides input simulation
|
||||
# uinput and evdev - provides input simulation
|
||||
|
||||
XK_KEYS: Dict[str, int] = keysymdef.key_symbols
|
||||
|
||||
|
|
@ -111,14 +111,11 @@ 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
|
||||
|
||||
|
||||
|
|
@ -170,7 +167,7 @@ class XkbStateRec(ctypes.Structure):
|
|||
|
||||
|
||||
def x11_setup():
|
||||
global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS, xtest_available
|
||||
global _x11, xdisplay, modifier_keycodes, NET_ACTIVE_WINDOW, NET_WM_PID, WM_CLASS
|
||||
if _x11 is not None:
|
||||
return _x11
|
||||
try:
|
||||
|
|
@ -187,7 +184,6 @@ 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
|
||||
|
||||
|
||||
|
|
@ -272,10 +268,6 @@ 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()
|
||||
|
|
@ -324,31 +316,6 @@ 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():
|
||||
|
|
@ -364,30 +331,11 @@ 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):
|
||||
|
|
@ -406,8 +354,6 @@ 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")
|
||||
|
|
@ -415,14 +361,6 @@ 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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
## 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]
|
||||
|
|
@ -189,7 +189,9 @@ class Hidpp10:
|
|||
write_register(device, Registers.THREE_LEDS, v1, v2)
|
||||
|
||||
def get_notification_flags(self, device: Device):
|
||||
return self._get_register(device, Registers.NOTIFICATIONS)
|
||||
flags = self._get_register(device, Registers.NOTIFICATIONS)
|
||||
if flags is not None:
|
||||
return NotificationFlag(flags)
|
||||
|
||||
def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag):
|
||||
assert device is not None
|
||||
|
|
|
|||
|
|
@ -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(Flag):
|
||||
class NotificationFlag(IntFlag):
|
||||
"""Some flags are used both by devices and receivers.
|
||||
|
||||
The Logitech documentation mentions that the first and last (third)
|
||||
|
|
@ -89,23 +89,14 @@ class NotificationFlag(Flag):
|
|||
"""
|
||||
|
||||
@classmethod
|
||||
def flag_names(cls, flag_bits: int) -> List[str]:
|
||||
def flag_names(cls, flags) -> List[str]:
|
||||
"""Extract the names of the flags from the integer."""
|
||||
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
|
||||
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]
|
||||
|
||||
NUMPAD_NUMERICAL_KEYS = 0x800000
|
||||
F_LOCK_STATUS = 0x400000
|
||||
|
|
@ -125,13 +116,13 @@ class NotificationFlag(Flag):
|
|||
THREED_GESTURE = 0x000001
|
||||
|
||||
|
||||
def flags_to_str(flag_bits: int | None, fallback: str) -> str:
|
||||
def flags_to_str(flags, fallback: str) -> str:
|
||||
flag_names = []
|
||||
if flag_bits is not None:
|
||||
if flag_bits == 0:
|
||||
if flags is not None and flags is not False:
|
||||
if flags.value == 0:
|
||||
flag_names = (fallback,)
|
||||
else:
|
||||
flag_names = NotificationFlag.flag_names(flag_bits)
|
||||
flag_names = NotificationFlag.flag_names(flags)
|
||||
return f"\n{' ':15}".join(sorted(flag_names))
|
||||
|
||||
|
||||
|
|
@ -156,11 +147,19 @@ 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.
|
||||
|
|
@ -213,7 +212,7 @@ class InfoSubRegisters(IntEnum):
|
|||
BOLT_DEVICE_NAME = 0x60 # 0x6N01, by connected device
|
||||
|
||||
|
||||
class DeviceFeature(Flag):
|
||||
class DeviceFeature(IntFlag):
|
||||
"""Features for devices.
|
||||
|
||||
Flags taken from
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ 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
|
||||
|
|
@ -35,10 +36,13 @@ 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
|
||||
|
|
@ -118,6 +122,7 @@ class MappingFlag(Flag):
|
|||
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
|
||||
|
|
@ -138,6 +143,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
|
||||
|
|
@ -161,23 +167,156 @@ 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
|
||||
response = self.device.feature_request(SupportedFeature.FEATURE_SET, 0x10, index)
|
||||
# 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
|
||||
if response:
|
||||
data = struct.unpack("!H", response[:2])[0]
|
||||
try:
|
||||
|
|
@ -193,7 +332,14 @@ class FeaturesArray(dict):
|
|||
if self._check():
|
||||
for index in range(self.count):
|
||||
feature = self.get_feature(index)
|
||||
yield 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
|
||||
|
||||
def get_feature_version(self, feature: NamedInt) -> Optional[int]:
|
||||
if self[feature]:
|
||||
|
|
@ -223,7 +369,16 @@ class FeaturesArray(dict):
|
|||
index = super().get(feature)
|
||||
if index is not None:
|
||||
return index
|
||||
response = self.device.request(0x0000, struct.pack("!H", feature))
|
||||
# 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
|
||||
if response:
|
||||
index = response[0]
|
||||
self[feature] = index if index else False
|
||||
|
|
@ -242,7 +397,7 @@ class FeaturesArray(dict):
|
|||
raise ValueError("Don't delete features from FeatureArray")
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.count
|
||||
return self.count + getattr(self, "_sub_feature_count", 0)
|
||||
|
||||
__bool__ = __nonzero__ = _check
|
||||
|
||||
|
|
@ -1014,22 +1169,37 @@ class LEDParam:
|
|||
ramp = "ramp"
|
||||
form = "form"
|
||||
saturation = "saturation"
|
||||
direction = "direction"
|
||||
|
||||
|
||||
class LedRampChoice(IntEnum):
|
||||
DEFAULT = 0
|
||||
YES = 1
|
||||
NO = 2
|
||||
# NamedInts (not IntEnum) so the GTK ComboBoxText shows readable labels.
|
||||
LedRampChoice = common.NamedInts(Default=0, Yes=1, No=2)
|
||||
|
||||
LedFormChoices = common.NamedInts(
|
||||
Default=0,
|
||||
Sine=1,
|
||||
Square=2,
|
||||
Triangle=3,
|
||||
Sawtooth=4,
|
||||
Shark_fin=5,
|
||||
Exponential=6,
|
||||
)
|
||||
|
||||
class LedFormChoices(IntEnum):
|
||||
DEFAULT = 0
|
||||
SINE = 1
|
||||
SQUARE = 2
|
||||
TRIANGLE = 3
|
||||
SAWTOOTH = 4
|
||||
SHARKFIN = 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
|
||||
}
|
||||
|
||||
|
||||
LEDParamSize = {
|
||||
|
|
@ -1040,33 +1210,77 @@ LEDParamSize = {
|
|||
LEDParam.ramp: 1,
|
||||
LEDParam.form: 1,
|
||||
LEDParam.saturation: 1,
|
||||
LEDParam.direction: 1,
|
||||
}
|
||||
# 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
|
||||
# Entry: [NamedInt, params, defaults, ranges] — trailing dicts optional.
|
||||
# ranges overrides a field's global min/max, e.g. period: (2, 200).
|
||||
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}],
|
||||
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},
|
||||
],
|
||||
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, _("CycleS")), {LEDParam.saturation: 1, LEDParam.period: 6, LEDParam.intensity: 8}],
|
||||
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}],
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
|
@ -1545,6 +1759,11 @@ 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.
|
||||
|
||||
|
|
@ -1574,6 +1793,27 @@ 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)
|
||||
|
|
@ -1625,6 +1865,9 @@ 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.
|
||||
|
||||
|
|
@ -1669,6 +1912,9 @@ 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"""
|
||||
|
|
@ -1689,10 +1935,10 @@ class Hidpp20:
|
|||
def get_keys(self, device: Device):
|
||||
# TODO: add here additional variants for other REPROG_CONTROLS
|
||||
count = None
|
||||
if SupportedFeature.REPROG_CONTROLS_V2 in device.features:
|
||||
if device.features and SupportedFeature.REPROG_CONTROLS_V2 in device.features:
|
||||
count = device.feature_request(SupportedFeature.REPROG_CONTROLS_V2)
|
||||
return KeysArrayV2(device, ord(count[:1]))
|
||||
elif SupportedFeature.REPROG_CONTROLS_V4 in device.features:
|
||||
elif device.features and SupportedFeature.REPROG_CONTROLS_V4 in device.features:
|
||||
count = device.feature_request(SupportedFeature.REPROG_CONTROLS_V4)
|
||||
return KeysArrayV4(device, ord(count[:1]))
|
||||
return None
|
||||
|
|
@ -1891,7 +2137,41 @@ 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)
|
||||
|
||||
|
||||
|
|
@ -1900,6 +2180,7 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1980,6 +2261,9 @@ 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])
|
||||
|
|
@ -2119,3 +2403,22 @@ class ForceSensingButtonArray(UserDict):
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ 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
|
||||
|
|
@ -61,6 +62,7 @@ class SupportedFeature(IntEnum):
|
|||
CONFIG_DEVICE_PROPS = 0x1806
|
||||
CHANGE_HOST = 0x1814
|
||||
HOSTS_INFO = 0x1815
|
||||
BLE_PRO_PRE_PAIRING = 0x1816
|
||||
BACKLIGHT = 0x1981
|
||||
BACKLIGHT2 = 0x1982
|
||||
BACKLIGHT3 = 0x1983
|
||||
|
|
@ -74,10 +76,16 @@ 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
|
||||
|
|
@ -110,6 +118,7 @@ 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
|
||||
|
|
@ -132,22 +141,98 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
## 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
|
||||
|
|
@ -52,6 +53,11 @@ 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
|
||||
|
|
@ -145,7 +151,8 @@ class EventsListener(threading.Thread):
|
|||
self.receiver.close()
|
||||
break
|
||||
if n:
|
||||
n = base.make_notification(*n)
|
||||
report_id, devnumber, data = n
|
||||
n = base.make_notification(report_id, devnumber, data)
|
||||
else:
|
||||
n = self._queued_notifications.get() # deliver any queued notifications
|
||||
if n:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,300 @@
|
|||
## 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)
|
||||
|
|
@ -34,6 +34,7 @@ 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
|
||||
|
|
@ -287,6 +288,9 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
|
|||
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])
|
||||
|
|
@ -319,9 +323,14 @@ 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 so need to change status
|
||||
if notification.data[1] == 1: # device is asking for software reconfiguration
|
||||
alert = Alert.NONE
|
||||
device.changed(active=True, alert=alert, reason=reason, push=True)
|
||||
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()
|
||||
else:
|
||||
logger.warning("%s: unknown WIRELESS %s", device, notification)
|
||||
|
||||
|
|
@ -419,6 +428,55 @@ 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
|
||||
|
||||
|
|
@ -433,7 +491,8 @@ def handle_pairing_lock(receiver: Receiver, notification: HIDPPNotification) ->
|
|||
receiver.pairing.new_device = None
|
||||
pair_error = ord(notification.data[:1])
|
||||
if pair_error:
|
||||
receiver.pairing.error = error_string = hidpp10_constants.PairingError(pair_error).name
|
||||
error_string = hidpp10_constants.PairingError(pair_error).label
|
||||
receiver.pairing.error = error_string
|
||||
receiver.pairing.new_device = None
|
||||
logger.warning("pairing error %d: %s", pair_error, error_string)
|
||||
receiver.changed(reason=reason)
|
||||
|
|
@ -453,7 +512,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).name
|
||||
receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error).label
|
||||
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
|
||||
receiver.changed(reason=reason)
|
||||
return True
|
||||
|
|
@ -495,7 +554,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).name
|
||||
receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error).label
|
||||
receiver.pairing.new_device = None
|
||||
logger.warning("pairing error %d: %s", pair_error, error_string)
|
||||
receiver.changed(reason=reason)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
## 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)
|
||||
|
|
@ -413,6 +413,31 @@ 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])
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
"""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)
|
||||
|
|
@ -0,0 +1,979 @@
|
|||
## 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 active→off 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)
|
||||
|
|
@ -27,6 +27,7 @@ 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__)
|
||||
|
|
@ -42,6 +43,7 @@ class Kind(IntEnum):
|
|||
MAP_CHOICE = 0x0A
|
||||
MULTIPLE_TOGGLE = 0x10
|
||||
PACKED_RANGE = 0x20
|
||||
GRAPHIC_EQ = 0x21
|
||||
MULTIPLE_RANGE = 0x40
|
||||
HETERO = 0x80
|
||||
MAP_RANGE = 0x102
|
||||
|
|
@ -59,6 +61,15 @@ class Setting:
|
|||
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
|
||||
|
|
@ -170,8 +181,16 @@ class Setting:
|
|||
logger.debug("%s: prepare write(%s) => %r", self.name, value, data_bytes)
|
||||
|
||||
reply = self._rw.write(self._device, data_bytes)
|
||||
if not reply:
|
||||
# tell whomever is calling that the write failed
|
||||
# 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,
|
||||
)
|
||||
return None
|
||||
|
||||
return value
|
||||
|
|
@ -627,7 +646,7 @@ class FeatureRW:
|
|||
read_prefix=b"",
|
||||
no_reply=False,
|
||||
):
|
||||
assert isinstance(feature, hidpp20_constants.SupportedFeature)
|
||||
assert isinstance(feature, (hidpp20_constants.SupportedFeature, CenturionCoreFeature))
|
||||
self.feature = feature
|
||||
self.read_fnid = read_fnid
|
||||
self.write_fnid = write_fnid
|
||||
|
|
@ -664,7 +683,7 @@ class FeatureRWMap(FeatureRW):
|
|||
key_byte_count=default_key_byte_count,
|
||||
no_reply=False,
|
||||
):
|
||||
assert isinstance(feature, hidpp20_constants.SupportedFeature)
|
||||
assert isinstance(feature, (hidpp20_constants.SupportedFeature, CenturionCoreFeature))
|
||||
self.feature = feature
|
||||
self.read_fnid = read_fnid
|
||||
self.write_fnid = write_fnid
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
import math
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from logitech_receiver import common
|
||||
|
|
@ -531,12 +532,13 @@ 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""):
|
||||
def __init__(self, min_value=0, max_value=255, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b"", signed=False):
|
||||
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:
|
||||
|
|
@ -545,7 +547,9 @@ 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])
|
||||
reply_value = common.bytes2int(
|
||||
reply_bytes[self.read_skip_byte_count : self.read_skip_byte_count + self._byte_count], signed=self._signed
|
||||
)
|
||||
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
|
||||
|
|
@ -554,14 +558,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)
|
||||
to_write = self.write_prefix_bytes + common.int2bytes(new_value, self._byte_count, signed=self._signed)
|
||||
# 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 isinstance(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
|
||||
|
||||
def compare(self, args, current):
|
||||
if len(args) == 1:
|
||||
|
|
@ -580,7 +584,8 @@ class HeteroValidator(Validator):
|
|||
return cls(**kwargs)
|
||||
|
||||
def __init__(self, data_class=None, options=None, readable=True):
|
||||
assert data_class is not None and options is not None
|
||||
# options=None for purely host-side settings — data_class handles bytes[0] as the ID.
|
||||
assert data_class is not None
|
||||
self.data_class = data_class
|
||||
self.options = options
|
||||
self.readable = readable
|
||||
|
|
@ -743,3 +748,114 @@ 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]
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ def _create_parser():
|
|||
)
|
||||
subparsers = parser.add_subparsers(title="actions", help="command-line action to perform")
|
||||
|
||||
sp = subparsers.add_parser("show", help="show information about devices")
|
||||
sp = subparsers.add_parser("show", description="Show information about device or all devices.")
|
||||
sp.add_argument(
|
||||
"device",
|
||||
nargs="?",
|
||||
|
|
@ -49,7 +49,7 @@ def _create_parser():
|
|||
)
|
||||
sp.set_defaults(action="show")
|
||||
|
||||
sp = subparsers.add_parser("probe", help="probe a receiver (debugging use only)")
|
||||
sp = subparsers.add_parser("probe", description="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,25 +57,26 @@ def _create_parser():
|
|||
|
||||
sp = subparsers.add_parser(
|
||||
"profiles",
|
||||
help="read or write onboard profiles",
|
||||
description="Print or load YAML dump of profiles.",
|
||||
epilog="Only works on active devices.",
|
||||
)
|
||||
sp.add_argument(
|
||||
"device",
|
||||
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",
|
||||
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",
|
||||
)
|
||||
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles")
|
||||
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles to load")
|
||||
sp.set_defaults(action="profiles")
|
||||
|
||||
sp = subparsers.add_parser(
|
||||
"config",
|
||||
help="read/write device-specific settings",
|
||||
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",
|
||||
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")
|
||||
|
|
@ -85,7 +86,7 @@ def _create_parser():
|
|||
|
||||
sp = subparsers.add_parser(
|
||||
"pair",
|
||||
help="pair a new device",
|
||||
description="Pair a new device with a receiver. The device has to be compatible with the receiver.",
|
||||
epilog="The Logitech Unifying Receiver supports up to 6 paired devices at the same time.",
|
||||
)
|
||||
sp.add_argument(
|
||||
|
|
@ -93,10 +94,30 @@ def _create_parser():
|
|||
)
|
||||
sp.set_defaults(action="pair")
|
||||
|
||||
sp = subparsers.add_parser("unpair", help="unpair a device")
|
||||
sp = subparsers.add_parser("unpair", description="Unpair a device from its receiver. Not all receivers allow unpairing.")
|
||||
sp.add_argument(
|
||||
"device",
|
||||
help="device to unpair; may be a device number (1..6), a serial number, " "or a substring of a device's name.",
|
||||
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.",
|
||||
)
|
||||
sp.set_defaults(action="unpair")
|
||||
|
||||
|
|
@ -127,7 +148,14 @@ def _receivers_and_devices(dev_path=None):
|
|||
continue
|
||||
try:
|
||||
if dev_info.isDevice:
|
||||
d = device.create_device(base, dev_info)
|
||||
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)
|
||||
else:
|
||||
d = receiver.create_receiver(base, dev_info)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,28 @@ 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:
|
||||
|
|
@ -70,7 +86,11 @@ def _print_setting_keyed(s, key, verbose=True):
|
|||
if k is None:
|
||||
print(s.name, "=? (key not found)")
|
||||
else:
|
||||
print("# possible values: one of [", ", ".join(str(v) for v in s.choices[k]), "]")
|
||||
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), "]")
|
||||
value = s.read(cached=False)
|
||||
if value is None:
|
||||
print(s.name, "= ? (failed to read from device)")
|
||||
|
|
@ -210,7 +230,8 @@ 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])
|
||||
application.run(yaml.safe_dump(argl))
|
||||
args = yaml.dump(argl)
|
||||
application.run([args])
|
||||
else:
|
||||
if dev.persister and setting.persist:
|
||||
dev.persister[setting.name] = setting._value
|
||||
|
|
@ -244,12 +265,21 @@ 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 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:
|
||||
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
|
||||
else:
|
||||
value = select_choice(args.extra_subkey, value_space, setting, key)
|
||||
args.extra_subkey = int(value)
|
||||
args.value_key = str(int(k))
|
||||
message = f"Setting {setting.name} of {dev.name} key {k!r} to {value!r}"
|
||||
result = setting.write_key_value(int(k), value, save=save)
|
||||
|
||||
|
|
@ -278,8 +308,6 @@ def set(dev, setting: SettingsProtocol, args, save):
|
|||
key = args.value_key
|
||||
all_keys = getattr(setting, "choices_universe", None)
|
||||
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, NamedInts) else to_int(key)
|
||||
print("S", args.extra2, key, type(all_keys), ikey)
|
||||
print("SS", args)
|
||||
if args.extra2 is None or to_int(args.extra2) is None:
|
||||
raise Exception(f"{setting.name}: setting needs an integer value, not {args.extra2}")
|
||||
if not setting._value: # ensure that there are values to look through
|
||||
|
|
@ -308,8 +336,14 @@ def set(dev, setting: SettingsProtocol, args, save):
|
|||
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("KIND", setting.kind)
|
||||
print(f"Setting {setting.name}, with kind {setting.kind.name}, not implemented")
|
||||
raise Exception("NotImplemented")
|
||||
|
||||
return result, message, value
|
||||
|
|
|
|||
|
|
@ -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) or 0
|
||||
old_notification_flags = _hidpp10.get_notification_flags(receiver)
|
||||
if not (old_notification_flags & hidpp10_constants.NotificationFlag.WIRELESS):
|
||||
_hidpp10.set_notification_flags(receiver, old_notification_flags | hidpp10_constants.NotificationFlag.WIRELESS)
|
||||
|
||||
|
|
@ -79,33 +79,36 @@ def run(receivers, args, find_receiver, _ignore):
|
|||
name = receiver.pairing.device_name
|
||||
authentication = receiver.pairing.device_authentication
|
||||
kind = receiver.pairing.device_kind
|
||||
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")
|
||||
if authentication is None: # no compatible device stepped forward
|
||||
print("No Bolt-compatible device requested pairing.")
|
||||
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)
|
||||
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)
|
||||
|
||||
else:
|
||||
receiver.set_lock(False, timeout=timeout)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ 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
|
||||
|
|
@ -35,36 +36,91 @@ _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}")
|
||||
print(" Serial :", receiver.serial)
|
||||
pending = hidpp10.get_configuration_pending_flags(receiver)
|
||||
if pending:
|
||||
print(f" C Pending : {pending:02x}")
|
||||
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}")
|
||||
if receiver.firmware:
|
||||
for f in receiver.firmware:
|
||||
print(" %-11s: %s" % (f.kind, f.version))
|
||||
|
||||
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(" 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.")
|
||||
|
||||
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})")
|
||||
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)))
|
||||
else:
|
||||
print(" Notifications: (none)")
|
||||
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}")
|
||||
|
||||
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)")
|
||||
|
||||
_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")
|
||||
|
||||
|
||||
def _battery_text(level) -> str:
|
||||
|
|
@ -91,9 +147,11 @@ 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:
|
||||
dev.ping()
|
||||
online = dev.ping()
|
||||
except exceptions.NoSuchDevice:
|
||||
print(f" {num}: Device not found" or dev.number)
|
||||
return
|
||||
|
|
@ -102,18 +160,29 @@ def _print_device(dev, num=None):
|
|||
print(f" {int(num or dev.number)}: {dev.name}")
|
||||
else:
|
||||
print(f"{dev.name}")
|
||||
print(" Device path :", dev.path)
|
||||
if dev.wpid:
|
||||
|
||||
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(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:
|
||||
print(f" Protocol : HID++ {dev.protocol:1.1f}")
|
||||
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}")
|
||||
else:
|
||||
print(" Protocol : unknown (device is offline)")
|
||||
if dev.polling_rate:
|
||||
if not is_centurion and dev.polling_rate:
|
||||
print(" Report Rate :", dev.polling_rate)
|
||||
print(" Serial number:", dev.serial)
|
||||
if dev.modelId:
|
||||
|
|
@ -127,12 +196,13 @@ def _print_device(dev, num=None):
|
|||
if dev.power_switch_location:
|
||||
print(f" The power switch is located on the {dev.power_switch_location}.")
|
||||
|
||||
if dev.online:
|
||||
# Skip HID++ 1.0 register reads for centurion devices — they don't support these
|
||||
if dev.online and not is_centurion:
|
||||
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:06X}).")
|
||||
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags.value:06X}).")
|
||||
else:
|
||||
print(" Notifications: (none).")
|
||||
device_features = _hidpp10.get_device_features(dev)
|
||||
|
|
@ -144,21 +214,56 @@ def _print_device(dev, num=None):
|
|||
print(" Features: (none)")
|
||||
|
||||
if dev.online and dev.features:
|
||||
print(f" Supports {len(dev.features)} HID++ 2.0 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:")
|
||||
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")
|
||||
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)))
|
||||
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))
|
||||
if feature == SupportedFeature.HIRES_WHEEL:
|
||||
wheel = _hidpp20.get_hires_wheel(dev)
|
||||
if wheel:
|
||||
|
|
@ -226,7 +331,25 @@ 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.DEVICE_FW_VERSION:
|
||||
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:
|
||||
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}")
|
||||
|
|
@ -247,6 +370,28 @@ 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)
|
||||
|
|
@ -259,14 +404,19 @@ def _print_device(dev, num=None):
|
|||
):
|
||||
v = setting.val_to_string(setting._device.persister.get(setting.name))
|
||||
print(f" {setting.label} (saved): {v}")
|
||||
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}")
|
||||
# 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}")
|
||||
|
||||
if dev.online and dev.keys:
|
||||
print(f" Has {len(dev.keys)} reprogrammable keys:")
|
||||
|
|
@ -320,7 +470,7 @@ def run(devices, args, find_receiver, find_device):
|
|||
|
||||
if device_name == "all":
|
||||
for d in devices:
|
||||
if isinstance(d, receiver.Receiver):
|
||||
if isinstance(d, (receiver.Receiver, CenturionReceiver)):
|
||||
_print_receiver(d)
|
||||
count = d.count()
|
||||
if count:
|
||||
|
|
@ -332,8 +482,8 @@ def run(devices, args, find_receiver, find_device):
|
|||
break
|
||||
print("")
|
||||
else:
|
||||
print("")
|
||||
_print_device(d)
|
||||
print("")
|
||||
return
|
||||
|
||||
dev = find_receiver(devices, device_name)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@
|
|||
|
||||
def run(receivers, args, find_receiver, find_device):
|
||||
assert receivers
|
||||
assert args.device
|
||||
|
||||
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"
|
||||
|
||||
device_name = args.device.lower()
|
||||
dev = next(find_device(receivers, device_name), None)
|
||||
|
|
@ -36,3 +41,50 @@ 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)")
|
||||
|
|
|
|||
|
|
@ -231,8 +231,16 @@ 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)
|
||||
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)
|
||||
)
|
||||
)
|
||||
|
||||
with configuration_lock:
|
||||
|
|
@ -240,8 +248,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.modelId else None
|
||||
unitId = device.unitId if device.modelId != "000000000000" else device._serial if device.unitId else None
|
||||
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
|
||||
for c in _config:
|
||||
if isinstance(c, _DeviceEntry) and match(device.wpid, device._serial, modelId, unitId, c):
|
||||
entry = c
|
||||
|
|
|
|||
|
|
@ -58,7 +58,10 @@ temp = tempfile.NamedTemporaryFile(prefix="Solaar_", mode="w", delete=True)
|
|||
|
||||
def create_parser():
|
||||
arg_parser = argparse.ArgumentParser(
|
||||
prog=NAME.lower(), epilog="For more information see https://pwr-solaar.github.io/Solaar"
|
||||
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",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-d",
|
||||
|
|
@ -73,7 +76,7 @@ def create_parser():
|
|||
action="store",
|
||||
dest="hidraw_path",
|
||||
metavar="PATH",
|
||||
help="unifying receiver to use; the first detected receiver if unspecified. Example: /dev/hidraw2",
|
||||
help="device or receiver path to use if needed. Example: /dev/hidraw2",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--restart-on-wake-up",
|
||||
|
|
|
|||
|
|
@ -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 if nfs else 0) & hidpp10_constants.NotificationFlag.WIRELESS.value):
|
||||
if not self.receiver.isDevice and (not nfs or not (nfs & hidpp10_constants.NotificationFlag.WIRELESS)):
|
||||
logger.warning(
|
||||
"Receiver on %s might not support connection notifications, GUI might not show its devices",
|
||||
self.receiver.path,
|
||||
|
|
@ -115,7 +115,6 @@ class SolaarListener(listener.EventsListener):
|
|||
reason or "",
|
||||
)
|
||||
else:
|
||||
device.ping()
|
||||
logger.info(
|
||||
"status_changed %r: %s %s (%X) %s",
|
||||
device,
|
||||
|
|
@ -149,6 +148,18 @@ 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
|
||||
|
|
@ -228,6 +239,102 @@ 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})>"
|
||||
|
||||
|
|
@ -255,18 +362,38 @@ 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_:
|
||||
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)
|
||||
_post_attach_device(receiver_)
|
||||
|
||||
if receiver_:
|
||||
rl = SolaarListener(receiver_, _status_callback)
|
||||
|
|
@ -314,10 +441,13 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,9 @@ def run_loop(
|
|||
|
||||
|
||||
def _status_changed(device, alert, reason, refresh=False):
|
||||
assert device is not None
|
||||
if device is None:
|
||||
logger.debug("status changed on nil device: %s (%s) %s", device, alert, reason)
|
||||
return
|
||||
logger.debug("status changed: %s (%s) %s", device, alert, reason)
|
||||
if alert is None:
|
||||
alert = Alert.NONE
|
||||
|
|
|
|||
|
|
@ -56,11 +56,12 @@ 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"]),
|
||||
(_("Additional Programming"), ["Filipe Laíns", "Peter F. Patel-Schneider", "Ken Sanislo"]),
|
||||
(_("GUI design"), ["Julien Gascard", "Daniel Pavel"]),
|
||||
(
|
||||
_("Testing"),
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ def unpair(window, device):
|
|||
device_number = device.number
|
||||
|
||||
try:
|
||||
del receiver[device_number]
|
||||
# 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)
|
||||
except Exception:
|
||||
common.error_dialog(common.ErrorReason.UNPAIR, device)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ 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
|
||||
|
|
@ -56,7 +57,8 @@ 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))
|
||||
GLib.idle_add(_update_setting_item, sb, v, online, sensitive, True, priority=99)
|
||||
null_okay = not getattr(getattr(s, "_validator", None), "readable", True)
|
||||
GLib.idle_add(_update_setting_item, sb, v, online, sensitive, null_okay, priority=99)
|
||||
|
||||
ui_async(_do_read, setting, force_read, sbox, device_is_online, sensitive)
|
||||
|
||||
|
|
@ -144,6 +146,13 @@ 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):
|
||||
|
|
@ -545,7 +554,94 @@ 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)
|
||||
|
|
@ -566,6 +662,17 @@ 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)
|
||||
|
|
@ -576,6 +683,15 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
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)
|
||||
|
|
@ -592,11 +708,14 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
result[str(k)] = (r << 16) | (g << 8) | b
|
||||
else:
|
||||
result[str(k)] = box.get_value()
|
||||
result = hidpp20.LEDEffectSetting(**result)
|
||||
data_class = getattr(self.sbox.setting._validator, "data_class", hidpp20.LEDEffectSetting)
|
||||
result = data_class(**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:
|
||||
|
|
@ -608,9 +727,7 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
box.set_rgba(rgba)
|
||||
else:
|
||||
box.set_value(v)
|
||||
else:
|
||||
self.sbox._failed.set_visible(True)
|
||||
self.setup_visibles(value.ID if value is not None else 0)
|
||||
self.setup_visibles(id_)
|
||||
|
||||
def setup_visibles(self, id_):
|
||||
fields = self.sbox.setting.fields_map[id_][1] if id_ in self.sbox.setting.fields_map else {}
|
||||
|
|
@ -620,15 +737,61 @@ class HeteroKeyControl(Gtk.HBox, Control):
|
|||
lblbox.set_visible(visible)
|
||||
box.set_visible(visible)
|
||||
|
||||
def changed(self, control):
|
||||
def changed(self, control, *_args):
|
||||
# *_args swallows the extra GParamSpec passed by Gtk.Switch's
|
||||
# "notify::active" signal — other field signals pass just (widget,).
|
||||
if self.get_sensitive() and control.get_sensitive():
|
||||
if "ID" in self._items and control == self._items["ID"][1]:
|
||||
self.setup_visibles(int(self._items["ID"][1].get_value()))
|
||||
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)
|
||||
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")
|
||||
|
|
@ -648,6 +811,144 @@ _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()
|
||||
|
|
@ -665,6 +966,37 @@ 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
|
||||
|
||||
|
||||
|
|
@ -701,7 +1033,24 @@ def _create_sbox(s, _device):
|
|||
change.set_sensitive(True)
|
||||
change.connect(GtkSignal.CLICKED.value, _change_click, sbox)
|
||||
|
||||
if s.kind == settings.Kind.TOGGLE:
|
||||
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:
|
||||
control = ToggleControl(sbox)
|
||||
elif s.kind == settings.Kind.RANGE:
|
||||
control = SliderControl(sbox)
|
||||
|
|
@ -715,6 +1064,8 @@ 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:
|
||||
|
|
@ -733,8 +1084,10 @@ 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)
|
||||
sbox._control.set_sensitive(sensitive is True and can_function)
|
||||
_change_icon(sensitive, sbox._change_icon)
|
||||
sbox._failed.set_visible(is_online)
|
||||
return
|
||||
|
|
@ -744,8 +1097,12 @@ 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)
|
||||
sbox._control.set_sensitive(sensitive is True and can_function)
|
||||
_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):
|
||||
|
|
@ -805,6 +1162,7 @@ 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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ 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
|
||||
|
|
@ -1675,6 +1676,15 @@ 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
global _default_theme, _has_level_icons, _has_padded_level_icons
|
||||
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,6 +49,10 @@ 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):
|
||||
|
|
@ -68,16 +72,45 @@ 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 "battery-missing" + ("-symbolic" if gtk.battery_icons_style == "symbolic" else "")
|
||||
return f"battery-missing{suffix}"
|
||||
|
||||
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 "",
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def lux(level=None):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
## 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")
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
## 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)
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
## 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)
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
## 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
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
## 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)
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
## 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
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
## 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()})
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
## 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)
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
## 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)
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
## 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,
|
||||
)
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
## 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)",
|
||||
)
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
## 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",
|
||||
)
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
## 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 — A↔Q, W↔Z, 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",
|
||||
)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
## 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",
|
||||
)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
## 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",
|
||||
)
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
## 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",
|
||||
)
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
## 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",
|
||||
)
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
## 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()
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
## 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.
|
||||
"""
|
||||
...
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
## 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(),
|
||||
}
|
||||
|
|
@ -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,7 +536,13 @@ def _update_details(button):
|
|||
if device.product_id:
|
||||
yield _("Product ID"), f"{LOGITECH_VENDOR_ID:04x}:" + device.product_id
|
||||
hid_version = device.protocol
|
||||
yield _("Protocol"), f"HID++ {hid_version:1.1f}" if hid_version else _("Unknown")
|
||||
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")
|
||||
if read_all and device.polling_rate:
|
||||
yield _("Polling rate"), device.polling_rate
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.1.17
|
||||
1.1.20rc2
|
||||
|
|
|
|||
35
po/de.po
35
po/de.po
|
|
@ -6,8 +6,8 @@
|
|||
msgid ""
|
||||
msgstr "Project-Id-Version: solaar 1.0.1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-28 17:40+0100\n"
|
||||
"PO-Revision-Date: 2023-07-07 11:06+0200\n"
|
||||
"POT-Creation-Date: 2026-05-11 19:38+0200\n"
|
||||
"PO-Revision-Date: 2026-05-11 19:35+0200\n"
|
||||
"Last-Translator: Daniel Frost <one@frostinfo.de>\n"
|
||||
"Language-Team: none\n"
|
||||
"Language: de\n"
|
||||
|
|
@ -16,7 +16,7 @@ msgstr "Project-Id-Version: solaar 1.0.1\n"
|
|||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Launchpad-Export-Date: 2021-04-17 16:52+0000\n"
|
||||
"X-Generator: Poedit 3.3.2\n"
|
||||
"X-Generator: Poedit 3.4.2\n"
|
||||
|
||||
#: lib/logitech_receiver/base_usb.py:46
|
||||
msgid "Bolt Receiver"
|
||||
|
|
@ -753,27 +753,28 @@ msgstr "Mit zwei Fingern vom oberen Bildschirmrand wischen"
|
|||
#: lib/logitech_receiver/settings_templates.py:944
|
||||
#: lib/logitech_receiver/settings_templates.py:948
|
||||
msgid "Pinch to zoom out; spread to zoom in."
|
||||
msgstr "Kneifgeste zum Verkleinern; Spreizgeste zum Vergrößern."
|
||||
msgstr "Zusammenziehen-Geste zum Verkleinern; Auseinanderbewegen-Geste zum "
|
||||
"Vergrößern."
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:944
|
||||
msgid "Zoom with two fingers."
|
||||
msgstr "Vergrößerung mit zwei Fingern ändern."
|
||||
msgstr "Zoom mit zwei Fingern."
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:945
|
||||
msgid "Pinch to zoom out."
|
||||
msgstr "Kneifgeste zum Verkleinern."
|
||||
msgstr "Zusammenziehen-Geste zum Verkleinern."
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:946
|
||||
msgid "Spread to zoom in."
|
||||
msgstr "Spreizgeste zum Vergrößern."
|
||||
msgstr "Auseinanderbewegen-Geste zum Vergrößern."
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:947
|
||||
msgid "Zoom with three fingers."
|
||||
msgstr "Vergrößerung mit drei Fingern ändern."
|
||||
msgstr "Zoom mit drei Fingern."
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:948
|
||||
msgid "Zoom with two fingers"
|
||||
msgstr "Vergrößerung mit zwei Fingern"
|
||||
msgstr "Zoom mit zwei Fingern"
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:966
|
||||
msgid "Pixel zone"
|
||||
|
|
@ -1240,7 +1241,7 @@ msgstr "Gerät"
|
|||
|
||||
#: lib/solaar/ui/diversion_rules.py:524 lib/solaar/ui/diversion_rules.py:2297
|
||||
msgid "Host"
|
||||
msgstr ""
|
||||
msgstr "Host"
|
||||
|
||||
#: lib/solaar/ui/diversion_rules.py:525 lib/solaar/ui/diversion_rules.py:2339
|
||||
msgid "Setting"
|
||||
|
|
@ -1480,7 +1481,7 @@ msgstr "Maustaste"
|
|||
|
||||
#: lib/solaar/ui/diversion_rules.py:1922
|
||||
msgid "Count and Action"
|
||||
msgstr ""
|
||||
msgstr "Anzahl und Aktion"
|
||||
|
||||
#: lib/solaar/ui/diversion_rules.py:1972
|
||||
msgid "Execute a command with arguments."
|
||||
|
|
@ -1518,11 +1519,11 @@ msgstr "Das Gerät ist aktiv und die Einstellungen können geändert werden."
|
|||
|
||||
#: lib/solaar/ui/diversion_rules.py:2266
|
||||
msgid "Device that originated the current notification."
|
||||
msgstr ""
|
||||
msgstr "Gerät, von dem die aktuelle Benachrichtigung stammt."
|
||||
|
||||
#: lib/solaar/ui/diversion_rules.py:2280
|
||||
msgid "Name of host computer."
|
||||
msgstr ""
|
||||
msgstr "Name des Host-Computers."
|
||||
|
||||
#: lib/solaar/ui/diversion_rules.py:2347
|
||||
msgid "Value"
|
||||
|
|
@ -1665,7 +1666,7 @@ msgstr "\n"
|
|||
|
||||
#: lib/solaar/ui/tray.py:58
|
||||
msgid "No supported device found"
|
||||
msgstr ""
|
||||
msgstr "Kein unterstütztes Gerät gefunden"
|
||||
|
||||
#: lib/solaar/ui/tray.py:64 lib/solaar/ui/window.py:319
|
||||
#, python-format
|
||||
|
|
@ -1882,9 +1883,6 @@ msgstr "%(light_level)d Lux"
|
|||
#~ msgid "DPI Sliding Adjustment"
|
||||
#~ msgstr "DPI-Anpassung durch seitliches Bewegen"
|
||||
|
||||
#~ msgid "Device originated the current notification."
|
||||
#~ msgstr "Die aktuelle Benachrichtigung entspringt dem Gerät."
|
||||
|
||||
#~ msgid "Diverted key or button depressed or released.\n"
|
||||
#~ "Use the Key/Button Diversion setting to divert keys and buttons."
|
||||
#~ msgstr "Umgeleitete Taste oder Maustaste gedrückt bzw. losgelassen.\n"
|
||||
|
|
@ -1926,9 +1924,6 @@ msgstr "%(light_level)d Lux"
|
|||
#~ msgstr "Wenn Sie Solaar gerade neu installiert haben, versuchen Sie "
|
||||
#~ "den Empfänger aus- und wieder einzustecken."
|
||||
|
||||
#~ msgid "No Logitech device found"
|
||||
#~ msgstr "Kein Logitechgerät gefunden"
|
||||
|
||||
#~ msgid "No Logitech receiver found"
|
||||
#~ msgstr "Kein Logitech-Empfänger gefunden"
|
||||
|
||||
|
|
|
|||
2318
po/pt_BR.po
2318
po/pt_BR.po
File diff suppressed because it is too large
Load Diff
|
|
@ -5,9 +5,9 @@
|
|||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr "Project-Id-Version: solaar 1.1.17rc3\n"
|
||||
msgstr "Project-Id-Version: solaar 1.1.19\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-01 21:43+0300\n"
|
||||
"POT-Creation-Date: 2026-03-05 14:00+0200\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
|
@ -34,7 +34,7 @@ msgstr ""
|
|||
msgid "Lightspeed Receiver"
|
||||
msgstr ""
|
||||
|
||||
#: lib/logitech_receiver/base_usb.py:135
|
||||
#: lib/logitech_receiver/base_usb.py:136
|
||||
msgid "EX100 Receiver 27 Mhz"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -354,20 +354,20 @@ msgid "ADC measurement notification"
|
|||
msgstr ""
|
||||
|
||||
#: lib/logitech_receiver/notifications.py:428
|
||||
#: lib/logitech_receiver/notifications.py:483
|
||||
#: lib/logitech_receiver/notifications.py:484
|
||||
msgid "pairing lock is closed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/logitech_receiver/notifications.py:428
|
||||
#: lib/logitech_receiver/notifications.py:483
|
||||
#: lib/logitech_receiver/notifications.py:484
|
||||
msgid "pairing lock is open"
|
||||
msgstr ""
|
||||
|
||||
#: lib/logitech_receiver/notifications.py:446
|
||||
#: lib/logitech_receiver/notifications.py:447
|
||||
msgid "discovery lock is closed"
|
||||
msgstr ""
|
||||
|
||||
#: lib/logitech_receiver/notifications.py:446
|
||||
#: lib/logitech_receiver/notifications.py:447
|
||||
msgid "discovery lock is open"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -1151,7 +1151,7 @@ msgid "Force Sensing Button"
|
|||
msgstr ""
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:1814
|
||||
msgid "Haptic Feeback Level"
|
||||
msgid "Haptic Feedback Level"
|
||||
msgstr ""
|
||||
|
||||
#: lib/logitech_receiver/settings_templates.py:1815
|
||||
|
|
@ -1175,19 +1175,19 @@ msgid "Manages Logitech receivers,\n"
|
|||
"keyboards, mice, and tablets."
|
||||
msgstr ""
|
||||
|
||||
#: lib/solaar/ui/about/model.py:63
|
||||
#: lib/solaar/ui/about/model.py:64
|
||||
msgid "Additional Programming"
|
||||
msgstr ""
|
||||
|
||||
#: lib/solaar/ui/about/model.py:64
|
||||
#: lib/solaar/ui/about/model.py:65
|
||||
msgid "GUI design"
|
||||
msgstr ""
|
||||
|
||||
#: lib/solaar/ui/about/model.py:66
|
||||
#: lib/solaar/ui/about/model.py:67
|
||||
msgid "Testing"
|
||||
msgstr ""
|
||||
|
||||
#: lib/solaar/ui/about/model.py:74
|
||||
#: lib/solaar/ui/about/model.py:75
|
||||
msgid "Logitech documentation"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue