Compare commits

...

187 Commits

Author SHA1 Message Date
Peter F. Patel-Schneider
4bda869542 release 1.1.19 2026-01-08 12:32:44 -05:00
Ekaterine Papava
ce1adc7b03 po: Add Georgian translation 2026-01-07 22:50:17 -05:00
Peter F. Patel-Schneider
fc68521731 release 1.1.19rc1 2025-12-29 09:47:07 -05:00
Peter F. Patel-Schneider
c87730f1eb tests: remove test that doesn't work in older Pythons 2025-12-24 08:11:04 -05:00
Peter F. Patel-Schneider
76346cd5aa docs: update help messages for CLI commands 2025-12-21 18:03:53 -05:00
Peter F. Patel-Schneider
705279097f cli: allow to change LED settings 2025-12-21 18:03:53 -05:00
Peter F. Patel-Schneider
36377fdd5a doc: instructions on running solaar show in 1.1.18 2025-12-21 18:01:53 -05:00
Peter F. Patel-Schneider
a427c66dc1 tools: add python3-devel to install-dnf in Makefile 2025-12-20 12:31:17 -05:00
Peter F. Patel-Schneider
f0c64f5fb3 tools: improve flags for hidconsole 2025-12-19 10:55:50 -05:00
Peter F. Patel-Schneider
a0e19282ec tools: hidconsole can send an HID command non-interactively 2025-12-19 09:38:17 -05:00
Peter F. Patel-Schneider
e999b12246 receiver: add info about new lightspeed receiver 2025-12-17 15:52:17 -05:00
Peter F. Patel-Schneider
d3216ea57a device: remove debugging statement 2025-12-17 15:44:14 -05:00
Nick
0b6a5fa108 Update Swedish translation
Fix one translation
2025-12-17 11:47:25 -05:00
Peter F. Patel-Schneider
ff23601183 device: fix bug when showing details about direct-connected device 2025-12-16 15:23:06 -05:00
Daniel Nylander
aaabb5d811 Updated Swedish translation 2025-12-13 07:53:04 -05:00
Peter Dave Hello
ccf1ac5b6d po: Update zh_TW Traditional Chinese locale 2025-12-13 07:52:20 -05:00
Peter F. Patel-Schneider
46da00e214 release: drop testing of Python before 3.13 2025-12-12 04:55:14 -05:00
Gabriel Ebner
1dd1ace327 cli: Fix crash when showing notification flags. (#3070) 2025-12-12 04:54:10 -05:00
Peter F. Patel-Schneider
a87ae59a93 release 1.1.18 2025-12-11 15:28:01 -05:00
Peter F. Patel-Schneider
8298db0891 receiver: fix crash when turning notification flags into strings 2025-12-11 15:23:06 -05:00
Peter F. Patel-Schneider
93e90f4894 docs: update pairing documentation 2025-12-10 14:03:44 -05:00
Peter F. Patel-Schneider
4c63bdb6ee show better pairing errors (#3063)
* Fix: Show pairing error str

Fixes #2827

* device: also show bolt pairing errors

---------

Co-authored-by: MattHag <16444067+MattHag@users.noreply.github.com>
2025-12-10 11:18:50 -05:00
MattHag
441d608ca0 test_pair_window: Simplify tests by cleaning up receiver mock (#2899)
Remove unnecessary parameter for these tests.
2025-12-10 10:50:07 -05:00
Peter F. Patel-Schneider
2e549371ef device: fix typing issue with notification flags 2025-12-10 07:31:48 -05:00
Peter F. Patel-Schneider
4d2a42d541 doc: better formating of release notes 2025-12-09 15:41:02 -05:00
Peter F. Patel-Schneider
24fe69924b release 1.1.17 2025-12-09 14:30:04 -05:00
Peter F. Patel-Schneider
ebc4536b02 gui: add dark and updated icons 2025-12-05 08:54:10 -05:00
Peter F. Patel-Schneider
50fed28b3b device: permit onboard profiles data version 5 2025-12-04 04:04:27 -05:00
MistificaT0r
d77cc9ad65 update Russian translation 2025-12-01 14:17:57 -05:00
Matthaiks
a440eb7fcc Update Polish translation 2025-12-01 14:15:46 -05:00
Peter F. Patel-Schneider
f9ce65fd18 release 1.1.17rc3 2025-12-01 13:19:51 -05:00
Peter F. Patel-Schneider
b3ea338f86 release 1.1.17rc2 2025-12-01 13:15:26 -05:00
Peter F. Patel-Schneider
d7b2834697 release 1.1.17rc1 2025-12-01 13:15:26 -05:00
Morton Fox
ff67790f18 docs: Remove unnecessary 'is' 2025-11-27 20:54:51 -05:00
Peter F. Patel-Schneider
3686920e85 settings: add onboard profiles warning to sensitivity tooltip 2025-11-25 09:26:45 -05:00
Peter F. Patel-Schneider
dbc97d96d5 cli: better error messages for solaar profile 2025-11-19 13:50:54 -05:00
Peter F. Patel-Schneider
dc3412c83b device: remove Solaar name for mice with WPID 4008 2025-11-15 19:32:18 -05:00
Peter F. Patel-Schneider
29bf463509 settings: prevent lock failure when showing debug messages 2025-11-15 18:21:18 -05:00
Din Tort
f12632b45e docs: Fix typo in installation instructions for pip/pipx 2025-11-15 15:46:03 -05:00
Nils Bergmann
7963215fa2 docs: swap commands
The commands were probably meant to be the other way around. 

And I also think there is something missing in the sentence "and then run
`pip install --user solaar` or `pipx install --system-site-packages solaar` or
If you are using pipx add the `` flag.", but I was not sure.
2025-11-15 14:06:02 -05:00
danicc097
817c90e561 replace color picker (#3028)
* replace color picker
2025-11-15 11:05:59 -05:00
Peter F. Patel-Schneider
0fd262424e settings: add setting for HAPTIC feature 2025-11-12 14:50:46 -05:00
Peter F. Patel-Schneider
97b6b958c8 settings: expand new settings type 2025-11-12 14:33:34 -05:00
Peter F. Patel-Schneider
f739331dc2 settings: add new settings type for structure-backed setting 2025-11-12 14:33:34 -05:00
Ruffin
ec5b406909 misc: Use PATH instead of hardcoded absolute paths (#3014)
for better shebang and multi platform support
2025-11-04 03:28:10 +09:00
Peter F. Patel-Schneider
cff0110f81 settings: add scroll ratchet force setting 2025-11-04 03:25:12 +09:00
Peter F. Patel-Schneider
b96d0bbe0b rules: fix debug messages for MouseClick rule 2025-11-02 21:01:33 +09:00
Peter F. Patel-Schneider
96adf6a026 rules: improve debug message for rule evaluation 2025-11-02 20:58:44 +09:00
Din Tort
e906b83103 MacOS: app wrapper and launch agent scripts #1244 (#3008)
* Add scripts to create macOS app bundle and LaunchAgent #1244

This change introduces two new helper scripts for macOS users to improve integration with the operating system.

The `create-macos-app.sh` script builds a minimal `.app` wrapper for Solaar. This allows Solaar to be treated as a standard macOS application, enabling it to request necessary permissions, such as input monitoring, and to be discoverable by the OS. The script generates the required directory structure, a wrapper executable to launch Solaar, a standard `Info.plist` file, and an application icon from the source PNG.

The `create-macos-launchagent.sh` script sets up a LaunchAgent to automatically start Solaar at login and keep it running in the background. This ensures that the Solaar process is always available for device management without requiring manual intervention from the user. The script also configures log file locations for standard output and error streams.

Together, these scripts provide a more native and user-friendly experience for Solaar on macOS.

https://github.com/pwr-Solaar/Solaar/issues/1244

* Fix icon in script to create macOS app bundle #1244

https://github.com/pwr-Solaar/Solaar/issues/1244

* build: Remove hardcoded Homebrew path for solaar in macOS script

The `create-macos-app.sh` script previously defaulted to a hardcoded Homebrew path for the `solaar` executable. This change removes the specific path, defaulting instead to `solaar`.

This modification makes the script more flexible and robust. It allows the system's `PATH` to resolve the location of the `solaar` binary, accommodating various installation methods beyond a fixed Homebrew directory. The explicit check for the executable's existence is also removed, as the script will now rely on the command being available in the shell's environment, which is a more standard approach.

* refactor(macos): Improve solaar executable lookup in launchagent script

This change improves how the `create-macos-launchagent.sh` script locates the `solaar` executable.

Previously, the script defaulted to a hardcoded path (`/opt/homebrew/bin/solaar`) and would exit with an error if that specific file was not found and executable. This was too restrictive and failed in environments where `solaar` is installed in a different location, such as through `pipx` or in standard system paths like `/usr/local/bin`.

Now, the script defaults the `SOLAAR_PATH` to just `solaar` and uses the `command -v` utility to find the executable in the user's `PATH`. This allows the system to resolve the correct location of the `solaar` binary automatically. If the executable cannot be found in the `PATH`, the script now issues a warning instead of exiting, and proceeds to use the provided `SOLAAR_PATH` value in the generated LaunchAgent plist. This makes the script more flexible and robust for different installation methods.

* refactor(macos): Improve solaar executable lookup in launchagent script

This change improves how the `create-macos-launchagent.sh` script locates the `solaar` executable.

Previously, the script defaulted to a hardcoded path (`/opt/homebrew/bin/solaar`) and would exit with an error if that specific file was not found and executable. This was too restrictive and failed in environments where `solaar` is installed in a different location, such as through `pipx` or in standard system paths like `/usr/local/bin`.

Now, the script defaults the `SOLAAR_PATH` to just `solaar` and uses the `command -v` utility to find the executable in the user's `PATH`. This allows the system to resolve the correct location of the `solaar` binary automatically. If the executable cannot be found in the `PATH`, the script now issues a warning instead of exiting, and proceeds to use the provided `SOLAAR_PATH` value in the generated LaunchAgent plist. This makes the script more flexible and robust for different installation methods.

* refactor: Improve Solaar path resolution in macOS helper scripts

This change improves the robustness of the macOS helper scripts by ensuring the Solaar executable is found before proceeding.

Previously, `create-macos-launchagent.sh` would only issue a warning and continue with a potentially invalid path if the `solaar` command was not found. `create-macos-app.sh` had a typo (`SOLAR_PATH`) and lacked a check altogether.

Now, both scripts (`create-macos-app.sh` and `create-macos-launchagent.sh`) will:
- Correctly check if the `solaar` executable exists in the system's `PATH`.
- Use the resolved, absolute path to the executable to avoid ambiguity.
- Exit with an error if the executable cannot be found, preventing the creation of a broken app bundle or launch agent.

* feat(macos): Relaunch app to fix tray icon and add Dock icon

This change modifies the macOS app bundle creation script to improve the application's behavior and user experience on macOS.

Previously, when Solaar was launched as a standard `.app` bundle, macOS restrictions sometimes prevented the GTK tray icon from appearing correctly. This change introduces a workaround in the wrapper script. The application now relaunches itself as a detached background process on the first launch. This new, detached process is no longer subject to the same `.app` bundle restrictions, allowing the tray icon to be created reliably.

Additionally, the `LSUIElement` key in the `Info.plist` is set to `false`. This makes Solaar a regular application with an icon in the Dock, which is the standard behavior expected by most macOS users for a graphical application.

* docs: Explain macOS Python.app Dock icon limitation

This change adds a comment to the `create-macos-app.sh` script to explain why the Solaar application may still show a Dock icon on macOS, even when it is not desired for a background utility.

On macOS, Python often runs as `Python.app`, which has its own `Info.plist` file. This built-in `Info.plist` takes precedence over the one generated for the Solaar app bundle. As a result, settings like `LSUIElement=true`, which would normally hide the Dock icon, are overridden. This comment clarifies that this behavior is a known limitation of the Python distribution on macOS and not a bug in the Solaar packaging script.

* docs: Add instructions for creating macOS launcher options

This update enhances the installation guide by including steps to set up macOS launchers for Solaar. Two options are provided:
- LaunchAgent for automatic background execution and crash recovery.
- App launcher for manual addition to Login Items.
2025-10-30 07:20:55 -04:00
Peter F. Patel-Schneider
2cb5fa4b97 settings: ignore hidden features 2025-10-27 15:27:13 -04:00
Peter F. Patel-Schneider
44a647499c ui: don't pop up window in response to ADC changes 2025-10-25 10:41:35 -04:00
Peter F. Patel-Schneider
02e05e46b0 doc: update bug report template 2025-10-25 10:21:45 -04:00
Joey Riches
bc41badff1 appstream: Fixed malformed file by adding closing tag 2025-10-24 12:43:32 -04:00
Peter F. Patel-Schneider
5c94cf4d9f device: fix error in low-level request for device with no recevier 2025-10-23 12:58:01 -04:00
Peter F. Patel-Schneider
e2a9206d78 docs: update files shown in documentation 2025-10-23 09:28:52 -04:00
Peter F. Patel-Schneider
e6ecf94deb docs: make known issues more prominent 2025-10-23 08:16:05 -04:00
Peter F. Patel-Schneider
b8ccec37ed release 1.1.16 2025-10-23 07:58:44 -04:00
Peter F. Patel-Schneider
51630421b2 device: add new flags for reprogrammable keys feature 2025-10-22 16:46:40 -04:00
Peter F. Patel-Schneider
ab517577b5 device: correctly handle missing battery feature 2025-10-22 16:46:40 -04:00
Peter F. Patel-Schneider
15ee0662f1 release 1.1.15 2025-10-21 09:06:28 -04:00
Peter F. Patel-Schneider
2c070e92b3 release 1.1.15rc2 2025-10-21 09:06:28 -04:00
Peter F. Patel-Schneider
a866de47fb udev: correctly re-raise access exception 2025-10-17 19:41:23 -04:00
NaviMen
632d4dd5a0 Update i18n.md
- Ukrainian: Олександр Афанасьєв
2025-10-17 19:40:48 -04:00
Peter F. Patel-Schneider
f942dbec41 device: add special keys from Logitech 2025-10-16 20:57:15 -04:00
Олександр Афанасьєв
6fa8ec6b86 i18n: Add and complete Ukrainian translation (uk) 2025-10-16 20:54:12 -04:00
MistificaT0r
137dd6b2ff update Russian translation 2025-10-14 19:25:57 -04:00
Matthaiks
bdb0e9589b Update Polish translation 2025-10-10 19:12:19 -04:00
Peter F. Patel-Schneider
0335dd003c release 1.1.15rc2 2025-10-10 09:19:31 -04:00
Peter F. Patel-Schneider
8bea0121cc release 1.1.15rc1 2025-10-10 09:19:31 -04:00
Peter F. Patel-Schneider
783bd5e4da device: fix bug with unknown tasks 2025-10-05 08:05:15 -04:00
ian-jeong
68514d83c1 fix: center labels and remove buggy entry resizing logic 2025-09-30 10:42:25 -04:00
ian-jeong
6409fc2832 fix: correct spelling of 'completion' in diversion_rules.py 2025-09-30 10:42:25 -04:00
Peter F. Patel-Schneider
dc28ab61c2 device: add shape keys from Key POP Icon 2025-09-30 10:34:23 -04:00
Peter F. Patel-Schneider
94f4c3230b rules: Device and Action rule conditions match on codename and name 2025-09-30 10:23:50 -04:00
Rok Mandeljc
62aaeac595 GitHub CI: fix and re-enable macOS tests with python 3.13
Fix the `Failed to load shared library 'libglib-2.0.0.dylib' referenced
by the typelib` error by adding the common Homebrew's shared library
directory (i.e., `$(brew --prefix)/lib`) to the dyld library search path.
This ensures that all Homebrew-installed shared libraries are discoverable
via `dlopen()`-like loading mechanism. (Previously, only directory
with `libhidapi` shared library was explicitly added to search path).

Use `DYLD_FALLBACK_LIBRARY_PATH` instead of `DYLD_LIBRARY_PATH` to
register the Homebrew library directory as a fallback search path
rather than preferred/default one. In general, this should be
preferred way of modifying library search path with 3rd-party
installations, even though it probably bears no real difference in
this particular scenario.
2025-09-14 18:52:00 -04:00
Peter F. Patel-Schneider
694caf635e docs: give uninstallation file correct name 2025-09-08 13:28:39 -04:00
Stephen Kitt
924684b610 Apply uaccess rules on all actions other than remove
These actions now need to react to "change" uevents, not only "add"
uevents. The recommendation from
5a8b9fd49f/NEWS (L22)
is to apply them on all non-"remove" uevents, which is what is done
here.

See also https://bugs.debian.org/1112660

Signed-off-by: Stephen Kitt <steve@sk2.org>
2025-09-08 10:11:52 -04:00
Peter F. Patel-Schneider
abc5a31c15 install: fix bug in apt install target 2025-09-08 09:55:21 -04:00
Salim B
3c11eff55a docs(metainfo): Add link to source repo
cf. https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines#url
2025-09-08 09:54:36 -04:00
MattHag
001dce7ef5 GitHub CI: Disable latest Python tests on macOS
Related #2892
2025-09-08 09:52:51 -04:00
Nick
3f24d52f7a Update Swedish translation 2025-09-08 09:52:04 -04:00
MattHag
2a363a6388 Unsupported locale: Fall back to English (#2891)
* Unsupported locale: Fall back to English

For any locale that is not supported, automatically fall back to no
translation, so it is English.

Fixes #2889

* Update lib/solaar/i18n.py

---------

Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
2025-09-08 09:44:45 -04:00
Peter F. Patel-Schneider
bebadc219c fixes battery setting when device is not available (#2890)
* device: fix battery setting when device is not available
2025-06-09 05:31:52 -04:00
Peter F. Patel-Schneider
694513832d device: report symbolic names for pairing errors (#2886)
* device: report symbolic names for pairing errors

* testing: fix testing of notifications
2025-05-31 08:12:42 -04:00
Peter F. Patel-Schneider
1a9725f540 doc: update status of hid_parser 2025-05-21 11:52:31 -04:00
mattdale77
c7a54cf7ec Update installation.md
Fix link to the desktop file
2025-05-21 11:51:50 -04:00
Alban Browaeys
7066ec40c9 device: Fix listing hidpp10 devices - bytes vs string concatenation (#2856)
* Fix listing hidpp10 devices - bytes vs string concatenation

Fix error concatenating a bytes with a string.

Closes #2855.

Fixes: 5e0c85a6 receiver: Refactor extraction of serial and max. devices

* Update lib/logitech_receiver/receiver.py

---------

Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
2025-04-22 08:47:26 -04:00
Peter F. Patel-Schneider
abea1c4341 device: add present flag, unset when internal error occurs, set when notification appears 2025-04-22 08:45:55 -04:00
Peter F. Patel-Schneider
217b9360e6 device: pause setting up features when error occurs; use ADC message to signal connection and disconnection 2025-04-22 08:45:55 -04:00
Ágata Leuck
33a06ac834 docs: add G604 mouse details 2025-04-13 20:29:39 -04:00
Alban Browaeys
03cfa12852 Fix listing of hidpp10 peripherals
The Flag enum was applied the value method twice. remove the value
method call from the set_flag_bits in  device.py. There is no such value
call in receiver.py set_flag_bits in the same commit so I believe this
was a mistake.
With this fix the LX7 mouse is properly enumerated over a Logitech
C-BT44 Receiver (seen as EX100, compatible 27MHz FastRF protocol)

Close #2850.

Fixes: 72c9dfc5 Remove NamedInts: Convert NotificationFlag to flag
2025-04-07 10:29:41 -04:00
Alban Browaeys
41ba24eee2 Complete DEVICE_FEATURES to DeviceFeature transition for hidpp10 devices
Fixes solaar show.

Fixes: 378175f9 Remove NamedInts: Convert DeviceFeature to flag
2025-04-07 10:24:13 -04:00
Alban Browaeys
ed596666ee Fix NOTIFICATION_FLAG to NotificationFlag transition leftovers
Fixes "solaar show" for hidpp10 device (or at least for 27MHz FastRF
hidpp10 peripherals).

Fixes: 72c9dfc5 Remove NamedInts: Convert NotificationFlag to flag
2025-04-07 10:24:13 -04:00
Alban Browaeys
16bd8126b6 Fix github workflow stopping all matrix jobs when one of them fails 2025-04-05 20:37:33 -04:00
Alban Browaeys
17150658bf Fix ubuntu github CI
python 3.13 brings pygobject >= 3.52.1 which requires libgirepository 2.0.
Add gobject-introspection has libgirepository-2.0-dev does not depends
on it and it is required by PyGObject.

Closes #2857.
2025-04-05 20:32:29 -04:00
Rolf Leggewie
f0ad2692b8 Update index.md
improve the wording describing the limitations set by the differences between the devices
2025-03-30 20:50:23 -04:00
Rolf Leggewie
d033a3c8fc Update index.md - add missing word 2025-03-30 20:50:23 -04:00
Peter F. Patel-Schneider
1613584c6a docs: python documentation appears to be broken so don't set it up 2025-03-29 09:35:33 -04:00
ml-
ebf8493e72 docs: add information for MX Anywhere 3 for Business 2025-03-29 09:24:07 -04:00
Peter F. Patel-Schneider
7a5a67c394 docs: improve documentation on onboard profiles 2025-03-29 09:22:59 -04:00
Peter F. Patel-Schneider
3fcc75f892 settings: use correct LOD values for extended adjustable dpi 2025-03-25 10:52:56 -04:00
Matija Kljajić
7b28423572 docs(i18n): mention Serbian translation 2025-03-21 12:20:00 -04:00
Peter F. Patel-Schneider
198067519d settings: better support RGB Effects - not readable 2025-03-03 14:11:09 -05:00
Peter F. Patel-Schneider
94155dbbf1 cli: fix crash when asking for help about config 2025-03-03 14:09:22 -05:00
Peter F. Patel-Schneider
64943c90d9 ui: fix error when updating ChoiceControlBig box 2025-02-26 16:08:23 -05:00
Purvi Das
637e562699 Adding uninstallation docs 2025-02-22 15:31:05 -05:00
Peter F. Patel-Schneider
9b5e416755 receiver: Handle unknown power switch locations again
Ensure functionality via unit test.
2025-02-22 15:29:35 -05:00
Peter F. Patel-Schneider
d8f321a5e9 ui: correctly handle selection of [empty] in rule editor 2025-02-11 17:37:21 -05:00
SeongWoo Chung
df2df301e2 macOS: handle HIDError in hidapi.hidapi_impl._match() (#2804)
* Fix: handle `HIDError` in `hidapi.hidapi_impl._match()`
The `open_path()` function may raise `HIDError` but `_match()`, its caller, does not handle it, unlike other cases after opening the path. This affects to the device enumeration process in `hidapi.enumerate()`, causing some devices to be randomly undiscovered.

* hidapi: revert to independent checking of long and short HID++ features with an extensible refactor

* Refactor: too long line
2025-02-09 12:31:20 -05:00
Peter F. Patel-Schneider
cefc502db9 ui: give ghost devices a path 2025-02-08 15:30:37 -05:00
Peter F. Patel-Schneider
7d4f787344 ui: guard against typeerror when setting the value of a control box 2025-02-04 10:22:28 -05:00
Peter F. Patel-Schneider
e297f90e79 device: recover from errors in ping 2025-02-04 10:22:28 -05:00
Peter F. Patel-Schneider
20e20ce827 diversion: replace spaces by underscores when looking up features 2025-02-04 09:10:00 -05:00
DomHeadroom
90ab457ebe Rewrote string concatenation/format with f strings 2025-01-29 08:40:14 -05:00
daviddavid
297ccb9cc1 Fix logo not showing in about dialog box 2025-01-29 08:35:53 -05:00
Dominik 'Rathann' Mierzejewski
d95068c3f5 make typing-extensions dependency mandatory
It's imported unconditionally in:
lib/solaar/ui/about/presenter.py:19
lib/logitech_receiver/hidpp10.py:22
lib/logitech_receiver/hidpp20.py:35

Fixes 469c04f (committed as part of #2428).
2025-01-10 17:00:03 -05:00
MattHag
3de575b697 Fix: Properly ignore unsupported locale
Generalize exception to catch anything locale error.

Related #2507
Fixes #2765
2025-01-10 16:58:17 -05:00
vulpes2
41e652609b hidapi: skip unsupported devices and handle exception on open 2025-01-02 17:18:39 -05:00
vulpes2
73ad98d5e4 Ignore macOS junk files and pipenv config 2025-01-02 17:18:39 -05:00
Peter F. Patel-Schneider
b9557a46b6 docs: mention typing dependency 2025-01-02 15:05:12 -05:00
Peter F. Patel-Schneider
5a03433f86 tests: fix ui desktop notifications test 2025-01-02 15:04:41 -05:00
MattHag
81567a98df hidpp20: Remove dependency to NamedInts
Replace ButtonBehaviors and ButtonMappingTypes with IntEnum.

Related #2273
2025-01-02 11:06:04 -05:00
MattHag
bd00cc97ad Estimate accurate battery level for some rechargable devices (#2745)
* battery: Extract battery level estimation into function

Test battery level estimation with sharp edges based on predefined
steps. Rename variable for clarity and add type hints.

Related #2744

* battery: Interpolate battery level for some rechargeable devices in percent

Estimate remaining battery based on measured battery voltage. Use linear
interpolation to achieve a smooth line instead of 10 percent jumps.

Fixes #2744
2025-01-02 10:58:07 -05:00
Peter F. Patel-Schneider
3192fa1a34 testing: upgrade desktop notifications tests to take notifications availability into account 2025-01-02 10:47:53 -05:00
MattHag
9af67f0e1d Update tests to run on Python 3.13 2025-01-02 10:47:03 -05:00
MattHag
382e0b6797 solaar: Remove outdated logger enabled checks
Logger enabled checks clutter the code unnecessarily. The checks are
now handled in a custom logger class. Eventually they can be completely
removed in the future.

Related #2664
2025-01-02 09:26:31 -05:00
MattHag
f5d80c30fa solaar/ui: Remove outdated logger enabled checks
Logger enabled checks clutter the code unnecessarily. The checks are
now handled in a custom logger class. Eventually they can be completely
removed in the future.

Related #2664
2025-01-02 09:26:31 -05:00
MattHag
636f736765 solaar/cli: Remove outdated logger enabled checks
Logger enabled checks clutter the code unnecessarily. The checks are
now handled in a custom logger class. Eventually they can be completely
removed in the future.

Related #2664
2025-01-02 09:26:31 -05:00
MattHag
e9a58fb3e0 Introduce GTK signal types
Related #2273
2025-01-02 08:29:32 -05:00
MattHag
ab52c4a7c0 Introduce error types
Related #2273
2025-01-02 08:29:32 -05:00
MattHag
3bf8a85866 hidapi: Remove outdated logger enabled checks
Logger enabled checks clutter the code unnecessarily. The checks are
now handled in a custom logger class. Eventually they can be completely
removed in the future.

Related #2664
2025-01-02 08:23:09 -05:00
MattHag
d42524dec9 notification: Remove alias for SupportedFeature
Related #2273
2025-01-02 08:05:02 -05:00
MattHag
8894463f64 notification: Refactor process_device_notification
Simplify code and unify interfaces and type hints.

Related #2273
2025-01-02 08:05:02 -05:00
MattHag
15aaba2802 notification: Refactor process_receiver_notification
Remove repeated code pattern with generalized implementation. Aim
towards easy extension and code readability.

Related #2273
2025-01-02 08:05:02 -05:00
MattHag
fa3a9bc5b3 notification: Refactor receiver event handling
Split processing of receiver notification into smaller functions.
Extract handler functions for every receiver notification for simple
maintenence and testability.

Related #2273
2025-01-02 08:05:02 -05:00
MattHag
33c057feff Introduce custom logger
Implement logger that internally checks if log level is enabled. Thus,
unnecessary log message computation costs are avoid, when logging is
disabled and logging code can be cut in half.

Related #2663
2025-01-02 07:56:46 -05:00
MattHag
810cda917a Refactor notifications
Add type hints and reasonable variable names.

Related #2711
2025-01-01 13:48:14 -05:00
MattHag
64ac437b7f Rename variable to full name notification
Related #2711
2025-01-01 13:48:14 -05:00
MattHag
207be464a5 Test notifications
Fixes #2711
2025-01-01 13:48:14 -05:00
MattHag
f28a923d15 receiver: Test extraction of serial and max. devices
Related #2273
2025-01-01 12:52:33 -05:00
MattHag
5e0c85a6d7 receiver: Refactor extraction of serial and max. devices
Related #2273
2025-01-01 12:52:33 -05:00
MattHag
800d3498f4 Update release notes: Add Bluetooth macOS support with 1.15
Related #2729
2025-01-01 11:55:10 -05:00
MattHag
918b584b95 macOS: Fix int.from_bytes, int.to_bytes for show.py
Related #2729
2025-01-01 11:55:10 -05:00
MattHag
83c380f85b macOS: Remove udev rule warning
Warning about missing udev rules do not apply to macOS.

Related #2729
2025-01-01 11:55:10 -05:00
MattHag
fd17e47382 macOS: Add support for Bluetooth devices
Use hidapi on macOS to communicate and configure Logitech peripherals
connected via Bluetooth. This brings macOS device support on the same
level as Linux. However, some rules might not be supported yet on macOS.

Tested with MX Keys and MX Master 3S.

Fixes #2729
2025-01-01 11:55:10 -05:00
cameronaw13
88787ab705 settings: add back and forward mouseclick actions 2025-01-01 11:46:05 -05:00
MattHag
1a3f4dab36 Speedup lookup of known receivers
Refactor get_receiver_info. Replacing data structure of known receivers
to avoid for loop, when an efficient dictionary lookup is possible.

Related #2273
2025-01-01 11:33:07 -05:00
MattHag
3186d880fc base: Refactor device filtering
Related #2273
2025-01-01 11:20:28 -05:00
MattHag
1e6af7fa7d base: Reorder private functions and variable definitions
Related #2273
2025-01-01 11:20:28 -05:00
MattHag
5d86c74df4 base: Turn filter_products_of_interest into a public function
Related #2273
2025-01-01 11:20:28 -05:00
MattHag
5cf7cbfd5d base: Improve tests of known receivers
Related #2273
2025-01-01 11:20:28 -05:00
some_developer
96364d2df3 Refactor InfoSubRegisters: Use IntEnum in favour of NamedInts 2025-01-01 10:46:04 -05:00
MattHag
378175f98f Remove NamedInts: Convert DeviceFeature to flag
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
72c9dfc50c Remove NamedInts: Convert NotificationFlag to flag
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
571cdb5f2d Prepare refactoring of NotificationFlag
Ensure behavior stays the same.

Related #2273
2025-01-01 10:46:04 -05:00
MattHag
5f5c7cdcce Fixes on top of refactoring 2025-01-01 10:46:04 -05:00
MattHag
ad3916e1b8 Fix KeyFlag conversion 2025-01-01 10:46:04 -05:00
MattHag
6903eeefcd Remove NamedInts: Convert LedFormChoices to enum
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
c9d7d7234a charge status: Refactor to enum and move to module of use
The charge status is solely used in the hiddpp20 module, thus put it
into this module.

Related #2273
2025-01-01 10:46:04 -05:00
MattHag
c34fd3c2b0 Remove NamedInts: Convert LedRampChoice to flag
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
b19c886426 Remove NamedInts: Convert HorizontalScroll to enum
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
96c9cc2aa4 Remove NamedInts: Convert PowerSwitchLocation to flag
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
d27f7285e0 Remove NamedInts: Convert MappingFlag to flag
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
5c736e9154 mapping flag: Move to module of use
The mapping flags are solely used in hiddpp20 module, thus put them into
this module.

Related #2273
2025-01-01 10:46:04 -05:00
MattHag
7c91d0b2db Remove NamedInts: Convert ActionId to enum
This data is not in use currently.

Related #2273
2025-01-01 10:46:04 -05:00
MattHag
5ca9c0a6ba Remove NamedInts: Convert Spec to enum
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
f54eeb7998 Remove NamedInts: Convert KeyFlag to Flag
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
0bf7a78553 Add type hints
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
267b0a723d key flags: Move to module of use
The key flags are solely used in hiddpp20 module, thus put them into the
module.

Related #2273
2025-01-01 10:46:04 -05:00
MattHag
5a9725ee17 Add type hints
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
4c160d1723 Remove NamedInts: Convert Task to enum
Refactor code related to task and task ID.

Related #2273
2025-01-01 10:46:04 -05:00
MattHag
b74e789715 Remove NamedInts: Convert Column to enum
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
0d7fc46a81 settings: Add docstrings and type hint
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
8bc42d20fb Enforce rules on RuleComponentUI subclasses
Enforce create_widgets and collect_values.

Related #2273
2025-01-01 10:46:04 -05:00
MattHag
dd13993ff3 Simplify settings UI class
Classes shouldn't don't need to know about other settings classes.

Related #2273
2025-01-01 10:46:04 -05:00
MattHag
cdaffce463 Refactor: Remove diversion alias
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
dfb4ccc93f type hints: Introduce settings protocol
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
3636ed78bb Refactor: Convert Kind to IntEnum
Related #2273
2025-01-01 10:46:04 -05:00
MattHag
03de6fb276 Split up huge settings module
- Move validators into their own module.
- Convert Kind to IntEnum

Related #2273
2025-01-01 10:46:04 -05:00
Peter F. Patel-Schneider
789d35450c solaar: don't close temp file until after CLI call 2025-01-01 10:40:07 -05:00
MattHag
62e8aacd9f Remove Python 2 specific path handling
Related #2273
2025-01-01 10:18:44 -05:00
Nick
8eb0aec3e8 i18n: Swedish translations in .desktop files 2025-01-01 10:15:42 -05:00
MattHag
8a0fc13f23 Test arg parse 2025-01-01 10:14:10 -05:00
MattHag
41768d9616 Test receiver notification info 2025-01-01 10:14:10 -05:00
Nick
a822b2f237 Update Swedish translation 2025-01-01 10:06:53 -05:00
Jan Fader
dfafe15575 delete temp-file in case help-actions too 2025-01-01 10:04:44 -05:00
Jan Fader
e6c833f635 delete tmpfile on close for cli 2025-01-01 10:04:44 -05:00
118 changed files with 14110 additions and 6108 deletions

View File

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

View File

@@ -8,7 +8,8 @@ jobs:
strategy:
matrix:
python-version: [3.8, 3.12]
python-version: [3.13]
fail-fast: false
steps:
- name: Checkout
@@ -19,10 +20,16 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
- name: Install Ubuntu dependencies
- name: Install Ubuntu dependencies for python 3.8
if: matrix.python-version == '3.8'
run: |
make install_apt
- name: Install Ubuntu dependencies for python 3.13
if: matrix.python-version == '3.13'
run: |
make install_apt_python3.13
- name: Install Python dependencies
run: |
make install_pip PIP_ARGS='.["test"]'
@@ -47,7 +54,8 @@ jobs:
strategy:
matrix:
python-version: [3.8, 3.12]
python-version: [3.13]
fail-fast: false
steps:
- name: Checkout
@@ -61,12 +69,15 @@ jobs:
- name: Set up macOS dependencies
run: |
make install_brew
- name: Add Homebrew's library directory to dyld search path
run: |
echo "DYLD_FALLBACK_LIBRARY_PATH=$(brew --prefix)/lib:$DYLD_FALLBACK_LIBRARY_PATH" >> $GITHUB_ENV
- name: Install Python dependencies
run: |
make install_pip PIP_ARGS='.["test"]'
- name: Run tests on macOS
run: |
export DYLD_LIBRARY_PATH=$(brew --prefix hidapi)/lib:$DYLD_LIBRARY_PATH && pytest --cov --cov-report=xml
pytest --cov --cov-report=xml
- name: Upload coverage to Codecov
if: github.ref == 'refs/heads/master'
uses: codecov/codecov-action@v4.5.0

6
.gitignore vendored
View File

@@ -23,3 +23,9 @@ __pycache__/
/po/*.po~
/.idea/
.DS_Store
._*
Pipfile
Pipfile.lock

View File

@@ -1,3 +1,127 @@
# 1.1.19
* New Georgian translation
* Remove test that doesn't work in older Pythons
* Update help messages for CLI commands
* Allow solaar config to change LED settings
* Add python3-devel to install-dnf in Makefile
* hidconsole can send an HID command non-interactively
* Add info about new lightspeed receiver
* Update Swedish and zh_TW translation
* Fix bug when showing details about direct-connected device
* Drop testing of Python before 3.13
* Fix crash in solaar show when showing notification flags. (#3070)
# 1.1.18
* Fix crash when showing notification flags
# 1.1.17
* Add dark icons
* Permit onboard profiles version 5
* Update Russian and Polish translations
* Add onboard profiles warning to sensitivity tooltip
* Better error messages for solaar profile
* Remove Solaar name for mice with WPID 4008
* Prevent lock failure when showing debug messages
* Replace color picker (#3028)
* Add setting for HAPTIC feature
* Add setting to adjust force needed for force-sensing buttons
* Expand new settings type
* Add new settings type for structure-backed setting
* Use PATH instead of hardcoded absolute paths (#3014)
* Add scroll ratchet force setting
* Fix debug messages for MouseClick rule
* Improve debug message for rule evaluation
* App wrapper and launch agent scripts for MacOS
* Ignore hidden features
* Don't pop up window in response to ADC changes
* Update bug report template
* Fixed malformed doc file by adding closing tag
* Fix error in low-level request for device with no recevier
* Update documentation files
# 1.1.16
* Add new flags for reprogrammable keys feature
* Correctly handle missing battery feature
# 1.1.15
* Correctly re-raise permissions exception
* Add several new special keys and tasks
* Update several translations
* Center labels and remove buggy entry resizing logic
* Add shape keys from Key POP Icon
* Device and Action rule conditions match on codename and name
* Fix listing hidpp10 devices - bytes vs string concatenation (#2856)
* Add present flag, unset when internal error occurs, set when notification appears
* Pause setting up features when error occurs; use ADC message to signal connection and disconnection
* Fix listing of hidpp10 peripherals
* Complete DEVICE_FEATURES to DeviceFeature transition for hidpp10 devices
* Fix NOTIFICATION_FLAG to NotificationFlag transition leftovers
* Fix github workflow stopping all matrix jobs when one of them fails
* Fix ubuntu github CI
* Update index.md
* Python documentation appears to be broken so don't set it up
* Improve documentation on onboard profiles
* Use correct LOD values for extended adjustable dpi
* Better support RGB Effects - not readable
* Fix crash when asking for help about config
* Fix error when updating ChoiceControlBig box
* Add uninstallation docs
* Handle unknown power switch locations again
* Correctly handle selection of [empty] in rule editor
* Handle `HIDError` in `hidapi.hidapi_impl._match()` (#2804)
* Give ghost devices a path
* Guard against typeerror when setting the value of a control box
* Recover from errors in ping
* Replace spaces by underscores when looking up features
* Rewrote string concatenation/format with f strings
* Fix logo not showing in about dialog box
* Make typing-extensions dependency mandatory
* Properly ignore unsupported locale
* hidapi: skip unsupported devices and handle exception on open
* Ignore macOS junk files and pipenv config
* Fix ui desktop notifications test
* hidpp20: Remove dependency to NamedInts
* Estimate accurate battery level for some rechargable devices (#2745)
* Upgrade desktop notifications tests to take notifications availability into account
* Update tests to run on Python 3.13
* Remove outdated logger enabled checks
* Introduce GTK signal types
* Introduce error types
* Remove alias for SupportedFeature
* Refactor process_device_notification
* Refactor process_receiver_notification
* Refactor receiver event handling
* Introduce custom logger
* Refactor notifications
* Rename variable to full name notification
* Test notifications
* Test extraction of serial and max. devices
* Refactor extraction of serial and max. devices
* macOS: Fix int.from_bytes, int.to_bytes for show.py
* macOS: Remove udev rule warning
* macOS: Add support for Bluetooth devices
* Add back and forward mouseclick actions
* Speedup lookup of known receivers
* Refactor device filtering
* Reorder private functions and variable definitions
* Turn filter_products_of_interest into a public function
* Improve tests of known receivers
* Refactor: Remove NamedInts and move enums where used
* Add docstrings and type hints
* Enforce rules on RuleComponentUI subclasses
* Simplify settings UI class
* Remove diversion alias
* Refactor: Convert Kind to IntEnum
* Split up huge settings module
* Remove Python 2 specific path handling
* Delete logging temp file on exit
* Update Swedish translation
# 1.1.14
* Handle fake feature enums in show

View File

@@ -19,9 +19,14 @@ install_apt:
sudo apt update
sudo apt install libdbus-1-dev libglib2.0-dev libgtk-3-dev libgirepository1.0-dev
install_apt_python3.13:
@echo "Installing Solaar dependencies via apt"
sudo apt update
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"

View File

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

View File

@@ -1,5 +1,27 @@
# Notes on Major Changes in Releases
## Version 1.1.18
* Solaar is only guaranteed to work in Python 3.13 or later.
## Version 1.1.17
* Several new features have been added related to the MX Master 4
* The scroll ratchet force can be adjusted
* The force required to click the button under your thumb can be adjusted
* The haptic force can be adjusted
* Haptic feeback can be triggered by commands like `solaar config 'mx master 4' haptic-play 'HAPPY ALER'`
## Version 1.1.16
* Two bugs that were affecting users in 1.1.15 are fixed.
## Version 1.1.15
* Some key names have been changed to match Logitech names. Rules that use removed names will no longer work and will end up with a key of 0.
* Device and Action rule conditions match on device codename and name
* Solaar supports configuration of Bluetooth devices on macOS.
## Version 1.1.13
* Solaar will drop support for Python 3.7 immediately after version 1.1.13.

View File

@@ -24,20 +24,7 @@ def init_paths():
import os.path
import sys
# Python 3 might have problems converting back to UTF-8 in case of Unicode surrogates
decoded_path = None
try:
decoded_path = sys.path[0]
sys.path[0].encode(sys.getfilesystemencoding())
except UnicodeError:
sys.stderr.write(
"ERROR: Solaar cannot recognize encoding of filesystem path, "
"this may happen due to non UTF-8 characters in the pathname.\n"
)
sys.exit(1)
root = os.path.join(os.path.realpath(decoded_path), "..")
root = os.path.join(os.path.realpath(sys.path[0]), "..")
prefix = os.path.normpath(root)
src_lib = os.path.join(prefix, "lib")
share_lib = os.path.join(prefix, "share", "solaar", "lib")

132
docs/LICENSE.txt Normal file
View File

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

View File

@@ -56,12 +56,12 @@ Bluetooth product ID.
Solaar is able to pair and unpair devices with
receivers as supported by the device and receiver.
For Unifying 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,13 +69,15 @@ 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
@@ -178,7 +180,7 @@ For more information on Mouse Gestures rule conditions see
Solaar uses the standard Logitech names for keyboard keys. Some Logitech keyboards have different icons on some of their keys and have different functionality than suggested by these names.
Solaar is uses the standard US keyboard layout. This currently only matters for the `Per-key Lighting` setting. Users who want to have the key names for this setting reflect the keyboard layout that they use can create and edit `~/.config/solaar/keys.yaml` which contains a YAML dictionary of key names and locations. For example, switching the `Y` and `Z` keys can be done as:
Solaar uses the standard US keyboard layout. This currently only matters for the `Per-key Lighting` setting. Users who want to have the key names for this setting reflect the keyboard layout that they use can create and edit `~/.config/solaar/keys.yaml` which contains a YAML dictionary of key names and locations. For example, switching the `Y` and `Z` keys can be done as:
Z: 25
Y: 26
@@ -186,13 +188,13 @@ Solaar is uses the standard US keyboard layout. This currently only matters for
This is an experimental feature and may be modified or even eliminated.
### Device Profiles
### Onboard Profiles
Some mice store one or more profiles, which control aspects of the behavior of the device.
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.
Profiles can control the rate at which the mouse reports movement, the resolution of the the movement reports, what the mouse buttons do, and its LED effects. Solaar can dump the entire set of profiles into a YAML file can load an entire set of profiles from a file. Users can edit the file to effect changes to the profiles. Solaar has a setting that switches between profiles or disables all profiles. When switching between profiles or using a button to change resolution Solaar keeps track of the changes in the settings for these features.
When an onboard profile is active it may not be possible to change the aspects that the profile controls. This is often seen for the Report Rate setting. For some devices it is possible to make changes to the Sensitivity setting and to LED settings. These changes are likely to only be temporary and may be overridden when the device reconnects or when Solaar is restarted. This is in keeping with the intent of Onboard Profiles as controlling the device behavior. To make the changes to these settings permanent it is necessary to disable onboard profiles. Alternatively, multiple profiles can be set up as described below and these settings controlled by switching between the profiles.
When profiles are active changes cannot be made to the Report Rate setting. Changes can be made to the Sensitivity setting and to LED settings. To keep the profile values make these setting ignored.
Solaar can dump the entire set of profiles into a YAML file and can load the entire set of profiles from a file. Users can edit the file to effect changes to the profiles.
A profile file has some bookkeeping information, including profile version and the name of the device, and a sequence of profiles.
@@ -247,7 +249,7 @@ See USB_HID_KEYCODES and HID_CONSUMERCODES in lib/logitech_receiver/special_keys
Buttons can also execute macros but Solaar does not provide any support for macros.
Lighting information is a sequence of lighting effects, with the first for the logo LEDs and the second for the side LEDs.
Lighting information is a sequence of lighting effects, with the first usually for the logo LEDs and the second usually for the side LEDs.
The fields possible in an effect are:
- ID: The kind of effect:

View File

@@ -209,6 +209,7 @@ so what is important for support is the USB WPID or Bluetooth model ID.
| Device | WPID | HID++ |
|------------------------------|------|-------|
| G604 Wireless Gaming Mouse | 4085 | 4.2 |
| PRO X Superlight Wireless | 4093 | 4.2 |
### Trackballs (Unifying)

View File

@@ -0,0 +1,84 @@
solaar version 03cfa128
1: G604 Wireless Gaming Mouse
Device path : /dev/hidraw6
WPID : 4085
Codename : G604
Kind : mouse
Protocol : HID++ 4.2
Report Rate : 1ms
Serial number: XXXXXXXX
Model ID: B02440850000
Unit ID: XXXXXXXX
1: BL1 04.01.B0014
0: MPM 21.01.B0014
3:
The power switch is located on the base.
Supports 33 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V2
Firmware: 1 BL1 04.01.B0014 0000B01B3067
Firmware: 0 MPM 21.01.B0014 4085B01B3067
Firmware: 3
Unit ID: XXXXXXXX Model ID: B02440850000 Transport IDs: {'btleid': 'B024', 'wpid': '4085'}
3: DEVICE NAME {0005} V0
Name: G604 Wireless Gaming Mouse
Kind: mouse
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 00000000000000000000000000000000
6: BATTERY STATUS {1000} V0
Battery: 30%, BatteryStatus.DISCHARGING, next level 15%.
7: COLOR LED EFFECTS {8070} V4
LED Control (saved): Device
LED Control : Device
LEDs Primary : None
8: LED CONTROL {1300} V0
9: ONBOARD PROFILES {8100} V0
Device Mode: On-Board
Onboard Profiles (saved): Profile 1
Onboard Profiles : Profile 1
10: MOUSE BUTTON SPY {8110} V0
11: REPORT RATE {8060} V0
Report Rate: 1ms
Report Rate (saved): 1ms
Report Rate : 1ms
12: ADJUSTABLE DPI {2201} V1
Sensitivity (DPI) (saved): 800
Sensitivity (DPI) : 800
13: DFUCONTROL SIGNED {00C2} V0
14: DEVICE RESET {1802} V0
15: unknown:1803 {0318} V0 internal, hidden
16: OOBSTATE {1805} V0
17: CONFIG DEVICE PROPS {1806} V4
18: unknown:1813 {1318} V0 internal, hidden
19: unknown:1830 {3018} V0 internal, hidden
20: unknown:1890 {9018} V0 internal, hidden
21: unknown:1891 {9118} V0 internal, hidden
22: unknown:1861 {6118} V0 internal, hidden
23: unknown:1801 {0118} V0 internal, hidden
24: unknown:18B1 {B118} V0 internal, hidden
25: unknown:1DF3 {F31D} V0 internal, hidden
26: unknown:1E00 {001E} V0 hidden
27: unknown:1EB0 {B01E} V0 internal, hidden
28: unknown:1E22 {221E} V0 internal, hidden
29: HIRES WHEEL {2121} V0
Multiplier: 8
Has invert: Normal wheel motion
Has ratchet switch: Normal wheel mode
High resolution mode
HID notification
Scroll Wheel Direction (saved): False
Scroll Wheel Direction : False
Scroll Wheel Resolution (saved): True
Scroll Wheel Resolution : True
Scroll Wheel Diversion (saved): False
Scroll Wheel Diversion : False
30: unknown:18C0 {C018} V0 internal, hidden
31: CHANGE HOST {1814} V1
Change Host : 1:host1
32: HOSTS INFO {1815} V1
Host 0 (unpaired): host1
Host 1 (paired):
Battery: 30%, BatteryStatus.DISCHARGING, next level 15%.

View File

@@ -0,0 +1,100 @@
solaar version 1.1.14
1: MX Anywhere 3 for Business
Device path : None
WPID : B02D
Codename : MX Anywhere 3
Kind : mouse
Protocol : HID++ 4.5
Serial number: 00000000
Model ID: B02D00000000
Unit ID: 00000000
1: BL1 36.01.B0011
0: RBM 15.01.B0011
3:
The power switch is located on the (unknown).
Supports 35 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V4
Firmware: 1 BL1 36.01.B0011 B02D1EEFD8F8
Firmware: 0 RBM 15.01.B0011 B02D1EEFD8F8
Firmware: 3
Unit ID: 00000000 Model ID: B02D00000000 Transport IDs: {'btleid': 'B02D'}
3: DEVICE NAME {0005} V0
Name: MX Anywhere 3 for Business
Kind: mouse
4: WIRELESS DEVICE STATUS {1D4B} V0
5: CONFIG CHANGE {0020} V0
Configuration: 11000000000000000000000000000000
6: CRYPTO ID {0021} V1
7: DEVICE FRIENDLY NAME {0007} V0
Friendly Name: MX Anywhere 3B
8: UNIFIED BATTERY {1004} V3
Battery: 75%, 0.
9: REPROG CONTROLS V4 {1B04} V5
Key/Button Actions : {Left Button:Left Click, Right Button:Right Click, Middle Button:Mouse Middle Button, Back Button:Mouse Back Button, Forward Button:Mouse Forward Button, Smart Shift:Smart Shift}
Key/Button Diversion : {Middle Button:Regular, Back Button:Regular, Forward Button:Regular, Smart Shift:Diverted}
10: CHANGE HOST {1814} V1
Change Host : 2:archlinux
11: HOSTS INFO {1815} V2
Host 0 (paired): archlinux
Host 1 (paired): archlinux
Host 2 (unpaired):
12: XY STATS {2250} V1
13: ADJUSTABLE DPI {2201} V2
Sensitivity (DPI) : 1000
14: SMART SHIFT ENHANCED {2111} V0
Scroll Wheel Ratcheted : Ratcheted
Scroll Wheel Ratchet Speed : 15
15: HIRES WHEEL {2121} V1
Multiplier: 15
Has invert: Normal wheel motion
Has ratchet switch: Normal wheel mode
Low resolution mode
HID notification
Scroll Wheel Direction : False
Scroll Wheel Resolution : False
Scroll Wheel Diversion : False
16: WHEEL STATS {2251} V0
17: DFUCONTROL {00C3} V0
18: DEVICE RESET {1802} V0 internal, hidden, unknown:000010
19: unknown:1803 {1803} V0 internal, hidden, unknown:000010
20: CONFIG DEVICE PROPS {1806} V8 internal, hidden, unknown:000010
21: unknown:1816 {1816} V0 internal, hidden, unknown:000010
22: OOBSTATE {1805} V0 internal, hidden
23: unknown:1830 {1830} V0 internal, hidden, unknown:000010
24: unknown:1891 {1891} V7 internal, hidden, unknown:000008
25: unknown:18A1 {18A1} V0 internal, hidden, unknown:000010
26: unknown:1E00 {1E00} V0 hidden
27: unknown:1E02 {1E02} V0 internal, hidden
28: unknown:1602 {1602} V0
29: unknown:1EB0 {1EB0} V0 internal, hidden, unknown:000010
30: unknown:1861 {1861} V1 internal, hidden, unknown:000010
31: unknown:9300 {9300} V1 internal, hidden, unknown:000010
32: unknown:9001 {9001} V0 internal, hidden, unknown:000010
33: unknown:1E22 {1E22} V0 internal, hidden, unknown:000010
34: unknown:9205 {9205} V0 internal, hidden, unknown:000010
Has 7 reprogrammable keys:
0: Left Button , default: Left Click => Left Click
mse, analytics key events, pos:0, group:1, group mask:g1
reporting: default
1: Right Button , default: Right Click => Right Click
mse, analytics key events, pos:0, group:1, group mask:g1
reporting: default
2: Middle Button , default: Mouse Middle Button => Mouse Middle Button
mse, reprogrammable, divertable, raw XY, analytics key events, pos:0, group:2, group mask:g1,g2
reporting: default
3: Back Button , default: Mouse Back Button => Mouse Back Button
mse, reprogrammable, divertable, raw XY, analytics key events, unknown:000800, pos:0, group:2, group mask:g1,g2
reporting: default
4: Forward Button , default: Mouse Forward Button => Mouse Forward Button
mse, reprogrammable, divertable, raw XY, analytics key events, unknown:000800, pos:0, group:2, group mask:g1,g2
reporting: default
5: Smart Shift , default: Smart Shift => Smart Shift
mse, reprogrammable, divertable, raw XY, analytics key events, pos:0, group:2, group mask:g1,g2
reporting: diverted, raw XY diverted
6: Virtual Gesture Button , default: Virtual Gesture Button => Virtual Gesture Button
divertable, virtual, raw XY, force raw XY, pos:0, group:3, group mask:empty
reporting: default
Battery: 75%, 0.

Binary file not shown.

View File

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

View File

@@ -0,0 +1,64 @@
solaar show
rules cannot access modifier keys in Wayland, accessing process only works on GNOME with Solaar Gnome extension installed
solaar version 1.1.14-2
Unifying Receiver
Device path : /dev/hidraw1
USB id : 046d:C52B
Serial : EC219AC2
C Pending : ff
0 : 12.11.B0032
1 : 04.16
3 : AA.AA
Has 2 paired device(s) out of a maximum of 6.
Notifications: wireless (0x000100)
Device activity counters: 1=195, 2=74
1: Wireless Mouse M175
Device path : /dev/hidraw2
WPID : 4008
Codename : M175
Kind : mouse
Protocol : HID++ 2.0
Report Rate : 8ms
Serial number: 16E46E8C
Model ID: 000000000000
Unit ID: 00000000
0: RQM 40.00.B0016
The power switch is located on the base.
Supports 21 HID++ 2.0 features:
0: ROOT {0000} V0
1: FEATURE SET {0001} V0
2: DEVICE FW VERSION {0003} V0
Firmware: 0 RQM 40.00.B0016 4008
Unit ID: 00000000 Model ID: 000000000000 Transport IDs: {}
3: DEVICE NAME {0005} V0
Name: Wireless Mouse M185
Kind: mouse
4: BATTERY STATUS {1000} V0
Battery: 70%, 0, next level 5%.
5: unknown:1830 {1830} V0 internal, hidden
6: unknown:1850 {1850} V0 internal, hidden
7: unknown:1860 {1860} V0 internal, hidden
8: unknown:1890 {1890} V0 internal, hidden
9: unknown:18A0 {18A0} V0 internal, hidden
10: unknown:18C0 {18C0} V0 internal, hidden
11: WIRELESS DEVICE STATUS {1D4B} V0
12: unknown:1DF3 {1DF3} V0 internal, hidden
13: REPROG CONTROLS {1B00} V0
14: REMAINING PAIRING {1DF0} V0 hidden
Remaining Pairings: 117
15: unknown:1E00 {1E00} V0 hidden
16: unknown:1E80 {1E80} V0 internal, hidden
17: unknown:1E90 {1E90} V0 internal, hidden
18: unknown:1F03 {1F03} V0 internal, hidden
19: VERTICAL SCROLLING {2100} V0
Roller type: standard
Ratchet per turn: 24
Scroll lines: 0
20: MOUSE POINTER {2200} V0
DPI: 1000
Acceleration: low
Override OS ballistics
No vertical tuning, standard mice
Battery: 70%, 0, next level 5%.

View File

@@ -39,8 +39,8 @@ Feature | ID | Status | Notes
`CONFIG_DEVICE_PROPS` | `0x1806` | Unsupported |
`CHANGE_HOST` | `0x1814` | Supported | `ChangeHost`
`HOSTS_INFO` | `0x1815` | Partial Support | `get_host_names`, partial listing only
`BACKLIGHT` | `0x1981` | Unsupported |
`BACKLIGHT2` | `0x1982` | Supported | `Backlight2`
`BACKLIGHT` | `0x1981` | Supported | `Backlight`
`BACKLIGHT2` | `0x1982` | Supported | `Backlight2`, ...
`BACKLIGHT3` | `0x1983` | Unsupported |
`PRESENTER_CONTROL` | `0x1A00` | Unsupported |
`SENSOR_3D` | `0x1A01` | Unsupported |
@@ -54,7 +54,7 @@ Feature | ID | Status | Notes
`WIRELESS_DEVICE_STATUS` | `0x1D4B` | Read only | status reporting from device
`REMAINING_PAIRING` | `0x1DF0` | Unsupported |
`FIRMWARE_PROPERTIES` | `0x1F1F` | Unsupported |
`ADC_MEASUREMENT` | `0x1F20` | Unsupported |
`ADC_MEASUREMENT` | `0x1F20` | Supported | `ADCPower`
`LEFT_RIGHT_SWAP` | `0x2001` | Unsupported |
`SWAP_BUTTON_CANCEL` | `0x2005` | Unsupported |
`POINTER_AXIS_ORIENTATION` | `0x2006` | Unsupported |
@@ -97,22 +97,22 @@ Feature | ID | Status | Notes
`GESTURE` | `0x6500` | Unsupported |
`GESTURE_2` | `0x6501` | Partial Support | `Gesture2Gestures`, `Gesture2Params`
`GKEY` | `0x8010` | Partial Support | `DivertGkeys`
`MKEYS` | `0x8020` | Unsupported |
`MR` | `0x8030` | Unsupported |
`BRIGHTNESS_CONTROL` | `0x8040` | Unsupported |
`REPORT_RATE` | `0x8060` | Supported | `ReportRate`
`COLOR_LED_EFFECTS` | `0x8070` | Unsupported |
`RGB_EFFECTS` | `0X8071` | Unsupported |
`MKEYS` | `0x8020` | Supported | `MkeyLEDs`
`MR` | `0x8030` | Supported | `MRKeyLED`
`BRIGHTNESS_CONTROL` | `0x8040` | Supported | `BrightnessControl`
`REPORT_RATE` | `0x8060` | Supported | `ReportRate`
`COLOR_LED_EFFECTS` | `0x8070` | Supported | `LEDControl`, `LEDZoneSetting`
`RGB_EFFECTS` | `0X8071` | Supported | `RGBControl`, `RGBEffectSetting`
`PER_KEY_LIGHTING` | `0x8080` | Unsupported |
`PER_KEY_LIGHTING_V2` | `0x8081` | Unsupported |
`PER_KEY_LIGHTING_V2` | `0x8081` | Supported | `PerKeyLighting`
`MODE_STATUS` | `0x8090` | Unsupported |
`ONBOARD_PROFILES` | `0x8100` | Unsupported |
`ONBOARD_PROFILES` | `0x8100` | Supported |
`MOUSE_BUTTON_SPY` | `0x8110` | Unsupported |
`LATENCY_MONITORING` | `0x8111` | Unsupported |
`GAMING_ATTACHMENTS` | `0x8120` | Unsupported |
`FORCE_FEEDBACK` | `0x8123` | Unsupported |
`SIDETONE` | `0x8300` | Unsupported |
`EQUALIZER` | `0x8310` | Unsupported |
`SIDETONE` | `0x8300` | Supported | `Sidetone`
`EQUALIZER` | `0x8310` | Supported | `Equalizer`
`HEADSET_OUT` | `0x8320` | Unsupported |
A “read only” note means the feature is a read-only feature.

View File

@@ -62,10 +62,12 @@ Some of the languages Solaar has been translated to are listed below. A full lis
- Portuguese-BR: [Drovetto][drovetto], [Josenivaldo Benito Jr.][jrbenito], Vinícius
- Română: Daniel Pavel
- Russian: [Dimitriy Ryazantcev][DJm00n], Anton Soroko
- Serbian: [Renato Kaurić][renatoka]
- Slovak: [Jose Riha][jose1711]
- Spanish, Castilian: Jose Luis Tirado
- Swedish: John Erling Blad, [Daniel Zippert][zipperten], Emelie Snecker, Jonatan Nyberg
- Turkish: Osman Karagöz
- Ukrainian: Олександр Афанасьєв
[Rongronggg9]: https://github.com/Rongronggg9
[papoteur]: https://github.com/papoteur
@@ -80,3 +82,4 @@ Some of the languages Solaar has been translated to are listed below. A full lis
[jrbenito]: https://github.com/jrbenito
[jeblad]: https://github.com/jeblad
[feku]: https://github.com/FerdinaKusumah
[renatoka]: https://github.com/renatoka

View File

@@ -68,9 +68,9 @@ Many devices allow reprogramming some keys or buttons. One the main reasons for
Many pointing devices provide a facility for recognizing gestures and sending an HID message for the gesture. The `Gesture` class stores inforation for one gesture and the `Gestures` class stores information for all the gestures on a device. Functions in the Device class request `KeysArray` information and store it on devices. Functions in the Device class request `Gestures` information for a device when appropriate and store it on the device.
Many gaming devices provide an interface to controlling their LEDs by zone. The `LEDEffectSetting` class stores the current state of one zone of LEDs. This information can come directly from an LED feature but is also part of device profiles so this class provides a byte string interface. Solaar stores this information in YAML so this class provides a YAML interface. The `LEDEffectsInfo` class stores information about what LED zones are on a device and what effects they can perform and provides a method that builds an object by querying a device.
Many gaming devices provide an interface to controlling their LEDs by zone. The `LEDEffectSetting` class stores the current state of one zone of LEDs. This information can come directly from an LED feature but is also part of Onboard Profiles so this class provides a byte string interface. Solaar stores this information in YAML so this class provides a YAML interface. The `LEDEffectsInfo` class stores information about what LED zones are on a device and what effects they can perform and provides a method that builds an object by querying a device.
Many gaming devices can be controlled by selecting one of their profiles. A profile sets up the rate at which the device reports movement, a set of sensitivites of its movement detector, a set of actions to be performed by mouse buttons or G and M keys, and effects for up to two LED zones. The `Button` class stores information about a button or key action. The `OnboardProfile` class stores a single profile, using the `LEDEffectSetting` and `Button` classes. Because retrieving and changing a profile is complex, this class provides a byte string interface. Because Solaar dumps profiles from devices as YAML documents and loads them into devices from YAML documents, this class provides a YAML interface. The `OnboardProfiles` class class stores the entire profiles information for a device. It provides an interface to construct an `OnboardProfiles` object by querying a device.
Many gaming devices can be controlled by selecting one of their Onboard Profiles. An Onboard Profile sets up the rate at which the device reports movement, a set of sensitivites of its movement detector, a set of actions to be performed by mouse buttons or G and M keys, and effects for up to two LED zones. The `Button` class stores information about a button or key action. The `OnboardProfile` class stores a single profile, using the `LEDEffectSetting` and `Button` classes. Because retrieving and changing a profile is complex, this class provides a byte string interface. Because Solaar dumps profiles from devices as YAML documents and loads them into devices from YAML documents, this class provides a YAML interface. The `OnboardProfiles` class class stores the entire profiles information for a device. It provides an interface to construct an `OnboardProfiles` object by querying a device.
Because Solaar dumps profiles from devices as YAML documents and loads them into devices from YAML documents, these classes also provide a YAML interface.
#### HID++ 1.0

View File

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

View File

@@ -7,8 +7,7 @@ layout: page
An easy way to install the most recent release version of Solaar is from the PyPI repository.
First install pip, and then run
`pip install --user solaar` or `pipx install --system-site-packages solaar` or
If you are using pipx add the `` flag.
`pip install --user solaar` or `pipx install --system-site-packages solaar`.
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`
@@ -27,6 +26,18 @@ brew update
brew install hidapi gtk+3 pygobject3
```
### Optional: Set up macOS launcher
* Option A (recommended): Configure a LaunchAgent to automatically start Solaar and keep it running in the background.
It will also automatically restart Solaar if it crashed or closed.
```
bash <(curl -fsSL https://raw.githubusercontent.com/pwr-Solaar/Solaar/refs/heads/master/tools/create-macos-launchagent.sh)
```
* Option B: Create Solaar.app launcher in /Applications.
It can be added to Login Items to start on login, but it will not automatically recover on crashes.
```
bash <(curl -fsSL https://raw.githubusercontent.com/pwr-Solaar/Solaar/refs/heads/master/tools/create-macos-app.sh)
```
# Installating from GitHub
## Downloading
@@ -42,8 +53,8 @@ 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`
Finally, install Solaar via `make install_pip` or `make install_pipx`.
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.
@@ -73,7 +84,7 @@ If you are running the system version of Python in Debian/Ubuntu you should have
In Fedora you need `gtk3` and `python3-gobject`.
You may have to install `gcc` and the Python development package (`python3-dev` or `python3-devel`,
depending on your distribution).
Other system packages may be required depending on your distribution, such as `python-gobject-common-devel`.
Other system packages may be required depending on your distribution, such as `python-gobject-common-devel` and `python-typing-extensions'.
Although the Solaar CLI does not require Gtk3,
`solaar config` does use Gtk3 capabilities to determine whether the Solaar GUI is running
and thus should tell the Solaar GUI to update its information about settings
@@ -92,10 +103,11 @@ If desktop notifications bindings are also installed
(`gir1.2-notify-0.7` for Debian/Ubuntu),
you will also see desktop notifications when devices come online and go offline.
If the `hid_parser` Python package is available, Solaar parses HID report descriptors
and can control more HID++ devices that do not use a receiver.
This package may not be available in some distributions but can be installed using pip
via `pip install --user hid-parser`.
Solaar includes its own version of `hid_parser` because the version that is in PyPi
(at https://pypi.org/project/hid-parser/) does not have some changes that are in
https://github.com/usb-tools/python-hid-parser and are needed for some devices.
Do not use pip to install hid_parser!
Some distributions (e.g., Fedora) may separately package this code.
If the `gitinfo` Python package is available, Solaar shows better information
about which version of Solaar is running.
@@ -131,6 +143,6 @@ and set the LANGUAGE environment variable appropriately when running Solaar.
Distributions can cause Solaar can be run automatically at user login by installing a desktop file at
`/etc/xdg/autostart/solaar.desktop`. An example of this file content can be seen in the repository at
[`share/autostart/solaar.desktop`](/share/autostart/solaar.desktop).
[`share/autostart/solaar.desktop`](https://github.com/pwr-Solaar/Solaar/blob/master/share/autostart/solaar.desktop).
If you install Solaar yourself you may need to create or modify this file or install a startup file under your home directory.

61
docs/issues.md Normal file
View File

@@ -0,0 +1,61 @@
---
title: Known Issues
layout: page
---
# Known Issues
- Some internal structures in Solaar have been updated to use more standard Python language features.
This has caused some problems and introduced bugs are still being found.
- 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.
- Bluez 5.73 does not remove Bluetooth devices when they disconnect.
Solaar 1.1.12 processes the DBus disconnection and connection messages from Bluez and does re-initialize devices when they reconnect.
The HID++ driver does not re-initialize devices, which causes problems with smooth scrolling.
Until the problem is resolved having Scroll Wheel Resolution set to true (and not ignored) may be helpful.
- The Linux HID++ driver modifies the Scroll Wheel Resolution setting to
implement smooth scrolling. If Solaar changes this setting, scrolling
can be either very fast or very slow. To fix this problem
click on the icon at the right edge of the setting to set it to
"Ignore this setting", which is the default for new devices.
The mouse has to be reset (e.g., by turning it off and on again) before this fix will take effect.
- Solaar expects that it has exclusive control over settings that are not ignored.
Running other programs that modify these settings, such as logiops,
will likely result in unexpected device behavior.
- The driver also sets the scrolling direction to its normal setting when implementing smooth scrolling.
This can interfere with the Scroll Wheel Direction setting, requiring flipping this setting back and forth
to restore reversed scrolling.
- The driver sends messages to devices that do not conform with the Logitech HID++ specification
resulting in responses being sent back that look like other messages. For some devices this causes
Solaar to report incorrect battery levels.
- Solaar normally uses icon names for its icons, which in some system tray implementations
results in missing or wrong-sized icons.
The `--tray-icon-size` option forces Solaar to use icon files of appropriate size
for tray icons instead, which produces better results in some system tray implementations.
To use icon files close to 32 pixels in size use `--tray-icon-size=32`.
- The icon in the system tray can show up as 'black on black' in dark
themes or as non-symbolic when the theme uses symbolic icons. This is due to problems
in some system tray implementations. Changing to a different theme may help.
The `--battery-icons=symbolic` option can be used to force symbolic icons.
- Solaar will try to use uinput to simulate input from rules under Wayland or if Xtest is not available
but this needs write permission on /dev/uinput.
For more information see [the rules page](https://pwr-solaar.github.io/Solaar/rules).
- Diverted keys remain diverted and so do not have their normal behavior when Solaar terminates
or a device disconnects from a host that is running Solaar. If necessary, their normal behavior
can be reestablished by turning the device off and on again. This is most important to restore
the host switching behavior of a host switch key that was diverted, for example to switch away
from a host that crashed or was turned off.
- When a receiver-connected device changes hosts Solaar remembers which diverted keys were down on it.
When the device changes back the first time any of these diverted keys is depressed Solaar will not
realize that the key was newly depressed. For this reason Solaar rules that can change hosts should
trigger on key releasing.

View File

@@ -133,8 +133,9 @@ or the window's Window manager class or instance name starts with their string a
`Device` conditions are true if a particular device originated the notification.
`Active` conditions are true if a particular device is active.
`Device` and `Active` conditions take one argument, which is the serial number or unit ID of a device,
as shown in Solaar's detail pane.
Some older devices do not have a useful serial number or unit ID and so cannot be tested for by these conditions.
as shown in Solaar's detail pane, or either of its names, as shown by Solaar.
Some older devices do not have a useful serial number or unit ID and so cannot
distinguished from other devices with the same names.
### Host
`Host` conditions are true if the computers hostname starts with the condition's argument.
@@ -244,7 +245,8 @@ If the previous condition in the parent rule returns a number the scroll amounts
### Mouse click
A `MouseClick` action takes a mouse button name (`left`, `middle` or `right`) and a positive number or 'click', 'depress', or 'release'.
The action simulates that number of clicks of the specified button or just one click, depress, or release of the button.
A `MouseClick` action takes a mouse button name (`left`, `middle` or `right`) and a positive number, and simulates that number of clicks of the specified button.
### Execute
An `Execute` action takes a program and arguments and executes it asynchronously.
### Set setting

40
docs/uninstallation.md Normal file
View File

@@ -0,0 +1,40 @@
---
title: Uninstalling Solaar
layout: page
---
# Uninstalling Solaar
## Uninstalling from Debian systems
If you installed Solaar using `apt`, you can remove it by running:
```bash
sudo apt remove --purge solaar
```
## Uninstalling from GitHub
If you cloned and installed Solaar from GitHub manually, navigate to the cloned directory and run:
```bash
sudo make uninstall
```
## Removing Configuration Files
Solaar may leave behind configuration files in your home directory. To delete them, run:
```bash
rm -rf ~/.config/solaar
```
## Verifying Uninstallation
To confirm that Solaar is fully removed, try running:
```bash
which solaar
```
If no output is returned, Solaar has been successfully uninstalled.

View File

@@ -181,15 +181,8 @@ def _enumerate_devices():
p = p.contents.next
_hidapi.hid_free_enumeration(c_devices)
keyboard_or_mouse = {d["path"] for d in devices if d["usage_page"] == 1 and d["usage"] in (6, 2)}
unique_devices = {}
for device in devices:
# On macOS we cannot access keyboard or mouse devices without special permissions. Since
# we don't need them anyway we remove them so opening them doesn't cause errors later.
if device["path"] in keyboard_or_mouse:
# print(f"Ignoring keyboard or mouse device: {device}")
continue
# hidapi returns separate entries for each usage page of a device.
# Deduplicate by path to only keep one device entry.
if device["path"] not in unique_devices:
@@ -228,7 +221,7 @@ class _DeviceMonitor(Thread):
def _match(
action: str,
device,
device: dict[str, Any],
filter_func: Callable[[int, int, int, bool, bool], dict[str, Any]],
):
"""
@@ -240,6 +233,7 @@ def _match(
vid = device["vendor_id"]
pid = device["product_id"]
hid_bus_type = device["bus_type"]
# Translate hidapi bus_type to the bus_id values Solaar expects
if device.get("bus_type") == 0x01:
@@ -248,35 +242,55 @@ def _match(
bus_id = 0x05 # Bluetooth
else:
bus_id = None
logger.info(f"Device {device['path']} has an unsupported bus type {hid_bus_type:02X}")
return None
# Skip unlikely devices with all-zero VID PID or unsupported bus IDs
if vid == 0 and pid == 0:
logger.info(f"Device {device['path']} has all-zero VID and PID")
logger.info(f"Skipping unlikely device {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
return None
# Check for hidpp support
device["hidpp_short"] = False
device["hidpp_long"] = False
device_handle = None
try:
device_handle = open_path(device["path"])
def check_hidpp_short():
report = _get_input_report(device_handle, 0x10, 32)
if len(report) == 1 + 6 and report[0] == 0x10:
device["hidpp_short"] = True
def check_hidpp_long():
report = _get_input_report(device_handle, 0x11, 32)
if len(report) == 1 + 19 and report[0] == 0x11:
device["hidpp_long"] = True
except HIDError as e: # noqa: F841
if logger.isEnabledFor(logging.INFO):
logger.info(f"Error opening device {device['path']} ({bus_id}/{vid:04X}/{pid:04X}) for hidpp check: {e}") # noqa
try:
device_handle = open_path(device["path"])
for check_func in (check_hidpp_short, check_hidpp_long):
try:
check_func()
except HIDError as e:
logger.info(
f"Error while {check_func.__name__}"
f"on device {device['path']} ({bus_id}/{vid:04X}/{pid:04X}) for hidpp check: {e}"
)
except HIDError as e:
logger.info(f"Error opening device {device['path']} ({bus_id}/{vid:04X}/{pid:04X}) for hidpp check: {e}")
finally:
if device_handle:
close(device_handle)
if logger.isEnabledFor(logging.INFO):
logger.info(
"Found device BID %s VID %04X PID %04X HID++ %s %s",
bus_id,
vid,
pid,
device["hidpp_short"],
device["hidpp_long"],
)
logger.info(
"Found device BID %s VID %04X PID %04X HID++ SHORT %s LONG %s",
bus_id,
vid,
pid,
device["hidpp_short"],
device["hidpp_long"],
)
if not device["hidpp_short"] and not device["hidpp_long"]:
return None
@@ -322,6 +336,8 @@ def _match(
)
return d_info
logger.info(f"Finished checking HIDPP support for device {device['path']} ({bus_id}/{vid:04X}/{pid:04X})")
def find_paired_node(receiver_path: str, index: int, timeout: int):
"""Find the node of a device paired with a receiver"""
@@ -393,7 +409,7 @@ def open(vendor_id, product_id, serial=None):
return device_handle
def open_path(device_path) -> Any:
def open_path(device_path: str) -> int:
"""Open a HID device by its path name.
:param device_path: the path of a ``DeviceInfo`` tuple returned by enumerate().

View File

@@ -50,7 +50,7 @@ print_lock = Lock()
def _print(marker, data, scroll=False):
t = time.time() - start_time
if isinstance(data, str):
s = marker + " " + data
s = f"{marker} {data}"
else:
hexs = strhex(data)
s = "%s (% 8.3f) [%s %s %s %s] %s" % (marker, t, hexs[0:2], hexs[2:4], hexs[4:8], hexs[8:], repr(data))
@@ -90,7 +90,7 @@ def _continuous_read(handle, timeout=2000):
try:
reply = hidapi.read(handle, 128, timeout)
except OSError as e:
_error("Read failed, aborting: " + str(e), True)
_error(f"Read failed, aborting: {str(e)}", True)
break
assert reply is not None
if reply:
@@ -101,7 +101,7 @@ def _validate_input(line, hidpp=False):
try:
data = unhexlify(line.encode("ascii"))
except Exception as e:
_error("Invalid input: " + str(e))
_error(f"Invalid input: {str(e)}")
return None
if hidpp:
@@ -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)

View File

@@ -86,8 +86,7 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
interest to Solaar. It is given the bus id, vendor id, and product
id and returns a dictionary with the required hid_driver and
usb_interface and whether this is a receiver or device."""
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f"Dbus event {action} {device}")
logger.debug(f"Dbus event {action} {device}")
hid_device = device.find_parent("hid")
if hid_device is None: # only HID devices are of interest to Solaar
return
@@ -137,18 +136,17 @@ def _match(action: str, device, filter_func: typing.Callable[[int, int, int, boo
intf_device = device.find_parent("usb", "usb_interface")
usb_interface = None if intf_device is None else intf_device.attributes.asint("bInterfaceNumber")
# print('*** usb interface', action, device, 'usb_interface:', intf_device, usb_interface, interface_number)
if logger.isEnabledFor(logging.INFO):
logger.info(
"Found device %s BID %s VID %s PID %s HID++ %s %s USB %s %s",
device.device_node,
bid,
vid,
pid,
hidpp_short,
hidpp_long,
usb_interface,
interface_number,
)
logger.info(
"Found device %s BID %s VID %s PID %s HID++ %s %s USB %s %s",
device.device_node,
bid,
vid,
pid,
hidpp_short,
hidpp_long,
usb_interface,
interface_number,
)
if not (hidpp_short or hidpp_long or interface_number is None or interface_number == usb_interface):
return
attrs = intf_device.attributes if intf_device is not None else None
@@ -268,8 +266,7 @@ def monitor_glib(glib: GLib, callback: Callable, filter_func: Callable):
except Exception:
glib.io_add_watch(m, glib.IO_IN, _process_udev_event, callback, filter_func)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Starting dbus monitoring")
logger.debug("Starting dbus monitoring")
m.start()
@@ -282,8 +279,7 @@ def enumerate(filter_func: typing.Callable[[int, int, int, bool, bool], dict[str
:returns: a list of matching ``DeviceInfo`` tuples.
"""
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Starting dbus enumeration")
logger.debug("Starting dbus enumeration")
for dev in pyudev.Context().list_devices(subsystem="hidraw"):
dev_info = _match(ACTION_ADD, dev, filter_func)
if dev_info:
@@ -327,7 +323,7 @@ def open_path(device_path):
if e.errno == errno.EACCES:
sleep(0.1)
else:
raise
raise e
def close(device_handle) -> None:

View File

@@ -56,7 +56,7 @@ else:
logger = logging.getLogger(__name__)
class HIDAPI(typing.Protocol):
class HIDProtocol(typing.Protocol):
def find_paired_node_wpid(self, receiver_path: str, index: int):
...
@@ -66,7 +66,7 @@ class HIDAPI(typing.Protocol):
def open(self, vendor_id, product_id, serial=None):
...
def open_path(self, path):
def open_path(self, path) -> int:
...
def enumerate(self, filter_func: Callable[[int, int, int, bool, bool], dict[str, typing.Any]]) -> DeviceInfo:
@@ -87,8 +87,6 @@ class HIDAPI(typing.Protocol):
...
hidapi = typing.cast(HIDAPI, hidapi)
SHORT_MESSAGE_SIZE = 7
_LONG_MESSAGE_SIZE = 20
_MEDIUM_MESSAGE_SIZE = 15
@@ -108,6 +106,11 @@ _DEVICE_REQUEST_TIMEOUT = DEFAULT_TIMEOUT
# when pinging, be extra patient (no longer)
_PING_TIMEOUT = DEFAULT_TIMEOUT
hidapi = typing.cast(HIDProtocol, hidapi)
request_lock = threading.Lock() # serialize all requests
handles_lock = {}
@dataclasses.dataclass
class HIDPPNotification:
@@ -146,26 +149,68 @@ for _ignore, d in descriptors.DEVICES.items():
KNOWN_DEVICE_IDS.append(_bluetooth_device(d.btid))
def _other_device_check(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any] | None:
"""Check whether product is a Logitech USB-connected or Bluetooth device based on bus, vendor, and product IDs
This allows Solaar to support receiverless HID++ 2.0 devices that it knows nothing about"""
if vendor_id != LOGITECH_VENDOR_ID:
return
device_info = None
if bus_id == BusID.USB and (0xC07D <= product_id <= 0xC094 or 0xC32B <= product_id <= 0xC344):
device_info = _usb_device(product_id, 2)
elif bus_id == BusID.BLUETOOTH and (0xB012 <= product_id <= 0xB0FF or 0xB317 <= product_id <= 0xB3FF):
device_info = _bluetooth_device(product_id)
return device_info
def product_information(usb_id: int) -> dict[str, Any]:
"""Returns hardcoded information from USB receiver."""
return base_usb.get_receiver_info(usb_id)
def _match(record: dict[str, Any], bus_id: int, vendor_id: int, product_id: int):
def receivers():
"""Enumerate all the receivers attached to the machine."""
yield from hidapi.enumerate(get_known_receiver_info)
def filter_products_of_interest(
bus_id: int, vendor_id: int, product_id: int, hidpp_short: bool = False, hidpp_long: bool = False
) -> dict[str, Any] | None:
"""Check that this product is of interest and if so return the device record for further checking"""
recv = get_known_receiver_info(bus_id, vendor_id, product_id, hidpp_short, hidpp_long)
if recv: # known or unknown receiver
return recv
device = get_known_device_info(bus_id, vendor_id, product_id)
if device:
return device
if hidpp_short or hidpp_long:
return get_unknown_hid_device_info(bus_id, vendor_id, product_id)
if hidpp_short is None and hidpp_long is None:
return get_unknown_logitech_device_info(bus_id, vendor_id, product_id)
return None
def get_known_device_info(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any]:
for recv in KNOWN_DEVICE_IDS:
if _match_device(recv, bus_id, vendor_id, product_id):
return recv
def get_unknown_hid_device_info(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any]:
return {"vendor_id": vendor_id, "product_id": product_id, "bus_id": bus_id, "isDevice": True}
def get_unknown_logitech_device_info(bus_id: int, vendor_id: int, product_id: int) -> dict[str, Any] | None:
"""Get info from unknown device in Logitech product range.
Check whether product is a Logitech USB-connected or Bluetooth
device based on bus, vendor, and product ID. This allows Solaar to
support receiverless HID++ 2.0 devices that it knows nothing about.
"""
if vendor_id != LOGITECH_VENDOR_ID:
return None
if bus_id == BusID.USB.value and (0xC07D <= product_id <= 0xC094 or 0xC32B <= product_id <= 0xC344):
device_info = _usb_device(product_id, 2)
return device_info
elif bus_id == BusID.BLUETOOTH.value and (0xB012 <= product_id <= 0xB0FF or 0xB317 <= product_id <= 0xB3FF):
device_info = _bluetooth_device(product_id)
return device_info
return None
def _match_device(record: dict[str, Any], bus_id: int, vendor_id: int, product_id: int):
return (
(record.get("bus_id") is None or record.get("bus_id") == bus_id)
and (record.get("vendor_id") is None or record.get("vendor_id") == vendor_id)
@@ -173,7 +218,7 @@ def _match(record: dict[str, Any], bus_id: int, vendor_id: int, product_id: int)
)
def _filter_receivers(
def get_known_receiver_info(
bus_id: int, vendor_id: int, product_id: int, _hidpp_short: bool = False, _hidpp_long: bool = False
) -> dict[str, Any]:
"""Check that this product is a Logitech receiver and return it.
@@ -184,7 +229,7 @@ def _filter_receivers(
"""
try:
record = base_usb.get_receiver_info(product_id)
if _match(record, bus_id, vendor_id, product_id):
if _match_device(record, bus_id, vendor_id, product_id):
return record
except ValueError:
pass
@@ -194,32 +239,9 @@ def _filter_receivers(
return None
def receivers():
"""Enumerate all the receivers attached to the machine."""
yield from hidapi.enumerate(_filter_receivers)
def _filter_products_of_interest(
bus_id: int, vendor_id: int, product_id: int, hidpp_short: bool = False, hidpp_long: bool = False
) -> dict[str, Any] | None:
"""Check that this product is of interest and if so return the device record for further checking"""
record = _filter_receivers(bus_id, vendor_id, product_id, hidpp_short, hidpp_long)
if record: # known or unknown receiver
return record
for record in KNOWN_DEVICE_IDS:
if _match(record, bus_id, vendor_id, product_id):
return record
if hidpp_short or hidpp_long: # unknown devices that use HID++
return {"vendor_id": vendor_id, "product_id": product_id, "bus_id": bus_id, "isDevice": True}
elif hidpp_short is None and hidpp_long is None: # unknown devices in correct range of IDs
return _other_device_check(bus_id, vendor_id, product_id)
return None
def receivers_and_devices():
"""Enumerate all the receivers and devices directly attached to the machine."""
yield from hidapi.enumerate(_filter_products_of_interest)
yield from hidapi.enumerate(filter_products_of_interest)
def notify_on_receivers_glib(glib: GLib, callback: Callable):
@@ -230,10 +252,10 @@ def notify_on_receivers_glib(glib: GLib, callback: Callable):
glib
GLib instance.
"""
return hidapi.monitor_glib(glib, callback, _filter_products_of_interest)
return hidapi.monitor_glib(glib, callback, filter_products_of_interest)
def open_path(path):
def open_path(path) -> int:
"""Checks if the given Linux device path points to the right UR device.
:param path: the Linux device path.
@@ -356,7 +378,7 @@ def _is_relevant_message(data: bytes) -> bool:
return False
def _read(handle, timeout):
def _read(handle, timeout) -> tuple[int, int, bytes]:
"""Read an incoming packet from the receiver.
:returns: a tuple of (report_id, devnumber, data), or `None`.
@@ -393,33 +415,6 @@ def _read(handle, timeout):
return report_id, devnumber, data[2:]
def _skip_incoming(handle, ihandle, notifications_hook):
"""Read anything already in the input buffer.
Used by request() and ping() before their write.
"""
while True:
try:
# read whatever is already in the buffer, if any
data = hidapi.read(ihandle, _MAX_READ_SIZE, 0)
except Exception as reason:
logger.error("read failed, assuming receiver %s no longer available", handle)
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
if data:
if _is_relevant_message(data): # only process messages that pass check
# report_id = ord(data[:1])
if notifications_hook:
n = make_notification(ord(data[:1]), ord(data[1:2]), data[2:])
if n:
notifications_hook(n)
else:
# nothing in the input buffer, we're done
return
def make_notification(report_id: int, devnumber: int, data: bytes) -> HIDPPNotification | None:
"""Guess if this is a notification (and not just a request reply), and
return a Notification if it is."""
@@ -455,10 +450,6 @@ def make_notification(report_id: int, devnumber: int, data: bytes) -> HIDPPNotif
return None
request_lock = threading.Lock() # serialize all requests
handles_lock = {}
def handle_lock(handle):
with request_lock:
if handles_lock.get(handle) is None:
@@ -481,22 +472,6 @@ def acquire_timeout(lock, handle, timeout):
lock.release()
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
def find_paired_node(receiver_path: str, index: int, timeout: int):
"""Find the node of a device paired with a receiver."""
return hidapi.find_paired_node(receiver_path, index, timeout)
@@ -551,7 +526,7 @@ def request(
ihandle = int(handle)
notifications_hook = getattr(handle, "notifications_hook", None)
try:
_skip_incoming(handle, ihandle, notifications_hook)
_read_input_buffer(handle, ihandle, notifications_hook)
except exceptions.NoReceiver:
logger.warning("device or receiver disconnected")
return None
@@ -648,7 +623,7 @@ def ping(handle, devnumber, long_message: bool = False):
with acquire_timeout(handle_lock(handle), handle, 10.0):
notifications_hook = getattr(handle, "notifications_hook", None)
try:
_skip_incoming(handle, int(handle), notifications_hook)
_read_input_buffer(handle, int(handle), notifications_hook)
except exceptions.NoReceiver:
logger.warning("device or receiver disconnected")
return
@@ -693,3 +668,46 @@ def ping(handle, devnumber, long_message: bool = False):
delta = time() - request_started
logger.warning("(%s) timeout (%0.2f/%0.2f) on device %d ping", handle, delta, _PING_TIMEOUT, devnumber)
def _read_input_buffer(handle, ihandle, notifications_hook):
"""Consume anything already in the input buffer.
Used by request() and ping() before their write.
"""
while True:
try:
# read whatever is already in the buffer, if any
data = hidapi.read(ihandle, _MAX_READ_SIZE, 0)
except Exception as reason:
logger.error("read failed, assuming receiver %s no longer available", handle)
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
if data:
if _is_relevant_message(data): # only process messages that pass check
# report_id = ord(data[:1])
if notifications_hook:
n = make_notification(ord(data[:1]), ord(data[1:2]), data[2:])
if n:
notifications_hook(n)
else:
# nothing in the input buffer, we're done
return
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

View File

@@ -27,6 +27,10 @@ USB ids of Logitech wireless receivers.
Only receivers supporting the HID++ protocol can go in here.
"""
from __future__ import annotations
from typing import Any
from solaar.i18n import _
# max_devices is only used for receivers that do not support reading from Registers.RECEIVER_INFO offset 0x03, default
@@ -120,6 +124,7 @@ def _lightspeed_receiver(product_id: int) -> dict:
"receiver_kind": "lightspeed",
"name": _("Lightspeed Receiver"),
"may_unpair": False,
"re_pairs": True,
}
@@ -170,53 +175,64 @@ 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)
KNOWN_RECEIVERS = (
BOLT_RECEIVER_C548,
UNIFYING_RECEIVER_C52B,
UNIFYING_RECEIVER_C532,
NANO_RECEIVER_ADVANCED,
NANO_RECEIVER_C518,
NANO_RECEIVER_C51A,
NANO_RECEIVER_C51B,
NANO_RECEIVER_C521,
NANO_RECEIVER_C525,
NANO_RECEIVER_C526,
NANO_RECEIVER_C52E,
NANO_RECEIVER_C531,
NANO_RECEIVER_C534,
NANO_RECEIVER_C535,
NANO_RECEIVER_C537,
NANO_RECEIVER_6042,
LIGHTSPEED_RECEIVER_C539,
LIGHTSPEED_RECEIVER_C53A,
LIGHTSPEED_RECEIVER_C53D,
LIGHTSPEED_RECEIVER_C53F,
LIGHTSPEED_RECEIVER_C541,
LIGHTSPEED_RECEIVER_C545,
LIGHTSPEED_RECEIVER_C547,
EX100_27MHZ_RECEIVER_C517,
)
KNOWN_RECEIVERS = {
0xC548: BOLT_RECEIVER_C548,
0xC52B: UNIFYING_RECEIVER_C52B,
0xC532: UNIFYING_RECEIVER_C532,
0xC52F: NANO_RECEIVER_ADVANCED,
0xC518: NANO_RECEIVER_C518,
0xC51A: NANO_RECEIVER_C51A,
0xC51B: NANO_RECEIVER_C51B,
0xC521: NANO_RECEIVER_C521,
0xC525: NANO_RECEIVER_C525,
0xC526: NANO_RECEIVER_C526,
0xC52E: NANO_RECEIVER_C52E,
0xC531: NANO_RECEIVER_C531,
0xC534: NANO_RECEIVER_C534,
0xC535: NANO_RECEIVER_C535,
0xC537: NANO_RECEIVER_C537,
0x6042: NANO_RECEIVER_6042,
0xC539: LIGHTSPEED_RECEIVER_C539,
0xC53A: LIGHTSPEED_RECEIVER_C53A,
0xC53D: LIGHTSPEED_RECEIVER_C53D,
0xC53F: LIGHTSPEED_RECEIVER_C53F,
0xC541: LIGHTSPEED_RECEIVER_C541,
0xC545: LIGHTSPEED_RECEIVER_C545,
0xC547: LIGHTSPEED_RECEIVER_C547,
0xC54D: LIGHTSPEED_RECEIVER_C54D,
0xC517: EX100_27MHZ_RECEIVER_C517,
}
def get_receiver_info(product_id: int) -> dict:
"""Returns hardcoded information about Logitech receiver.
def get_receiver_info(product_id: int) -> dict[str, Any]:
"""Returns hardcoded information about a Logitech receiver.
Parameters
----------
product_id
Product ID of receiver e.g. 0xC548 for a Logitech Bolt receiver.
Product ID (pid) of the receiver, e.g. 0xC548 for a Logitech
Bolt receiver.
Returns
-------
dict
Product info with mandatory vendor_id, product_id,
usb_interface, name, receiver_kind
dict[str, Any]
Receiver info with mandatory fields:
- vendor_id
- product_id
Raises
------
ValueError
If the product ID is unknown.
"""
for receiver in KNOWN_RECEIVERS:
if product_id == receiver.get("product_id"):
return receiver
raise ValueError(f"Unknown product ID '0x{product_id:02X}")
try:
return KNOWN_RECEIVERS[product_id]
except KeyError:
pass
raise ValueError(f"Unknown product ID '0x{product_id:02X}'")

View File

@@ -20,6 +20,7 @@ import binascii
import dataclasses
import typing
from enum import Flag
from enum import IntEnum
from typing import Generator
from typing import Iterable
@@ -332,7 +333,7 @@ class NamedInt(int):
return self.name.lower() == other.lower()
# this should catch comparisons with bytes in Py3
if other is not None:
raise TypeError("Unsupported type " + str(type(other)))
raise TypeError(f"Unsupported type {str(type(other))}")
def __ne__(self, other):
return not self.__eq__(other)
@@ -466,7 +467,7 @@ class NamedInts:
def __setitem__(self, index, name):
assert isinstance(index, int), type(index)
if isinstance(name, NamedInt):
assert int(index) == int(name), repr(index) + " " + repr(name)
assert int(index) == int(name), f"{repr(index)} {repr(name)}"
value = name
elif isinstance(name, str):
value = NamedInt(index, name)
@@ -589,7 +590,7 @@ class FirmwareInfo:
extras: str | None
class BatteryStatus(IntEnum):
class BatteryStatus(Flag):
DISCHARGING = 0x00
RECHARGING = 0x01
ALMOST_FULL = 0x02
@@ -649,8 +650,7 @@ class Battery:
elif isinstance(self.level, int):
status = self.status.name.lower().replace("_", " ") if self.status is not None else "Unknown"
return _("Battery: %(percent)d%% (%(status)s)") % {"percent": self.level, "status": _(status)}
else:
return ""
return ""
class Alert(IntEnum):

View File

@@ -381,7 +381,7 @@ _D(
),
)
_D("Couch Mouse M515", codename="M515", protocol=2.0, wpid="4007")
_D("Wireless Mouse M175", codename="M175", protocol=2.0, wpid="4008")
# _D("Wireless Mouse M175", codename="M175", protocol=2.0, wpid="4008")
_D("Wireless Mouse M325", codename="M325", protocol=2.0, wpid="400A")
_D("Wireless Mouse M525", codename="M525", protocol=2.0, wpid="4013")
_D("Wireless Mouse M345", codename="M345", protocol=2.0, wpid="4017")

View File

@@ -86,7 +86,7 @@ if available:
n.set_urgency(Notify.Urgency.NORMAL)
n.set_hint("desktop-entry", GLib.Variant("s", "solaar")) # replace with better name late
try:
n.show()
return n.show()
except Exception:
logger.exception(f"showing {n}")

View File

@@ -23,7 +23,6 @@ import threading
import time
import typing
from typing import Any
from typing import Callable
from typing import Optional
from typing import Protocol
@@ -39,6 +38,7 @@ from . import settings
from . import settings_templates
from .common import Alert
from .common import Battery
from .hidpp10_constants import NotificationFlag
from .hidpp20_constants import SupportedFeature
if typing.TYPE_CHECKING:
@@ -51,7 +51,7 @@ _hidpp20 = hidpp20.Hidpp20()
class LowLevelInterface(Protocol):
def open_path(self, path) -> Any:
def open_path(self, path) -> int:
...
def find_paired_node(self, receiver_path: str, index: int, timeout: int):
@@ -87,10 +87,10 @@ def create_device(low_level: LowLevelInterface, device_info, setting_callback=No
except OSError as e:
logger.exception("open %s", device_info)
if e.errno == errno.EACCES:
raise
except Exception:
raise e
except Exception as e:
logger.exception("open %s", device_info)
raise
raise e
class Device:
@@ -140,13 +140,14 @@ class Device:
self._modelId = None # model id (contains identifiers for the transports of the device)
self._tid_map = None # map from transports to product identifiers
self._persister = None # persister holds settings
self._led_effects = self._firmware = self._keys = self._remap_keys = self._gestures = None
self._led_effects = self._firmware = self._keys = self._remap_keys = self._gestures = self._force_buttons = None
self._profiles = self._backlight = self._settings = None
self.registers = []
self.notification_flags = None
self.battery_info = None
self.link_encrypted = None
self._active = None # lags self.online - is used to help determine when to setup devices
self.present = True # used for devices that are integral with their receiver but that separately be disconnected
self._feature_settings_checked = False
self._gestures_lock = threading.Lock()
@@ -206,10 +207,10 @@ class Device:
Device.instances.append(self)
def find(self, id): # find a device by serial number or unit ID
assert id, "need serial number or unit ID to find a device"
def find(self, id): # find a device by serial number or unit ID or name or codename
assert id, "need id to find a device"
for device in Device.instances:
if device.online and (device.unitId == id or device.serial == id):
if device.online and (device.unitId == id or device.serial == id or device.name == id or device.codename == id):
return device
@property
@@ -231,14 +232,14 @@ class Device:
self._codename = codename
elif self.protocol < 2.0:
self._codename = "? (%s)" % (self.wpid or self.product_id)
return self._codename or "?? (%s)" % (self.wpid or self.product_id)
return self._codename or f"?? ({self.wpid or self.product_id})"
@property
def name(self):
if not self._name:
if self.online and self.protocol >= 2.0:
self._name = _hidpp20.get_name(self)
return self._name or self._codename or "Unknown device %s" % (self.wpid or self.product_id)
return self._name or self._codename or f"Unknown device {self.wpid or self.product_id}"
def get_ids(self):
ids = _hidpp20.get_ids(self)
@@ -345,6 +346,12 @@ class Device:
self._profiles = _hidpp20.get_profiles(self)
return self._profiles
def force_buttons(self):
if self._force_buttons is None:
if self.online and self.protocol >= 2.0:
self._force_buttons = _hidpp20.get_force_buttons(self) or ()
return self._force_buttons
def set_configuration(self, configuration_, no_reply=False):
if self.online and self.protocol >= 2.0:
_hidpp20.config_change(self, configuration_, no_reply=no_reply)
@@ -396,8 +403,8 @@ class Device:
self.persister["_battery"] = feature.value
return battery
except Exception:
if self.persister and battery_feature is None:
self.persister["_battery"] = result
if self.persister and battery_feature is None and result is not None and result != 0:
self.persister["_battery"] = result.value
def set_battery_info(self, info):
"""Update battery information for device, calling changed callback if necessary"""
@@ -432,6 +439,8 @@ class Device:
def changed(self, active=None, alert=Alert.NONE, reason=None, push=False):
"""The status of the device had changed, so invoke the status callback.
Also push notifications and settings to the device when necessary."""
if logger.isEnabledFor(logging.DEBUG):
logger.debug("device %d changing: active=%s %s present=%s", self.number, active, self._active, self.present)
if active is not None:
self.online = active
was_active, self._active = self._active, active
@@ -467,11 +476,7 @@ class Device:
return False
if enable:
set_flag_bits = (
hidpp10_constants.NOTIFICATION_FLAG.battery_status
| hidpp10_constants.NOTIFICATION_FLAG.ui
| hidpp10_constants.NOTIFICATION_FLAG.configuration_complete
)
set_flag_bits = NotificationFlag.BATTERY_STATUS | NotificationFlag.UI | NotificationFlag.CONFIGURATION_COMPLETE
else:
set_flag_bits = 0
ok = _hidpp10.set_notification_flags(self, set_flag_bits)
@@ -480,8 +485,12 @@ class Device:
flag_bits = _hidpp10.get_notification_flags(self)
if logger.isEnabledFor(logging.INFO):
flag_names = None if flag_bits is None else tuple(hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits))
logger.info("%s: device notifications %s %s", self, "enabled" if enable else "disabled", flag_names)
if flag_bits is None:
flag_names = None
else:
flag_names = hidpp10_constants.NotificationFlag.flag_names(flag_bits)
is_enabled = "enabled" if enable else "disabled"
logger.info(f"{self}: device notifications {is_enabled} {flag_names}")
return flag_bits if ok else None
def add_notification_handler(self, id: str, fn):
@@ -519,7 +528,7 @@ class Device:
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
)
return self.low_level.request(
self.handle or self.receiver.handle,
self.handle or (self.receiver.handle if self.receiver else None),
self.number,
request_id,
*params,
@@ -533,15 +542,21 @@ class Device:
return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
def ping(self):
"""Checks if the device is online, returns True of False"""
"""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."""
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)
)
handle = self.handle or self.receiver.handle
protocol = self.low_level.ping(handle, self.number, long_message=long)
self.online = protocol is not None
try:
protocol = self.low_level.ping(handle, self.number, long_message=long)
except exceptions.NoReceiver: # if ping fails, device is offline
protocol = None
self.online = protocol is not None and self.present
if protocol:
self._protocol = protocol
if logger.isEnabledFor(logging.DEBUG):
logger.debug("pinged %s: online %s protocol %s present %s", self.number, self.online, protocol, self.present)
return self.online
def notify_devices(self): # no need to notify, as there are none

View File

@@ -239,6 +239,8 @@ if evdev:
"scroll_right": (7, evdev.ecodes.ecodes["BTN_7"]),
"button8": (8, evdev.ecodes.ecodes["BTN_8"]),
"button9": (9, evdev.ecodes.ecodes["BTN_9"]),
"back": (10, evdev.ecodes.ecodes["BTN_SIDE"]),
"forward": (11, evdev.ecodes.ecodes["BTN_EXTRA"]),
}
# uinput capability for keyboard keys, mouse buttons, and scrolling
@@ -364,7 +366,7 @@ 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 simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event):
if evdev and simulate_uinput(evdev.ecodes.EV_KEY, code - 8, event):
return True
logger.warning("no way to simulate key input")
@@ -541,7 +543,7 @@ class Rule(RuleComponent):
self.source = source
def __str__(self):
source = "(" + self.source + ")" if self.source else ""
source = f"({self.source})" if self.source else ""
return f"Rule{source}[{', '.join([c.__str__() for c in self.components])}]"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
@@ -578,7 +580,7 @@ class Not(Condition):
self.component = self.compile(op)
def __str__(self):
return "Not: " + str(self.component)
return f"Not: {str(self.component)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -691,7 +693,7 @@ class Process(Condition):
self.process = str(process)
def __str__(self):
return "Process: " + str(self.process)
return f"Process: {str(self.process)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -722,7 +724,7 @@ class MouseProcess(Condition):
self.process = str(process)
def __str__(self):
return "MouseProcess: " + str(self.process)
return f"MouseProcess: {str(self.process)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -740,14 +742,14 @@ class MouseProcess(Condition):
class Feature(Condition):
def __init__(self, feature: str, warn: bool = True):
try:
self.feature = SupportedFeature[feature]
self.feature = SupportedFeature[feature.replace(" ", "_")]
except KeyError:
self.feature = None
if warn:
logger.warning("rule Feature argument not name of a feature: %s", feature)
def __str__(self):
return "Feature: " + str(self.feature)
return f"Feature: {str(self.feature)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -768,7 +770,7 @@ class Report(Condition):
self.report = report
def __str__(self):
return "Report: " + str(self.report)
return f"Report: {str(self.report)}"
def evaluate(self, report, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -841,7 +843,7 @@ class Modifiers(Condition):
logger.warning("unknown rule Modifier value: %s", k)
def __str__(self):
return "Modifiers: " + str(self.desired)
return f"Modifiers: {str(self.desired)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -987,7 +989,7 @@ class Test(Condition):
logger.warning("rule Test argument not valid %s", test)
def __str__(self):
return "Test: " + str(self.test)
return f"Test: {str(self.test)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -1015,7 +1017,7 @@ class TestBytes(Condition):
logger.warning("rule TestBytes argument not valid %s", test)
def __str__(self):
return "TestBytes: " + str(self.test)
return f"TestBytes: {str(self.test)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -1090,7 +1092,7 @@ class Active(Condition):
self.devID = devID
def __str__(self):
return "Active: " + str(self.devID)
return f"Active: {str(self.devID)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -1111,12 +1113,17 @@ class Device(Condition):
self.devID = devID
def __str__(self):
return "Device: " + str(self.devID)
return f"Device: {str(self.devID)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
return device.unitId == self.devID or device.serial == self.devID
return (
device.unitId == self.devID
or device.serial == self.devID
or device.codename == self.devID
or device.name == self.devID
)
def data(self):
return {"Device": self.devID}
@@ -1131,7 +1138,7 @@ class Host(Condition):
self.host = host
def __str__(self):
return "Host: " + str(self.host)
return f"Host: {str(self.host)}"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
@@ -1314,17 +1321,17 @@ class MouseClick(Action):
self.count = count
elif warn:
logger.warning(
"rule MouseClick action: argument %s should be an integer or CLICK, PRESS, or RELEASE",
"rule MouseClick action: argument %s should be an integer or click, depress, or release",
count,
)
self.count = 1
def __str__(self):
return f"MouseClick: {self.button} ({int(self.count)})"
return f"MouseClick: {self.button} ({str(self.count)})"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.INFO):
logger.info(f"MouseClick action: {int(self.count)} {self.button}")
logger.info(f"MouseClick action: {str(self.count)} {self.button}")
if self.button and self.count:
click(buttons[self.button], self.count)
time.sleep(0.01)
@@ -1423,7 +1430,7 @@ class Later(Action):
self.components = self.rule.components
def __str__(self):
return "Later: [" + str(self.delay) + ", " + ", ".join(str(c) for c in self.components) + "]"
return f"Later: [{str(self.delay)}, " + ", ".join(str(c) for c in self.components) + "]"
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if self.delay and self.rule:
@@ -1492,7 +1499,7 @@ def key_is_down(key: NamedInt) -> bool:
def evaluate_rules(feature, notification: HIDPPNotification, device):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluating rules on %s", notification)
logger.debug("evaluating rules on %s %s", feature, notification)
rules.evaluate(feature, notification, device, True)

View File

@@ -26,6 +26,7 @@ from .common import Battery
from .common import BatteryLevelApproximation
from .common import BatteryStatus
from .common import FirmwareKind
from .hidpp10_constants import NotificationFlag
from .hidpp10_constants import Registers
logger = logging.getLogger(__name__)
@@ -188,9 +189,11 @@ 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):
def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag):
assert device is not None
# Avoid a call if the device is not online,
@@ -200,7 +203,7 @@ class Hidpp10:
if device.protocol and device.protocol >= 2.0:
return
flag_bits = sum(int(b) for b in flag_bits)
flag_bits = sum(int(b.value) for b in flag_bits)
assert flag_bits & 0x00FFFFFF == flag_bits
result = write_register(device, Registers.NOTIFICATIONS, common.int2bytes(flag_bits, 3))
return result is not None

View File

@@ -14,7 +14,11 @@
## 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 __future__ import annotations
from enum import Flag
from enum import IntEnum
from typing import List
from .common import NamedInts
@@ -41,51 +45,80 @@ DEVICE_KIND = NamedInts(
receiver=0x0F, # for compatibility with HID++ 2.0
)
POWER_SWITCH_LOCATION = NamedInts(
base=0x01,
top_case=0x02,
edge_of_top_right_corner=0x03,
top_left_corner=0x05,
bottom_left_corner=0x06,
top_right_corner=0x07,
bottom_right_corner=0x08,
top_edge=0x09,
right_edge=0x0A,
left_edge=0x0B,
bottom_edge=0x0C,
)
# Some flags are used both by devices and receivers. The Logitech documentation
# mentions that the first and last (third) byte are used for devices while the
# second is used for the receiver. In practise, the second byte is also used for
# some device-specific notifications (keyboard illumination level). Do not
# simply set all notification bits if the software does not support it. For
# example, enabling keyboard_sleep_raw makes the Sleep key a no-operation unless
# the software is updated to handle that event.
# Observations:
# - wireless and software present were seen on receivers, reserved_r1b4 as well
# - the rest work only on devices as far as we can tell right now
# In the future would be useful to have separate enums for receiver and device notification flags,
# but right now we don't know enough.
# additional flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing
NOTIFICATION_FLAG = NamedInts(
numpad_numerical_keys=0x800000,
f_lock_status=0x400000,
roller_H=0x200000,
battery_status=0x100000, # send battery charge notifications (0x07 or 0x0D)
mouse_extra_buttons=0x080000,
roller_V=0x040000,
power_keys=0x020000, # system control keys such as Sleep
keyboard_multimedia_raw=0x010000, # consumer controls such as Mute and Calculator
multi_touch=0x001000, # notify on multi-touch changes
software_present=0x000800, # software is controlling part of device behaviour
link_quality=0x000400, # notify on link quality changes
ui=0x000200, # notify on UI changes
wireless=0x000100, # notify when the device wireless goes on/off-line
configuration_complete=0x000004,
voip_telephony=0x000002,
threed_gesture=0x000001,
)
class PowerSwitchLocation(IntEnum):
UNKNOWN = 0x00
BASE = 0x01
TOP_CASE = 0x02
EDGE_OF_TOP_RIGHT_CORNER = 0x03
TOP_LEFT_CORNER = 0x05
BOTTOM_LEFT_CORNER = 0x06
TOP_RIGHT_CORNER = 0x07
BOTTOM_RIGHT_CORNER = 0x08
TOP_EDGE = 0x09
RIGHT_EDGE = 0x0A
LEFT_EDGE = 0x0B
BOTTOM_EDGE = 0x0C
@classmethod
def location(cls, loc: int) -> PowerSwitchLocation:
try:
return cls(loc)
except ValueError:
return cls.UNKNOWN
class NotificationFlag(Flag):
"""Some flags are used both by devices and receivers.
The Logitech documentation mentions that the first and last (third)
byte are used for devices while the second is used for the receiver.
In practise, the second byte is also used for some device-specific
notifications (keyboard illumination level). Do not simply set all
notification bits if the software does not support it. For example,
enabling keyboard_sleep_raw makes the Sleep key a no-operation
unless the software is updated to handle that event.
Observations:
- wireless and software present seen on receivers,
reserved_r1b4 as well
- the rest work only on devices as far as we can tell right now
In the future would be useful to have separate enums for receiver
and device notification flags, but right now we don't know enough.
Additional flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing
"""
@classmethod
def flag_names(cls, flags) -> List[str]:
"""Extract the names of the flags from the integer."""
return flags.name.replace("_", " ").lower().split("|")
NUMPAD_NUMERICAL_KEYS = 0x800000
F_LOCK_STATUS = 0x400000
ROLLER_H = 0x200000
BATTERY_STATUS = 0x100000 # send battery charge notifications (0x07 or 0x0D)
MOUSE_EXTRA_BUTTONS = 0x080000
ROLLER_V = 0x040000
POWER_KEYS = 0x020000 # system control keys such as Sleep
KEYBOARD_MULTIMEDIA_RAW = 0x010000 # consumer controls such as Mute and Calculator
MULTI_TOUCH = 0x001000 # notify on multi-touch changes
SOFTWARE_PRESENT = 0x000800 # software is controlling part of device behaviour
LINK_QUALITY = 0x000400 # notify on link quality changes
UI = 0x000200 # notify on UI changes
WIRELESS = 0x000100 # notify when the device wireless goes on/off-line
CONFIGURATION_COMPLETE = 0x000004
VOIP_TELEPHONY = 0x000002
THREED_GESTURE = 0x000001
def flags_to_str(flags, fallback: str) -> str:
flag_names = []
if flags is not None and flags is not False:
if flags.value == 0:
flag_names = (fallback,)
else:
flag_names = NotificationFlag.flag_names(flags)
return f"\n{' ':15}".join(sorted(flag_names))
class ErrorCode(IntEnum):
@@ -109,11 +142,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.
@@ -155,33 +196,56 @@ class Registers(IntEnum):
# Subregisters for receiver_info register
INFO_SUBREGISTERS = NamedInts(
serial_number=0x01, # not found on many receivers
fw_version=0x02,
receiver_information=0x03,
pairing_information=0x20, # 0x2N, by connected device
extended_pairing_information=0x30, # 0x3N, by connected device
device_name=0x40, # 0x4N, by connected device
bolt_pairing_information=0x50, # 0x5N, by connected device
bolt_device_name=0x60, # 0x6N01, by connected device,
)
class InfoSubRegisters(IntEnum):
SERIAL_NUMBER = 0x01 # not found on many receivers
FW_VERSION = 0x02
RECEIVER_INFORMATION = 0x03
PAIRING_INFORMATION = 0x20 # 0x2N, by connected device
EXTENDED_PAIRING_INFORMATION = 0x30 # 0x3N, by connected device
DEVICE_NAME = 0x40 # 0x4N, by connected device
BOLT_PAIRING_INFORMATION = 0x50 # 0x5N, by connected device
BOLT_DEVICE_NAME = 0x60 # 0x6N01, by connected device
# Flags taken from https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing
DEVICE_FEATURES = NamedInts(
reserved1=0x010000,
special_buttons=0x020000,
enhanced_key_usage=0x040000,
fast_fw_rev=0x080000,
reserved2=0x100000,
reserved3=0x200000,
scroll_accel=0x400000,
buttons_control_resolution=0x800000,
inhibit_lock_key_sound=0x000001,
reserved4=0x000002,
mx_air_3d_engine=0x000004,
host_control_leds=0x000008,
reserved5=0x000010,
reserved6=0x000020,
reserved7=0x000040,
reserved8=0x000080,
)
class DeviceFeature(Flag):
"""Features for devices.
Flags taken from
https://drive.google.com/file/d/0BxbRzx7vEV7eNDBheWY0UHM5dEU/view?usp=sharing
"""
@classmethod
def flag_names(cls, flag_bits: int) -> 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
RESERVED1 = 0x010000
SPECIAL_BUTTONS = 0x020000
ENHANCED_KEY_USAGE = 0x040000
FAST_FW_REV = 0x080000
RESERVED2 = 0x100000
RESERVED3 = 0x200000
SCROLL_ACCEL = 0x400000
BUTTONS_CONTROL_RESOLUTION = 0x800000
INHIBIT_LOCK_KEY_SOUND = 0x000001
RESERVED4 = 0x000002
MX_AIR_3D_ENGINE = 0x000004
HOST_CONTROL_LEDS = 0x000008
RESERVED5 = 0x000010
RESERVED6 = 0x000020
RESERVED7 = 0x000040
RESERVED8 = 0x000080

View File

@@ -21,10 +21,12 @@ import socket
import struct
import threading
from collections import UserDict
from enum import Flag
from enum import IntEnum
from typing import Any
from typing import Dict
from typing import Generator
from typing import List
from typing import Optional
from typing import Tuple
@@ -42,11 +44,11 @@ from .common import BatteryLevelApproximation
from .common import BatteryStatus
from .common import FirmwareKind
from .common import NamedInt
from .hidpp20_constants import CHARGE_STATUS
from .hidpp20_constants import DEVICE_KIND
from .hidpp20_constants import ChargeLevel
from .hidpp20_constants import ChargeType
from .hidpp20_constants import ErrorCode
from .hidpp20_constants import FeatureFlag
from .hidpp20_constants import GestureId
from .hidpp20_constants import ParamId
from .hidpp20_constants import SupportedFeature
@@ -79,6 +81,57 @@ class Device(Protocol):
...
# pfps: Consider adding a class method that sanitizes inputs by removing unknown bits.
class KeyFlag(Flag):
"""Capabilities and desired software handling for a control.
Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view
We treat bytes 4 and 8 of `getCidInfo` as a single bitfield.
"""
UNUSED_8000 = 0x8000
UNUSED_4000 = 0x4000
UNUSED_2000 = 0x2000
UNUSED_1000 = 0x1000
RAW_WHEEL = 0x800
ANALYTICS_KEY_EVENTS = 0x400
FORCE_RAW_XY = 0x200
RAW_XY = 0x100
VIRTUAL = 0x80
PERSISTENTLY_DIVERTABLE = 0x40
DIVERTABLE = 0x20
REPROGRAMMABLE = 0x10
FN_SENSITIVE = 0x08
NONSTANDARD = 0x04
IS_FN = 0x02
MSE = 0x01
class MappingFlag(Flag):
"""Flags describing the reporting method of a control.
We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield
"""
UNUSED_4000 = 0x4000
UNUSED_1000 = 0x1000
RAW_WHEEL = 0x400
ANALYTICS_KEY_EVENTS_REPORTING = 0x100
FORCE_RAW_XY_DIVERTED = 0x40
RAW_XY_DIVERTED = 0x10
PERSISTENTLY_DIVERTED = 0x04
DIVERTED = 0x01
class ChargeStatus(Flag):
CHARGING = 0x00
FULL = 0x01
NOT_CHARGING = 0x02
ERROR = 0x07
class FeaturesArray(dict):
def __init__(self, device):
assert device is not None
@@ -86,6 +139,7 @@ class FeaturesArray(dict):
self.device = device
self.inverse = {}
self.version = {}
self.flags = {}
self.count = 0
def _check(self) -> bool:
@@ -132,6 +186,7 @@ class FeaturesArray(dict):
feature = f"unknown:{data:04X}"
self[feature] = index
self.version[feature] = response[3]
self.flags[feature] = response[2]
return feature
def enumerate(self): # return all features and their index, ordered by index
@@ -144,6 +199,15 @@ class FeaturesArray(dict):
if self[feature]:
return self.version.get(feature, 0)
def get_flags(self, feature: NamedInt) -> Optional[int]:
if self[feature]:
return self.flags.get(feature, 0)
def get_hidden(self, feature: NamedInt) -> Optional[bool]:
if self[feature]:
return self.flags.get(feature, 0) & FeatureFlag.INTERNAL
return True
def __contains__(self, feature: NamedInt) -> bool:
try:
index = self.__getitem__(feature)
@@ -164,6 +228,7 @@ class FeaturesArray(dict):
index = response[0]
self[feature] = index if index else False
self.version[feature] = response[2]
self.flags[feature] = response[1]
return index if index else False
def __setitem__(self, feature, index):
@@ -184,19 +249,21 @@ class FeaturesArray(dict):
class ReprogrammableKey:
"""Information about a control present on a device with the `REPROG_CONTROLS` feature.
Ref: https://drive.google.com/file/d/0BxbRzx7vEV7eU3VfMnRuRXktZ3M/view
Read-only properties:
- index {int} -- index in the control ID table
- key {NamedInt} -- the name of this control
- default_task {NamedInt} -- the native function of this control
- flags {List[str]} -- capabilities and desired software handling of the control
- index -- index in the control ID table
- key -- the name of this control
- default_task -- the native function of this control
- flags -- capabilities and desired software handling of the control
Ref: https://drive.google.com/file/d/0BxbRzx7vEV7eU3VfMnRuRXktZ3M/view
"""
def __init__(self, device: Device, index, cid, tid, flags):
def __init__(self, device: Device, index: int, cid: int, task_id: int, flags: int):
self._device = device
self.index = index
self._cid = cid
self._tid = tid
self._tid = task_id
self._flags = flags
@property
@@ -209,12 +276,15 @@ class ReprogrammableKey:
while the name is the Control ID's native task. But this makes more sense
than presenting details of controls vs tasks in the interface. The same
convention applies to `mapped_to`, `remappable_to`, `remap` in `ReprogrammableKeyV4`."""
task = str(special_keys.TASK[self._tid])
try:
task = str(special_keys.Task(self._tid))
except ValueError:
task = f"unknown:{self._tid:04X}"
return NamedInt(self._cid, task)
@property
def flags(self) -> List[str]:
return special_keys.KEY_FLAG.flag_names(self._flags)
def flags(self) -> KeyFlag:
return KeyFlag(self._flags)
class ReprogrammableKeyV4(ReprogrammableKey):
@@ -234,8 +304,8 @@ class ReprogrammableKeyV4(ReprogrammableKey):
- mapping_flags {List[str]} -- mapping flags set on the control
"""
def __init__(self, device: Device, index, cid, tid, flags, pos, group, gmask):
ReprogrammableKey.__init__(self, device, index, cid, tid, flags)
def __init__(self, device: Device, index, cid, task_id, flags, pos, group, gmask):
ReprogrammableKey.__init__(self, device, index, cid, task_id, flags)
self.pos = pos
self.group = group
self._gmask = gmask
@@ -251,11 +321,14 @@ class ReprogrammableKeyV4(ReprogrammableKey):
if self._mapped_to is None:
self._getCidReporting()
self._device.keys._ensure_all_keys_queried()
task = str(special_keys.TASK[self._device.keys.cid_to_tid[self._mapped_to]])
try:
task = str(special_keys.Task(self._device.keys.cid_to_tid[self._mapped_to]))
except ValueError:
task = f"Unknown_{self._mapped_to:x}"
return NamedInt(self._mapped_to, task)
@property
def remappable_to(self) -> common.NamedInts:
def remappable_to(self):
self._device.keys._ensure_all_keys_queried()
ret = common.UnsortedNamedInts()
if self.group_mask: # only keys with a non-zero gmask are remappable
@@ -263,31 +336,35 @@ class ReprogrammableKeyV4(ReprogrammableKey):
for g in self.group_mask:
g = special_keys.CidGroup[str(g)]
for tgt_cid in self._device.keys.group_cids[g]:
tgt_task = str(special_keys.TASK[self._device.keys.cid_to_tid[tgt_cid]])
cid = self._device.keys.cid_to_tid[tgt_cid]
try:
tgt_task = str(special_keys.Task(cid))
except ValueError:
tgt_task = f"unknown:{cid:04X}"
tgt_task = NamedInt(tgt_cid, tgt_task)
if tgt_task != self.default_task: # don't put itself in twice
ret[tgt_task] = tgt_task
return ret
@property
def mapping_flags(self) -> List[str]:
def mapping_flags(self) -> MappingFlag:
if self._mapping_flags is None:
self._getCidReporting()
return special_keys.MAPPING_FLAG.flag_names(self._mapping_flags)
return MappingFlag(self._mapping_flags)
def set_diverted(self, value: bool):
def set_diverted(self, value: bool) -> None:
"""If set, the control is diverted temporarily and reports presses as HID++ events."""
flags = {special_keys.MAPPING_FLAG.diverted: value}
flags = {MappingFlag.DIVERTED: value}
self._setCidReporting(flags=flags)
def set_persistently_diverted(self, value: bool):
def set_persistently_diverted(self, value: bool) -> None:
"""If set, the control is diverted permanently and reports presses as HID++ events."""
flags = {special_keys.MAPPING_FLAG.persistently_diverted: value}
flags = {MappingFlag.PERSISTENTLY_DIVERTED: value}
self._setCidReporting(flags=flags)
def set_rawXY_reporting(self, value: bool):
def set_rawXY_reporting(self, value: bool) -> None:
"""If set, the mouse temporarily reports all its raw XY events while this control is pressed as HID++ events."""
flags = {special_keys.MAPPING_FLAG.raw_XY_diverted: value}
flags = {MappingFlag.RAW_XY_DIVERTED: value}
self._setCidReporting(flags=flags)
def remap(self, to: NamedInt):
@@ -339,35 +416,30 @@ class ReprogrammableKeyV4(ReprogrammableKey):
"""
flags = flags if flags else {} # See flake8 B006
# if special_keys.MAPPING_FLAG.raw_XY_diverted in flags and flags[special_keys.MAPPING_FLAG.raw_XY_diverted]:
# We need diversion to report raw XY, so divert temporarily (since XY reporting is also temporary)
# flags[special_keys.MAPPING_FLAG.diverted] = True
# if special_keys.MAPPING_FLAG.diverted in flags and not flags[special_keys.MAPPING_FLAG.diverted]:
# flags[special_keys.MAPPING_FLAG.raw_XY_diverted] = False
# The capability required to set a given reporting flag.
FLAG_TO_CAPABILITY = {
special_keys.MAPPING_FLAG.diverted: special_keys.KEY_FLAG.divertable,
special_keys.MAPPING_FLAG.persistently_diverted: special_keys.KEY_FLAG.persistently_divertable,
special_keys.MAPPING_FLAG.analytics_key_events_reporting: special_keys.KEY_FLAG.analytics_key_events,
special_keys.MAPPING_FLAG.force_raw_XY_diverted: special_keys.KEY_FLAG.force_raw_XY,
special_keys.MAPPING_FLAG.raw_XY_diverted: special_keys.KEY_FLAG.raw_XY,
MappingFlag.DIVERTED: KeyFlag.DIVERTABLE,
MappingFlag.PERSISTENTLY_DIVERTED: KeyFlag.PERSISTENTLY_DIVERTABLE,
MappingFlag.ANALYTICS_KEY_EVENTS_REPORTING: KeyFlag.ANALYTICS_KEY_EVENTS,
MappingFlag.FORCE_RAW_XY_DIVERTED: KeyFlag.FORCE_RAW_XY,
MappingFlag.RAW_XY_DIVERTED: KeyFlag.RAW_XY,
}
bfield = 0
for f, v in flags.items():
if v and FLAG_TO_CAPABILITY[f] not in self.flags:
for mapping_flag, activated in flags.items():
key_flag = FLAG_TO_CAPABILITY[mapping_flag]
if activated and key_flag not in self.flags:
raise exceptions.FeatureNotSupported(
msg=f'Tried to set mapping flag "{f}" on control "{self.key}" '
+ f'which does not support "{FLAG_TO_CAPABILITY[f]}" on device {self._device}.'
msg=f'Tried to set mapping flag "{mapping_flag}" on control "{self.key}" '
+ f'which does not support "{key_flag}" on device {self._device}.'
)
bfield |= int(f) if v else 0
bfield |= int(f) << 1 # The 'Xvalid' bit
bfield |= mapping_flag.value if activated else 0
bfield |= mapping_flag.value << 1 # The 'Xvalid' bit
if self._mapping_flags: # update flags if already read
if v:
self._mapping_flags |= int(f)
if activated:
self._mapping_flags |= mapping_flag.value
else:
self._mapping_flags &= ~int(f)
self._mapping_flags &= ~mapping_flag.value
if remap != 0 and remap not in self.remappable_to:
raise exceptions.FeatureNotSupported(
@@ -408,23 +480,23 @@ class PersistentRemappableAction:
if self.actionId == special_keys.ACTIONID.Empty:
return None
elif self.actionId == special_keys.ACTIONID.Key:
return "Key: " + str(self.modifiers) + str(self.remapped)
return f"Key: {str(self.modifiers)}{str(self.remapped)}"
elif self.actionId == special_keys.ACTIONID.Mouse:
return "Mouse Button: " + str(self.remapped)
return f"Mouse Button: {str(self.remapped)}"
elif self.actionId == special_keys.ACTIONID.Xdisp:
return "X Displacement " + str(self.remapped)
return f"X Displacement {str(self.remapped)}"
elif self.actionId == special_keys.ACTIONID.Ydisp:
return "Y Displacement " + str(self.remapped)
return f"Y Displacement {str(self.remapped)}"
elif self.actionId == special_keys.ACTIONID.Vscroll:
return "Vertical Scroll " + str(self.remapped)
return f"Vertical Scroll {str(self.remapped)}"
elif self.actionId == special_keys.ACTIONID.Hscroll:
return "Horizontal Scroll: " + str(self.remapped)
return f"Horizontal Scroll: {str(self.remapped)}"
elif self.actionId == special_keys.ACTIONID.Consumer:
return "Consumer: " + str(self.remapped)
return f"Consumer: {str(self.remapped)}"
elif self.actionId == special_keys.ACTIONID.Internal:
return "Internal Action " + str(self.remapped)
return f"Internal Action {str(self.remapped)}"
elif self.actionId == special_keys.ACTIONID.Internal:
return "Power " + str(self.remapped)
return f"Power {str(self.remapped)}"
else:
return "Unknown"
@@ -524,9 +596,9 @@ class KeysArrayV2(KeysArray):
raise IndexError(index)
keydata = self.device.feature_request(SupportedFeature.REPROG_CONTROLS, 0x10, index)
if keydata:
cid, tid, flags = struct.unpack("!HHB", keydata[:5])
self.keys[index] = ReprogrammableKey(self.device, index, cid, tid, flags)
self.cid_to_tid[cid] = tid
cid, task_id, flags = struct.unpack("!HHB", keydata[:5])
self.keys[index] = ReprogrammableKey(self.device, index, cid, task_id, flags)
self.cid_to_tid[cid] = task_id
elif logger.isEnabledFor(logging.WARNING):
logger.warning(f"Key with index {index} was expected to exist but device doesn't report it.")
@@ -540,10 +612,10 @@ class KeysArrayV4(KeysArrayV2):
raise IndexError(index)
keydata = self.device.feature_request(SupportedFeature.REPROG_CONTROLS_V4, 0x10, index)
if keydata:
cid, tid, flags1, pos, group, gmask, flags2 = struct.unpack("!HHBBBBB", keydata[:9])
cid, task_id, flags1, pos, group, gmask, flags2 = struct.unpack("!HHBBBBB", keydata[:9])
flags = flags1 | (flags2 << 8)
self.keys[index] = ReprogrammableKeyV4(self.device, index, cid, tid, flags, pos, group, gmask)
self.cid_to_tid[cid] = tid
self.keys[index] = ReprogrammableKeyV4(self.device, index, cid, task_id, flags, pos, group, gmask)
self.cid_to_tid[cid] = task_id
if group != 0: # 0 = does not belong to a group
self.group_cids[special_keys.CidGroup(group)].append(cid)
elif logger.isEnabledFor(logging.WARNING):
@@ -587,7 +659,10 @@ class KeysArrayPersistent(KeysArray):
elif actionId == special_keys.ACTIONID.Mouse:
remapped = special_keys.MOUSE_BUTTONS[remapped]
elif actionId == special_keys.ACTIONID.Hscroll:
remapped = special_keys.HORIZONTAL_SCROLL[remapped]
try:
remapped = special_keys.HorizontalScroll(remapped)
except ValueError:
remapped = f"unknown horizontal scroll:{remapped:04X}"
elif actionId == special_keys.ACTIONID.Consumer:
remapped = special_keys.HID_CONSUMERCODES[remapped]
elif actionId == special_keys.ACTIONID.Empty: # purge data from empty value
@@ -639,37 +714,40 @@ SUB_PARAM = { # (byte count, minimum, maximum)
ParamId.SCALE_FACTOR: (SubParam("scale", 2, 0x002E, 0x01FF, "Scale"),),
}
# Spec Ids for feature GESTURE_2
SPEC = common.NamedInts(
DVI_field_width=1,
field_widths=2,
period_unit=3,
resolution=4,
multiplier=5,
sensor_size=6,
finger_width_and_height=7,
finger_major_minor_axis=8,
finger_force=9,
zone=10,
)
SPEC._fallback = lambda x: f"unknown:{x:04X}"
# Action Ids for feature GESTURE_2
ACTION_ID = common.NamedInts(
MovePointer=1,
ScrollHorizontal=2,
WheelScrolling=3,
ScrollVertial=4,
ScrollOrPageXY=5,
ScrollOrPageHorizontal=6,
PageScreen=7,
Drag=8,
SecondaryDrag=9,
Zoom=10,
ScrollHorizontalOnly=11,
ScrollVerticalOnly=12,
)
ACTION_ID._fallback = lambda x: f"unknown:{x:04X}"
class SpecGesture(IntEnum):
"""Spec IDs for feature GESTURE_2."""
DVI_FIELD_WIDTH = 1
FIELD_WIDTHS = 2
PERIOD_UNIT = 3
RESOLUTION = 4
MULTIPLIER = 5
SENSOR_SIZE = 6
FINGER_WIDTH_AND_HEIGHT = 7
FINGER_MAJOR_MINOR_AXIS = 8
FINGER_FORCE = 9
ZONE = 10
def __str__(self):
return f"{self.name.replace('_', ' ').lower()}"
class ActionId(IntEnum):
"""Action IDs for feature GESTURE_2."""
MOVE_POINTER = 1
SCROLL_HORIZONTAL = 2
WHEEL_SCROLLING = 3
SCROLL_VERTICAL = 4
SCROLL_OR_PAGE_XY = 5
SCROLL_OR_PAGE_HORIZONTAL = 6
PAGE_SCREEN = 7
DRAG = 8
SECONDARY_DRAG = 9
ZOOM = 10
SCROLL_HORIZONTAL_ONLY = 11
SCROLL_VERTICAL_ONLY = 12
class Gesture:
@@ -804,10 +882,13 @@ class Param:
class Spec:
def __init__(self, device, low, high):
def __init__(self, device, low: int, high):
self._device = device
self.id = low
self.spec = SPEC[low]
try:
self.spec = SpecGesture(low)
except ValueError:
self.spec = f"unknown:{low:04X}"
self.byte_count = high & 0x0F
self._value = None
@@ -935,8 +1016,22 @@ class LEDParam:
saturation = "saturation"
LEDRampChoices = common.NamedInts(default=0, yes=1, no=2)
LEDFormChoices = common.NamedInts(default=0, sine=1, square=2, triangle=3, sawtooth=4, sharkfin=5, exponential=6)
class LedRampChoice(IntEnum):
DEFAULT = 0
YES = 1
NO = 2
class LedFormChoices(IntEnum):
DEFAULT = 0
SINE = 1
SQUARE = 2
TRIANGLE = 3
SAWTOOTH = 4
SHARKFIN = 5
EXPONENTIAL = 6
LEDParamSize = {
LEDParam.color: 3,
LEDParam.speed: 1,
@@ -1092,28 +1187,42 @@ class RGBEffectsInfo(LEDEffectsInfo): # effects that the LEDs can do using RGB_
self.zones.append(LEDZoneInfo(SupportedFeature.RGB_EFFECTS, 0x00, 1, 0x00, device, i))
ButtonBehaviors = common.NamedInts(MacroExecute=0x0, MacroStop=0x1, MacroStopAll=0x2, Send=0x8, Function=0x9)
ButtonMappingTypes = common.NamedInts(No_Action=0x0, Button=0x1, Modifier_And_Key=0x2, Consumer_Key=0x3)
ButtonFunctions = common.NamedInts(
No_Action=0x0,
Tilt_Left=0x1,
Tilt_Right=0x2,
Next_DPI=0x3,
Previous_DPI=0x4,
Cycle_DPI=0x5,
Default_DPI=0x6,
Shift_DPI=0x7,
Next_Profile=0x8,
Previous_Profile=0x9,
Cycle_Profile=0xA,
G_Shift=0xB,
Battery_Status=0xC,
Profile_Select=0xD,
Mode_Switch=0xE,
Host_Button=0xF,
Scroll_Down=0x10,
Scroll_Up=0x11,
)
class ButtonBehavior(IntEnum):
MACRO_EXECUTE = 0x0
MACRO_STOP = 0x1
MACRO_STOP_ALL = 0x2
SEND = 0x8
FUNCTION = 0x9
class ButtonMappingType(IntEnum):
NO_ACTION = 0x0
BUTTON = 0x1
MODIFIER_AND_KEY = 0x2
CONSUMER_KEY = 0x3
class ButtonFunctions(IntEnum):
NO_ACTION = 0x0
TILT_LEFT = 0x1
TILT_RIGHT = 0x2
NEXT_DPI = 0x3
PREVIOUS_DPI = 0x4
CYCLE_DPI = 0x5
DEFAULT_DPI = 0x6
SHIFT_DPI = 0x7
NEXT_PROFILE = 0x8
PREVIOUS_PROFILE = 0x9
CYCLE_PROFILE = 0xA
G_SHIFT = 0xB
BATTERY_STATUS = 0xC
PROFILE_SELECT = 0xD
MODE_SWITCH = 0xE
HOST_BUTTON = 0xF
SCROLL_DOWN = 0x10
SCROLL_UP = 0x11
ButtonButtons = special_keys.MOUSE_BUTTONS
ButtonModifiers = special_keys.modifiers
ButtonKeys = special_keys.USB_HID_KEYCODES
@@ -1138,50 +1247,57 @@ class Button:
return dumper.represent_mapping("!Button", data.__dict__, flow_style=True)
@classmethod
def from_bytes(cls, bytes):
behavior = ButtonBehaviors[bytes[0] >> 4]
if behavior == ButtonBehaviors.MacroExecute or behavior == ButtonBehaviors.MacroStop:
sector = ((bytes[0] & 0x0F) << 8) + bytes[1]
address = (bytes[2] << 8) + bytes[3]
def from_bytes(cls, bytes_) -> Button:
behavior = bytes_[0] >> 4
if behavior == ButtonBehavior.MACRO_EXECUTE or behavior == ButtonBehavior.MACRO_STOP:
sector = ((bytes_[0] & 0x0F) << 8) + bytes_[1]
address = (bytes_[2] << 8) + bytes_[3]
result = cls(behavior=behavior, sector=sector, address=address)
elif behavior == ButtonBehaviors.Send:
mapping_type = ButtonMappingTypes[bytes[1]]
if mapping_type == ButtonMappingTypes.Button:
value = ButtonButtons[(bytes[2] << 8) + bytes[3]]
result = cls(behavior=behavior, type=mapping_type, value=value)
elif mapping_type == ButtonMappingTypes.Modifier_And_Key:
modifiers = bytes[2]
value = ButtonKeys[bytes[3]]
result = cls(behavior=behavior, type=mapping_type, modifiers=modifiers, value=value)
elif mapping_type == ButtonMappingTypes.Consumer_Key:
value = ButtonConsumerKeys[(bytes[2] << 8) + bytes[3]]
result = cls(behavior=behavior, type=mapping_type, value=value)
elif mapping_type == ButtonMappingTypes.No_Action:
result = cls(behavior=behavior, type=mapping_type)
elif behavior == ButtonBehaviors.Function:
value = ButtonFunctions[bytes[1]] if ButtonFunctions[bytes[1]] is not None else bytes[1]
data = bytes[3]
result = cls(behavior=behavior, value=value, data=data)
elif behavior == ButtonBehavior.SEND:
try:
mapping_type = ButtonMappingType(bytes_[1]).value
if mapping_type == ButtonMappingType.BUTTON:
value = ButtonButtons[(bytes_[2] << 8) + bytes_[3]]
result = cls(behavior=behavior, type=mapping_type, value=value)
elif mapping_type == ButtonMappingType.MODIFIER_AND_KEY:
modifiers = bytes_[2]
value = ButtonKeys[bytes_[3]]
result = cls(behavior=behavior, type=mapping_type, modifiers=modifiers, value=value)
elif mapping_type == ButtonMappingType.CONSUMER_KEY:
value = ButtonConsumerKeys[(bytes_[2] << 8) + bytes_[3]]
result = cls(behavior=behavior, type=mapping_type, value=value)
elif mapping_type == ButtonMappingType.NO_ACTION:
result = cls(behavior=behavior, type=mapping_type)
except Exception:
pass
elif behavior == ButtonBehavior.FUNCTION:
second_byte = bytes_[1]
try:
btn_func = ButtonFunctions(second_byte).value
except ValueError:
btn_func = second_byte
data = bytes_[3]
result = cls(behavior=behavior, value=btn_func, data=data)
else:
result = cls(behavior=bytes[0] >> 4, bytes=bytes)
result = cls(behavior=bytes_[0] >> 4, bytes=bytes_)
return result
def to_bytes(self):
bytes = common.int2bytes(self.behavior << 4, 1) if self.behavior is not None else None
if self.behavior == ButtonBehaviors.MacroExecute or self.behavior == ButtonBehaviors.MacroStop:
if self.behavior == ButtonBehavior.MACRO_EXECUTE.value or self.behavior == ButtonBehavior.MACRO_STOP.value:
bytes = common.int2bytes((self.behavior << 12) + self.sector, 2) + common.int2bytes(self.address, 2)
elif self.behavior == ButtonBehaviors.Send:
elif self.behavior == ButtonBehavior.SEND.value:
bytes += common.int2bytes(self.type, 1)
if self.type == ButtonMappingTypes.Button:
if self.type == ButtonMappingType.BUTTON:
bytes += common.int2bytes(self.value, 2)
elif self.type == ButtonMappingTypes.Modifier_And_Key:
elif self.type == ButtonMappingType.MODIFIER_AND_KEY:
bytes += common.int2bytes(self.modifiers, 1)
bytes += common.int2bytes(self.value, 1)
elif self.type == ButtonMappingTypes.Consumer_Key:
elif self.type == ButtonMappingType.CONSUMER_KEY:
bytes += common.int2bytes(self.value, 2)
elif self.type == ButtonMappingTypes.No_Action:
elif self.type == ButtonMappingType.NO_ACTION:
bytes += b"\xff\xff"
elif self.behavior == ButtonBehaviors.Function:
elif self.behavior == ButtonBehavior.FUNCTION:
data = common.int2bytes(self.data, 1) if self.data else b"\x00"
bytes += common.int2bytes(self.value, 1) + b"\xff" + data
else:
@@ -1191,7 +1307,7 @@ class Button:
def __repr__(self):
return "%s{%s}" % (
self.__class__.__name__,
", ".join([str(key) + ":" + str(val) for key, val in self.__dict__.items()]),
", ".join([f"{str(key)}:{str(val)}" for key, val in self.__dict__.items()]),
)
@@ -1306,7 +1422,14 @@ class OnboardProfiles:
return dumper.represent_mapping("!OnboardProfiles", data.__dict__)
@classmethod
def get_profile_headers(cls, device):
def get_profile_headers(cls, device) -> list[tuple[int, int]]:
"""Returns profile headers.
Returns
-------
list[tuple[int, int]]
Tuples contain (sector, enabled).
"""
i = 0
headers = []
chunk = device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x50, 0, 0, 0, i)
@@ -1327,16 +1450,14 @@ class OnboardProfiles:
device.ping()
response = device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x00)
memory, profile, _macro = struct.unpack("!BBB", response[0:3])
if memory != 0x01 or profile > 0x04:
if memory != 0x01 or profile > 0x05:
return
count, oob, buttons, sectors, size, shift = struct.unpack("!BBBBHB", response[3:10])
gbuttons = buttons if (shift & 0x3 == 0x2) else 0
headers = OnboardProfiles.get_profile_headers(device)
profiles = {}
i = 0
for sector, enabled in headers:
profiles[i + 1] = OnboardProfile.from_dev(device, i, sector, size, enabled, buttons, gbuttons)
i += 1
for i, (sector, enabled) in enumerate(headers, start=1):
profiles[i] = OnboardProfile.from_dev(device, i, sector, size, enabled, buttons, gbuttons)
return cls(
version=OnboardProfilesVersion,
name=device.name,
@@ -1423,25 +1544,6 @@ def feature_request(device, feature, function=0x00, *params, no_reply=False):
return device.request((feature_index << 8) + (function & 0xFF), *params, no_reply=no_reply)
# voltage to remaining charge from Logitech
battery_voltage_remaining = (
(4186, 100),
(4067, 90),
(3989, 80),
(3922, 70),
(3859, 60),
(3811, 50),
(3778, 40),
(3751, 30),
(3717, 20),
(3671, 10),
(3646, 5),
(3579, 2),
(3500, 0),
(-1000, 0),
)
class Hidpp20:
def get_firmware(self, device) -> tuple[common.FirmwareInfo] | None:
"""Reads a device's firmware info.
@@ -1612,6 +1714,12 @@ class Hidpp20:
if SupportedFeature.BACKLIGHT2 in device.features:
return Backlight(device)
def get_force_buttons(self, device: Device):
if getattr(device, "_force_buttons", None) is not None:
return device._force_buttons
if SupportedFeature.FORCE_SENSING_BUTTON in device.features:
return ForceSensingButtonArray(device)
def get_profiles(self, device: Device):
if getattr(device, "_profiles", None) is not None:
return device._profiles
@@ -1768,7 +1876,7 @@ class Hidpp20:
state = device.feature_request(SupportedFeature.REPORT_RATE, 0x10)
if state:
rate = struct.unpack("!B", state[:1])[0]
return str(rate) + "ms"
return f"{str(rate)}ms"
else:
rates = ["8ms", "4ms", "2ms", "1ms", "500us", "250us", "125us"]
state = device.feature_request(SupportedFeature.EXTENDED_ADJUSTABLE_REPORT_RATE, 0x20)
@@ -1819,10 +1927,10 @@ def decipher_battery_voltage(report: bytes):
charge_type = ChargeType.STANDARD
if flags & (1 << 7):
status = BatteryStatus.RECHARGING
charge_sts = CHARGE_STATUS[flags & 0x03]
charge_sts = ChargeStatus(flags & 0x03)
if charge_sts is None:
charge_sts = ErrorCode.UNKNOWN
elif charge_sts == CHARGE_STATUS.full:
elif isinstance(charge_sts, ChargeStatus) and ChargeStatus.FULL in charge_sts:
charge_lvl = ChargeLevel.FULL
status = BatteryStatus.FULL
if flags & (1 << 3):
@@ -1832,10 +1940,9 @@ def decipher_battery_voltage(report: bytes):
status = BatteryStatus.SLOW_RECHARGE
elif flags & (1 << 5):
charge_lvl = ChargeLevel.CRITICAL
for level in battery_voltage_remaining:
if level[0] < voltage:
charge_lvl = level[1]
break
charge_level = estimate_battery_level_percentage(voltage)
if charge_level:
charge_lvl = charge_level
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"battery voltage %d mV, charging %s, status %d = %s, level %s, type %s",
@@ -1849,7 +1956,7 @@ def decipher_battery_voltage(report: bytes):
return SupportedFeature.BATTERY_VOLTAGE, Battery(charge_lvl, None, status, voltage)
def decipher_battery_unified(report):
def decipher_battery_unified(report) -> tuple[SupportedFeature, Battery]:
discharge, level, status_byte, _ignore = struct.unpack("!BBBB", report[:4])
try:
status = BatteryStatus(status_byte)
@@ -1860,27 +1967,155 @@ def decipher_battery_unified(report):
logger.debug("battery unified %s%% charged, level %s, charging %s", discharge, level, status)
if level == 8:
level = BatteryLevelApproximation.FULL
approx_level = BatteryLevelApproximation.FULL
elif level == 4:
level = BatteryLevelApproximation.GOOD
approx_level = BatteryLevelApproximation.GOOD
elif level == 2:
level = BatteryLevelApproximation.LOW
approx_level = BatteryLevelApproximation.LOW
elif level == 1:
level = BatteryLevelApproximation.CRITICAL
approx_level = BatteryLevelApproximation.CRITICAL
else:
level = BatteryLevelApproximation.EMPTY
approx_level = BatteryLevelApproximation.EMPTY
return SupportedFeature.UNIFIED_BATTERY, Battery(discharge if discharge else level, None, status, None)
return SupportedFeature.UNIFIED_BATTERY, Battery(discharge if discharge else approx_level, None, status, None)
def decipher_adc_measurement(report):
def decipher_adc_measurement(report) -> tuple[SupportedFeature, Battery]:
# partial implementation - needs mapping to levels
charge_level = None
adc, flags = struct.unpack("!HB", report[:3])
for level in battery_voltage_remaining:
if level[0] < adc:
charge_level = level[1]
break
adc_voltage, flags = struct.unpack("!HB", report[:3])
charge_level = estimate_battery_level_percentage(adc_voltage)
if flags & 0x01:
status = BatteryStatus.RECHARGING if flags & 0x02 else BatteryStatus.DISCHARGING
return SupportedFeature.ADC_MEASUREMENT, Battery(charge_level, None, status, adc)
return SupportedFeature.ADC_MEASUREMENT, Battery(charge_level, None, status, adc_voltage)
def estimate_battery_level_percentage(value_millivolt: int) -> int | None:
"""Estimate battery level percentage based on battery voltage.
Uses linear approximation to estimate the battery level in percent.
Parameters
----------
value_millivolt
Measured battery voltage in millivolt.
"""
battery_voltage_to_percentage = [
(4186, 100),
(4067, 90),
(3989, 80),
(3922, 70),
(3859, 60),
(3811, 50),
(3778, 40),
(3751, 30),
(3717, 20),
(3671, 10),
(3646, 5),
(3579, 2),
(3500, 0),
]
if value_millivolt >= battery_voltage_to_percentage[0][0]:
return battery_voltage_to_percentage[0][1]
if value_millivolt <= battery_voltage_to_percentage[-1][0]:
return battery_voltage_to_percentage[-1][1]
for i in range(len(battery_voltage_to_percentage) - 1):
v_high, p_high = battery_voltage_to_percentage[i]
v_low, p_low = battery_voltage_to_percentage[i + 1]
if v_low <= value_millivolt <= v_high:
# Linear interpolation
percent = p_low + (p_high - p_low) * (value_millivolt - v_low) / (v_high - v_low)
return round(percent)
return 0
class ForceSensingButton:
"""A button that has a force value at which to trigger the button"""
@classmethod
def create(cls, device, number: int):
buttondata = device.feature_request(SupportedFeature.FORCE_SENSING_BUTTON, 0x10, number)
buttoncurrent = device.feature_request(SupportedFeature.FORCE_SENSING_BUTTON, 0x20, number)
if buttondata is not None and buttoncurrent is not None:
changeable, default, max_value, min_value = struct.unpack("!HHHH", buttondata[:8])
changeable = changeable & 0x01
current = struct.unpack("!H", buttoncurrent[:2])[0]
return cls(device, number, changeable, default, max_value, min_value, current)
def __init__(self, device, number: int, changeable: bool, default: int, max_value: int, min_value: int, current: int):
self._device = device
self.number = number
self.changeable = changeable
self.default = default
self.min_value = min_value
self.max_value = max_value
self._current = current
def get_current(self) -> int:
return self._current
def set_current(self, current: int) -> None:
if not self.changeable:
logger.warning(f"FORCE_SENSING_BUTTON on device {self._device} does not allow changing force.")
if self.min_value <= current <= self.max_value:
ret = self._device.feature_request(
SupportedFeature.FORCE_SENSING_BUTTON, 0x30, struct.pack("!BH", self.number, current)
)
if ret is None and logger.isEnabledFor(logging.DEBUG):
logger.debug(f"FORCE_SENSING_BUTTON setButtonConfig on device {self._device} didn't respond.")
def acceptable_current(self, value: int) -> bool:
return self.min_value <= value <= self.max_value
class ForceSensingButtonArray(UserDict):
"""A map of buttons supporting force sensing"""
def __new__(cls, device: Device):
assert device is not None
count = device.feature_request(SupportedFeature.FORCE_SENSING_BUTTON, 0x00)
if count:
instance = super().__new__(cls)
instance._count = ord(count[:1])
return instance
def __init__(self, device: Device):
super().__init__(self)
self.device = device
for index in range(0, self._count):
self[index] = None
def __getitem__(self, index: int):
item = super().__getitem__(index)
if item is None:
self.query_key(index)
return super().__getitem__(index)
def query_key(self, index):
if index not in self:
raise IndexError(index)
button = ForceSensingButton.create(self.device, index)
if button:
self[index] = button
return button
def query(self):
for index in self:
button = ForceSensingButton.create(self.device, index)
if button:
self[index] = button
return self
# interface for single force button
def get_current(self):
return self[0].get_current()
def set_current(self, current: int) -> None:
self[0].set_current(current)
def acceptable(self, value: int) -> bool:
return self[0].acceptable(value)
def acceptable_current_key(self, index: int, value: int) -> bool:
return self[index].acceptable(value)

View File

@@ -65,6 +65,8 @@ class SupportedFeature(IntEnum):
BACKLIGHT2 = 0x1982
BACKLIGHT3 = 0x1983
ILLUMINATION = 0x1990
FORCE_SENSING_BUTTON = 0x19C0
HAPTIC = 0x19B0
PRESENTER_CONTROL = 0x1A00
SENSOR_3D = 0x1A01
REPROG_CONTROLS = 0x1B00
@@ -179,16 +181,6 @@ class OnboardMode(IntEnum):
MODE_HOST = 0x02
CHARGE_STATUS = NamedInts(charging=0x00, full=0x01, not_charging=0x02, error=0x07)
class ChargeStatus(IntEnum):
CHARGING = 0x00
FULL = 0x01
NOT_CHARGING = 0x02
ERROR = 0x07
class ChargeLevel(IntEnum):
AVERAGE = 50
FULL = 90
@@ -286,3 +278,23 @@ class ParamId(IntEnum):
PIXEL_ZONE = 2 # 4 2-byte integers, left, bottom, width, height; pixels
RATIO_ZONE = 3 # 4 bytes, left, bottom, width, height; unit 1/240 pad size
SCALE_FACTOR = 4 # 2-byte integer, with 256 as normal scale
HapticWaveForms = NamedInts(
SHARP_STATE_CHANGE=0x00,
DAMP_STATE_CHANGE=0x01,
SHARP_COLLISION=0x02,
DAMP_COLLISION=0x03,
SUBTLE_COLLISION=0x04,
HAPPY_ALERT=0x05,
ANGRY_ALERT=0x06,
COMPLETED=0x07,
SQUARE=0x08,
WAVE=0x09,
FIREWORK=0x0A,
MAD=0x0B,
KNOCK=0x0C,
JINGLE=0x0D,
RINGING=0xE,
WHISPER_COLLISION=0x1B,
)

View File

@@ -117,7 +117,7 @@ class EventsListener(threading.Thread):
path_name = receiver.path.split("/")[2]
except IndexError:
path_name = receiver.path
super().__init__(name=self.__class__.__name__ + ":" + path_name)
super().__init__(name=f"{self.__class__.__name__}:{path_name}")
self.daemon = True
self._active = False
self.receiver = receiver

View File

@@ -34,158 +34,87 @@ from . import diversion
from . import hidpp10
from . import hidpp10_constants
from . import hidpp20
from . import hidpp20_constants
from . import settings_templates
from .common import Alert
from .common import BatteryStatus
from .common import Notification
from .hidpp10_constants import Registers
from .hidpp20_constants import SupportedFeature
if typing.TYPE_CHECKING:
from .base import HIDPPNotification
from .device import Device
from .receiver import Receiver
logger = logging.getLogger(__name__)
NotificationHandler = typing.Callable[["Receiver", "HIDPPNotification"], bool]
_hidpp10 = hidpp10.Hidpp10()
_hidpp20 = hidpp20.Hidpp20()
_F = hidpp20_constants.SupportedFeature
notification_lock = threading.Lock()
def process(device, notification):
def process(device: Device | Receiver, notification: HIDPPNotification):
"""Handle incoming events (notification) from device or receiver."""
assert device
assert notification
if not device.isDevice:
return _process_receiver_notification(device, notification)
return _process_device_notification(device, notification)
return process_receiver_notification(device, notification)
return process_device_notification(device, notification)
def _process_receiver_notification(receiver: Receiver, hidpp_notification: HIDPPNotification) -> bool | None:
# supposedly only 0x4x notifications arrive for the receiver
assert hidpp_notification.sub_id in [
def process_receiver_notification(receiver: Receiver, notification: HIDPPNotification) -> bool | None:
"""Process event messages from receivers."""
event_handler_mapping: dict[int, NotificationHandler] = {
Notification.PAIRING_LOCK: handle_pairing_lock,
Registers.DEVICE_DISCOVERY_NOTIFICATION: handle_device_discovery,
Registers.DISCOVERY_STATUS_NOTIFICATION: handle_discovery_status,
Registers.PAIRING_STATUS_NOTIFICATION: handle_pairing_status,
Registers.PASSKEY_PRESSED_NOTIFICATION: handle_passkey_pressed,
Registers.PASSKEY_REQUEST_NOTIFICATION: handle_passkey_request,
}
try:
handler_func = event_handler_mapping[notification.sub_id]
return handler_func(receiver, notification)
except KeyError:
pass
assert notification.sub_id in [
Notification.CONNECT_DISCONNECT,
Notification.DJ_PAIRING,
Notification.CONNECTED,
Notification.RAW_INPUT,
Notification.PAIRING_LOCK,
Notification.POWER,
Registers.DEVICE_DISCOVERY_NOTIFICATION,
Registers.DISCOVERY_STATUS_NOTIFICATION,
Registers.PAIRING_STATUS_NOTIFICATION,
Registers.PASSKEY_PRESSED_NOTIFICATION,
Registers.PASSKEY_REQUEST_NOTIFICATION,
]
if hidpp_notification.sub_id == Notification.PAIRING_LOCK:
receiver.pairing.lock_open = bool(hidpp_notification.address & 0x01)
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
if logger.isEnabledFor(logging.INFO):
logger.info("%s: %s", receiver, reason)
receiver.pairing.error = None
if receiver.pairing.lock_open:
receiver.pairing.new_device = None
pair_error = ord(hidpp_notification.data[:1])
if pair_error:
receiver.pairing.error = error_string = hidpp10_constants.PairingError(pair_error)
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
return True
elif hidpp_notification.sub_id == Registers.DISCOVERY_STATUS_NOTIFICATION: # Bolt pairing
with notification_lock:
receiver.pairing.discovering = hidpp_notification.address == 0x00
reason = _("discovery lock is open") if receiver.pairing.discovering else _("discovery lock is closed")
if logger.isEnabledFor(logging.INFO):
logger.info("%s: %s", receiver, reason)
receiver.pairing.error = None
if receiver.pairing.discovering:
receiver.pairing.counter = receiver.pairing.device_address = None
receiver.pairing.device_authentication = receiver.pairing.device_name = None
receiver.pairing.device_passkey = None
discover_error = ord(hidpp_notification.data[:1])
if discover_error:
receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error)
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
receiver.changed(reason=reason)
return True
elif hidpp_notification.sub_id == Registers.DEVICE_DISCOVERY_NOTIFICATION: # Bolt pairing
with notification_lock:
counter = hidpp_notification.address + hidpp_notification.data[0] * 256 # notification counter
if receiver.pairing.counter is None:
receiver.pairing.counter = counter
else:
if not receiver.pairing.counter == counter:
return None
if hidpp_notification.data[1] == 0:
receiver.pairing.device_kind = hidpp_notification.data[3]
receiver.pairing.device_address = hidpp_notification.data[6:12]
receiver.pairing.device_authentication = hidpp_notification.data[14]
elif hidpp_notification.data[1] == 1:
receiver.pairing.device_name = hidpp_notification.data[3 : 3 + hidpp_notification.data[2]].decode("utf-8")
return True
elif hidpp_notification.sub_id == Registers.PAIRING_STATUS_NOTIFICATION: # Bolt pairing
with notification_lock:
receiver.pairing.device_passkey = None
receiver.pairing.lock_open = hidpp_notification.address == 0x00
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
if logger.isEnabledFor(logging.INFO):
logger.info("%s: %s", receiver, reason)
receiver.pairing.error = None
if not receiver.pairing.lock_open:
receiver.pairing.counter = None
receiver.pairing.device_address = None
receiver.pairing.device_authentication = None
receiver.pairing.device_name = None
pair_error = hidpp_notification.data[0]
if receiver.pairing.lock_open:
receiver.pairing.new_device = None
elif hidpp_notification.address == 0x02 and not pair_error:
receiver.pairing.new_device = receiver.register_new_device(hidpp_notification.data[7])
if pair_error:
receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error)
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
return True
elif hidpp_notification.sub_id == Registers.PASSKEY_REQUEST_NOTIFICATION: # Bolt pairing
with notification_lock:
receiver.pairing.device_passkey = hidpp_notification.data[0:6].decode("utf-8")
return True
elif hidpp_notification.sub_id == Registers.PASSKEY_PRESSED_NOTIFICATION: # Bolt pairing
return True
logger.warning("%s: unhandled notification %s", receiver, hidpp_notification)
logger.warning(f"{receiver}: unhandled notification {notification}")
def _process_device_notification(device, n):
def process_device_notification(device: Device, notification: HIDPPNotification):
"""Process event messages from devices."""
# incoming packets with SubId >= 0x80 are supposedly replies from HID++ 1.0 requests, should never get here
assert n.sub_id & 0x80 == 0
assert notification.sub_id & 0x80 == 0
if n.sub_id == Notification.NO_OPERATION:
if notification.sub_id == Notification.NO_OPERATION:
# dispose it
return False
# Allow the device object to handle the notification using custom per-device state.
handling_ret = device.handle_notification(n)
handling_ret = device.handle_notification(notification)
if handling_ret is not None:
return handling_ret
# 0x40 to 0x7F appear to be HID++ 1.0 or DJ notifications
if n.sub_id >= 0x40:
if n.report_id == base.DJ_MESSAGE_ID:
return _process_dj_notification(device, n)
if notification.sub_id >= 0x40:
if notification.report_id == base.DJ_MESSAGE_ID:
return _process_dj_notification(device, notification)
else:
return _process_hidpp10_notification(device, n)
return _process_hidpp10_notification(device, notification)
# These notifications are from the device itself, so it must be active
device.online = True
@@ -194,63 +123,58 @@ def _process_device_notification(device, n):
# some custom battery events for HID++ 1.0 devices
if device.protocol < 2.0:
return _process_hidpp10_custom_notification(device, n)
return _process_hidpp10_custom_notification(device, notification)
# assuming 0x00 to 0x3F are feature (HID++ 2.0) notifications
if not device.features:
logger.warning("%s: feature notification but features not set up: %02X %s", device, n.sub_id, n)
return False
try:
feature = device.features.get_feature(n.sub_id)
except IndexError:
logger.warning("%s: notification from invalid feature index %02X: %s", device, n.sub_id, n)
logger.warning("%s: feature notification but features not set up: %02X %s", device, notification.sub_id, notification)
return False
return _process_feature_notification(device, n, feature)
return _process_feature_notification(device, notification)
def _process_dj_notification(device, n):
def _process_dj_notification(device: Device, notification: HIDPPNotification):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s (%s) DJ %s", device, device.protocol, n)
logger.debug("%s (%s) DJ %s", device, device.protocol, notification)
if n.sub_id == Notification.CONNECT_DISCONNECT:
if notification.sub_id == Notification.CONNECT_DISCONNECT:
# do all DJ paired notifications also show up as HID++ 1.0 notifications?
if logger.isEnabledFor(logging.INFO):
logger.info("%s: ignoring DJ unpaired: %s", device, n)
logger.info("%s: ignoring DJ unpaired: %s", device, notification)
return True
if n.sub_id == Notification.DJ_PAIRING:
if notification.sub_id == Notification.DJ_PAIRING:
# do all DJ paired notifications also show up as HID++ 1.0 notifications?
if logger.isEnabledFor(logging.INFO):
logger.info("%s: ignoring DJ paired: %s", device, n)
logger.info("%s: ignoring DJ paired: %s", device, notification)
return True
if n.sub_id == Notification.CONNECTED:
connected = not n.address & 0x01
if notification.sub_id == Notification.CONNECTED:
connected = not notification.address & 0x01
if logger.isEnabledFor(logging.INFO):
logger.info("%s: DJ connection: %s %s", device, connected, n)
logger.info("%s: DJ connection: %s %s", device, connected, notification)
device.changed(active=connected, alert=Alert.NONE, reason=_("connected") if connected else _("disconnected"))
return True
logger.warning("%s: unrecognized DJ %s", device, n)
logger.warning("%s: unrecognized DJ %s", device, notification)
def _process_hidpp10_custom_notification(device, n):
def _process_hidpp10_custom_notification(device: Device, notification: HIDPPNotification):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s (%s) custom notification %s", device, device.protocol, n)
logger.debug("%s (%s) custom notification %s", device, device.protocol, notification)
if n.sub_id in (Registers.BATTERY_STATUS, Registers.BATTERY_CHARGE):
assert n.data[-1:] == b"\x00"
data = chr(n.address).encode() + n.data
device.set_battery_info(hidpp10.parse_battery_status(n.sub_id, data))
if notification.sub_id in (Registers.BATTERY_STATUS, Registers.BATTERY_CHARGE):
assert notification.data[-1:] == b"\x00"
data = chr(notification.address).encode() + notification.data
device.set_battery_info(hidpp10.parse_battery_status(notification.sub_id, data))
return True
logger.warning("%s: unrecognized %s", device, n)
logger.warning("%s: unrecognized %s", device, notification)
def _process_hidpp10_notification(device, n):
if n.sub_id == Notification.CONNECT_DISCONNECT: # device unpairing
if n.address == 0x02:
def _process_hidpp10_notification(device: Device, notification: HIDPPNotification):
if notification.sub_id == Notification.CONNECT_DISCONNECT: # device unpairing
if notification.address == 0x02:
# device un-paired
device.wpid = None
if device.number in device.receiver:
@@ -258,21 +182,23 @@ def _process_hidpp10_notification(device, n):
device.changed(active=False, alert=Alert.ALL, reason=_("unpaired"))
## device.status = None
else:
logger.warning("%s: disconnection with unknown type %02X: %s", device, n.address, n)
logger.warning("%s: disconnection with unknown type %02X: %s", device, notification.address, notification)
return True
if n.sub_id == Notification.DJ_PAIRING: # device connection (and disconnection)
flags = ord(n.data[:1]) & 0xF0
if n.address == 0x02: # very old 27 MHz protocol
wpid = "00" + common.strhex(n.data[2:3])
if notification.sub_id == Notification.DJ_PAIRING: # device connection (and disconnection)
flags = ord(notification.data[:1]) & 0xF0
if notification.address == 0x02: # very old 27 MHz protocol
wpid = "00" + common.strhex(notification.data[2:3])
link_established = True
link_encrypted = bool(flags & 0x80)
elif n.address > 0x00: # all other protocols are supposed to be almost the same
wpid = common.strhex(n.data[2:3] + n.data[1:2])
elif notification.address > 0x00: # all other protocols are supposed to be almost the same
wpid = common.strhex(notification.data[2:3] + notification.data[1:2])
link_established = not (flags & 0x40)
link_encrypted = bool(flags & 0x20) or n.address == 0x10 # Bolt protocol always encrypted
link_encrypted = bool(flags & 0x20) or notification.address == 0x10 # Bolt protocol always encrypted
else:
logger.warning("%s: connection notification with unknown protocol %02X: %s", device.number, n.address, n)
logger.warning(
"%s: connection notification with unknown protocol %02X: %s", device.number, notification.address, notification
)
return True
if wpid != device.wpid:
logger.warning("%s wpid mismatch, got %s", device, wpid)
@@ -280,7 +206,7 @@ def _process_hidpp10_notification(device, n):
logger.debug(
"%s: protocol %s connection notification: software=%s, encrypted=%s, link=%s, payload=%s",
device,
n.address,
notification.address,
bool(flags & 0x10),
link_encrypted,
link_established,
@@ -292,151 +218,164 @@ def _process_hidpp10_notification(device, n):
device.changed(active=link_established)
return True
if n.sub_id == Notification.RAW_INPUT:
if notification.sub_id == Notification.RAW_INPUT:
# raw input event? just ignore it
# if n.address == 0x01, no idea what it is, but they keep on coming
# if n.address == 0x03, appears to be an actual input event, because they only come when input happents
# if notification.address == 0x01, no idea what it is, but they keep on coming
# if notification.address == 0x03, appears to be an actual input event, because they only come when input happents
return True
if n.sub_id == Notification.POWER:
if n.address == 0x01:
if notification.sub_id == Notification.POWER:
if notification.address == 0x01:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: device powered on", device)
reason = device.status_string() or _("powered on")
device.changed(active=True, alert=Alert.NOTIFICATION, reason=reason)
else:
logger.warning("%s: unknown %s", device, n)
logger.warning("%s: unknown %s", device, notification)
return True
logger.warning("%s: unrecognized %s", device, n)
logger.warning("%s: unrecognized %s", device, notification)
def _process_feature_notification(device, n, feature):
def _process_feature_notification(device: Device, notification: HIDPPNotification):
old_present, device.present = device.present, True # the device is generating a feature notification so it must be present
try:
feature = device.features.get_feature(notification.sub_id)
except IndexError:
logger.warning("%s: notification from invalid feature index %02X: %s", device, notification.sub_id, notification)
return False
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%s: notification for feature %s, report %s, data %s", device, feature, n.address >> 4, common.strhex(n.data)
"%s: notification for feature %s, report %s, data %s",
device,
feature,
notification.address >> 4,
common.strhex(notification.data),
)
if feature == _F.BATTERY_STATUS:
if n.address == 0x00:
device.set_battery_info(hidpp20.decipher_battery_status(n.data)[1])
elif n.address == 0x10:
if feature == SupportedFeature.BATTERY_STATUS:
if notification.address == 0x00:
device.set_battery_info(hidpp20.decipher_battery_status(notification.data)[1])
elif notification.address == 0x10:
if logger.isEnabledFor(logging.INFO):
logger.info("%s: spurious BATTERY status %s", device, n)
logger.info("%s: spurious BATTERY status %s", device, notification)
else:
logger.warning("%s: unknown BATTERY %s", device, n)
logger.warning("%s: unknown BATTERY %s", device, notification)
elif feature == _F.BATTERY_VOLTAGE:
if n.address == 0x00:
device.set_battery_info(hidpp20.decipher_battery_voltage(n.data)[1])
elif feature == SupportedFeature.BATTERY_VOLTAGE:
if notification.address == 0x00:
device.set_battery_info(hidpp20.decipher_battery_voltage(notification.data)[1])
else:
logger.warning("%s: unknown VOLTAGE %s", device, n)
logger.warning("%s: unknown VOLTAGE %s", device, notification)
elif feature == _F.UNIFIED_BATTERY:
if n.address == 0x00:
device.set_battery_info(hidpp20.decipher_battery_unified(n.data)[1])
elif feature == SupportedFeature.UNIFIED_BATTERY:
if notification.address == 0x00:
device.set_battery_info(hidpp20.decipher_battery_unified(notification.data)[1])
else:
logger.warning("%s: unknown UNIFIED BATTERY %s", device, n)
logger.warning("%s: unknown UNIFIED BATTERY %s", device, notification)
elif feature == _F.ADC_MEASUREMENT:
if n.address == 0x00:
result = hidpp20.decipher_adc_measurement(n.data)
if result:
elif feature == SupportedFeature.ADC_MEASUREMENT:
if notification.address == 0x00:
result = hidpp20.decipher_adc_measurement(notification.data)
if result: # if good data and the device was not present then a push is needed
device.set_battery_info(result[1])
else: # this feature is used to signal device becoming inactive
device.changed(active=True, alert=Alert.NONE, reason=_("ADC measurement notification"), push=not old_present)
else: # this feature is also used to signal device becoming inactive
device.present = False # exception to device presence
device.changed(active=False)
else:
logger.warning("%s: unknown ADC MEASUREMENT %s", device, n)
logger.warning("%s: unknown ADC MEASUREMENT %s", device, notification)
elif feature == _F.SOLAR_DASHBOARD:
if n.data[5:9] == b"GOOD":
charge, lux, adc = struct.unpack("!BHH", n.data[:5])
elif feature == SupportedFeature.SOLAR_DASHBOARD:
if notification.data[5:9] == b"GOOD":
charge, lux, adc = struct.unpack("!BHH", notification.data[:5])
# guesstimate the battery voltage, emphasis on 'guess'
# status_text = '%1.2fV' % (adc * 2.67793237653 / 0x0672)
status_text = BatteryStatus.DISCHARGING
if n.address == 0x00:
if notification.address == 0x00:
device.set_battery_info(common.Battery(charge, None, status_text, None))
elif n.address == 0x10:
elif notification.address == 0x10:
if lux > 200:
status_text = BatteryStatus.RECHARGING
device.set_battery_info(common.Battery(charge, None, status_text, None, lux))
elif n.address == 0x20:
elif notification.address == 0x20:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: Light Check button pressed", device)
device.changed(alert=Alert.SHOW_WINDOW)
# first cancel any reporting
# device.feature_request(_F.SOLAR_DASHBOARD)
# device.feature_request(SupportedFeature.SOLAR_DASHBOARD)
# trigger a new report chain
reports_count = 15
reports_period = 2 # seconds
device.feature_request(_F.SOLAR_DASHBOARD, 0x00, reports_count, reports_period)
device.feature_request(SupportedFeature.SOLAR_DASHBOARD, 0x00, reports_count, reports_period)
else:
logger.warning("%s: unknown SOLAR CHARGE %s", device, n)
logger.warning("%s: unknown SOLAR CHARGE %s", device, notification)
else:
logger.warning("%s: SOLAR CHARGE not GOOD? %s", device, n)
logger.warning("%s: SOLAR CHARGE not GOOD? %s", device, notification)
elif feature == _F.WIRELESS_DEVICE_STATUS:
if n.address == 0x00:
elif feature == SupportedFeature.WIRELESS_DEVICE_STATUS:
if notification.address == 0x00:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("wireless status: %s", n)
reason = "powered on" if n.data[2] == 1 else None
if n.data[1] == 1: # device is asking for software reconfiguration so need to change status
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
alert = Alert.NONE
device.changed(active=True, alert=alert, reason=reason, push=True)
else:
logger.warning("%s: unknown WIRELESS %s", device, n)
logger.warning("%s: unknown WIRELESS %s", device, notification)
elif feature == _F.TOUCHMOUSE_RAW_POINTS:
if n.address == 0x00:
elif feature == SupportedFeature.TOUCHMOUSE_RAW_POINTS:
if notification.address == 0x00:
if logger.isEnabledFor(logging.INFO):
logger.info("%s: TOUCH MOUSE points %s", device, n)
elif n.address == 0x10:
touch = ord(n.data[:1])
logger.info("%s: TOUCH MOUSE points %s", device, notification)
elif notification.address == 0x10:
touch = ord(notification.data[:1])
button_down = bool(touch & 0x02)
mouse_lifted = bool(touch & 0x01)
if logger.isEnabledFor(logging.INFO):
logger.info("%s: TOUCH MOUSE status: button_down=%s mouse_lifted=%s", device, button_down, mouse_lifted)
else:
logger.warning("%s: unknown TOUCH MOUSE %s", device, n)
logger.warning("%s: unknown TOUCH MOUSE %s", device, notification)
# TODO: what are REPROG_CONTROLS_V{2,3}?
elif feature == _F.REPROG_CONTROLS:
if n.address == 0x00:
elif feature == SupportedFeature.REPROG_CONTROLS:
if notification.address == 0x00:
if logger.isEnabledFor(logging.INFO):
logger.info("%s: reprogrammable key: %s", device, n)
logger.info("%s: reprogrammable key: %s", device, notification)
else:
logger.warning("%s: unknown REPROG_CONTROLS %s", device, n)
logger.warning("%s: unknown REPROG_CONTROLS %s", device, notification)
elif feature == _F.BACKLIGHT2:
if n.address == 0x00:
level = struct.unpack("!B", n.data[1:2])[0]
elif feature == SupportedFeature.BACKLIGHT2:
if notification.address == 0x00:
level = struct.unpack("!B", notification.data[1:2])[0]
if device.setting_callback:
device.setting_callback(device, settings_templates.Backlight2Level, [level])
elif feature == _F.REPROG_CONTROLS_V4:
if n.address == 0x00:
elif feature == SupportedFeature.REPROG_CONTROLS_V4:
if notification.address == 0x00:
if logger.isEnabledFor(logging.DEBUG):
cid1, cid2, cid3, cid4 = struct.unpack("!HHHH", n.data[:8])
cid1, cid2, cid3, cid4 = struct.unpack("!HHHH", notification.data[:8])
logger.debug("%s: diverted controls pressed: 0x%x, 0x%x, 0x%x, 0x%x", device, cid1, cid2, cid3, cid4)
elif n.address == 0x10:
elif notification.address == 0x10:
if logger.isEnabledFor(logging.DEBUG):
dx, dy = struct.unpack("!hh", n.data[:4])
dx, dy = struct.unpack("!hh", notification.data[:4])
logger.debug("%s: rawXY dx=%i dy=%i", device, dx, dy)
elif n.address == 0x20:
elif notification.address == 0x20:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: received analyticsKeyEvents", device)
elif logger.isEnabledFor(logging.INFO):
logger.info("%s: unknown REPROG_CONTROLS_V4 %s", device, n)
logger.info("%s: unknown REPROG_CONTROLS_V4 %s", device, notification)
elif feature == _F.HIRES_WHEEL:
if n.address == 0x00:
elif feature == SupportedFeature.HIRES_WHEEL:
if notification.address == 0x00:
if logger.isEnabledFor(logging.INFO):
flags, delta_v = struct.unpack(">bh", n.data[:3])
flags, delta_v = struct.unpack(">bh", notification.data[:3])
high_res = (flags & 0x10) != 0
periods = flags & 0x0F
logger.info("%s: WHEEL: res: %d periods: %d delta V:%-3d", device, high_res, periods, delta_v)
elif n.address == 0x10:
ratchet = n.data[0]
elif notification.address == 0x10:
ratchet = notification.data[0]
if logger.isEnabledFor(logging.INFO):
logger.info("%s: WHEEL: ratchet: %d", device, ratchet)
if ratchet < 2: # don't process messages with unusual ratchet values
@@ -444,20 +383,20 @@ def _process_feature_notification(device, n, feature):
device.setting_callback(device, settings_templates.ScrollRatchet, [2 if ratchet else 1])
else:
if logger.isEnabledFor(logging.INFO):
logger.info("%s: unknown WHEEL %s", device, n)
logger.info("%s: unknown WHEEL %s", device, notification)
elif feature == _F.ONBOARD_PROFILES:
if n.address > 0x10:
elif feature == SupportedFeature.ONBOARD_PROFILES:
if notification.address > 0x10:
if logger.isEnabledFor(logging.INFO):
logger.info("%s: unknown ONBOARD PROFILES %s", device, n)
logger.info("%s: unknown ONBOARD PROFILES %s", device, notification)
else:
if n.address == 0x00:
profile_sector = struct.unpack("!H", n.data[:2])[0]
if notification.address == 0x00:
profile_sector = struct.unpack("!H", notification.data[:2])[0]
if profile_sector:
settings_templates.profile_change(device, profile_sector)
elif n.address == 0x10:
resolution_index = struct.unpack("!B", n.data[:1])[0]
profile_sector = struct.unpack("!H", device.feature_request(_F.ONBOARD_PROFILES, 0x40)[:2])[0]
elif notification.address == 0x10:
resolution_index = struct.unpack("!B", notification.data[:1])[0]
profile_sector = struct.unpack("!H", device.feature_request(SupportedFeature.ONBOARD_PROFILES, 0x40)[:2])[0]
if device.setting_callback:
for profile in device.profiles.profiles.values() if device.profiles else []:
if profile.sector == profile_sector:
@@ -466,19 +405,109 @@ def _process_feature_notification(device, n, feature):
)
break
elif feature == _F.BRIGHTNESS_CONTROL:
if n.address > 0x10:
elif feature == SupportedFeature.BRIGHTNESS_CONTROL:
if notification.address > 0x10:
if logger.isEnabledFor(logging.INFO):
logger.info("%s: unknown BRIGHTNESS CONTROL %s", device, n)
logger.info("%s: unknown BRIGHTNESS CONTROL %s", device, notification)
else:
if n.address == 0x00:
brightness = struct.unpack("!H", n.data[:2])[0]
if notification.address == 0x00:
brightness = struct.unpack("!H", notification.data[:2])[0]
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
elif n.address == 0x10:
brightness = n.data[0] & 0x01
elif notification.address == 0x10:
brightness = notification.data[0] & 0x01
if brightness:
brightness = struct.unpack("!H", device.feature_request(_F.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
brightness = struct.unpack("!H", device.feature_request(SupportedFeature.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
diversion.process_notification(device, n, feature)
diversion.process_notification(device, notification, feature)
return True
def handle_pairing_lock(receiver: Receiver, notification: HIDPPNotification) -> bool:
receiver.pairing.lock_open = bool(notification.address & 0x01)
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
if logger.isEnabledFor(logging.INFO):
logger.info("%s: %s", receiver, reason)
receiver.pairing.error = None
if receiver.pairing.lock_open:
receiver.pairing.new_device = None
pair_error = ord(notification.data[:1])
if pair_error:
error_string = hidpp10_constants.PairingError(pair_error).label
receiver.pairing.error = error_string
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
return True
def handle_discovery_status(receiver: Receiver, notification: HIDPPNotification) -> bool:
with notification_lock:
receiver.pairing.discovering = notification.address == 0x00
reason = _("discovery lock is open") if receiver.pairing.discovering else _("discovery lock is closed")
if logger.isEnabledFor(logging.INFO):
logger.info("%s: %s", receiver, reason)
receiver.pairing.error = None
if receiver.pairing.discovering:
receiver.pairing.counter = receiver.pairing.device_address = None
receiver.pairing.device_authentication = receiver.pairing.device_name = None
receiver.pairing.device_passkey = None
discover_error = ord(notification.data[:1])
if discover_error:
receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error).label
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
receiver.changed(reason=reason)
return True
def handle_device_discovery(receiver: Receiver, notification: HIDPPNotification) -> bool:
with notification_lock:
counter = notification.address + notification.data[0] * 256 # notification counter
if receiver.pairing.counter is None:
receiver.pairing.counter = counter
else:
if not receiver.pairing.counter == counter:
return None
if notification.data[1] == 0:
receiver.pairing.device_kind = notification.data[3]
receiver.pairing.device_address = notification.data[6:12]
receiver.pairing.device_authentication = notification.data[14]
elif notification.data[1] == 1:
receiver.pairing.device_name = notification.data[3 : 3 + notification.data[2]].decode("utf-8")
return True
def handle_pairing_status(receiver: Receiver, notification: HIDPPNotification) -> bool:
with notification_lock:
receiver.pairing.device_passkey = None
receiver.pairing.lock_open = notification.address == 0x00
reason = _("pairing lock is open") if receiver.pairing.lock_open else _("pairing lock is closed")
if logger.isEnabledFor(logging.INFO):
logger.info("%s: %s", receiver, reason)
receiver.pairing.error = None
if not receiver.pairing.lock_open:
receiver.pairing.counter = None
receiver.pairing.device_address = None
receiver.pairing.device_authentication = None
receiver.pairing.device_name = None
pair_error = notification.data[0]
if receiver.pairing.lock_open:
receiver.pairing.new_device = None
elif notification.address == 0x02 and not pair_error:
receiver.pairing.new_device = receiver.register_new_device(notification.data[7])
if pair_error:
receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error).label
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
return True
def handle_passkey_request(receiver: Receiver, notification: HIDPPNotification) -> bool:
with notification_lock:
receiver.pairing.device_passkey = notification.data[0:6].decode("utf-8")
return True
def handle_passkey_pressed(_receiver: Receiver, _hidpp_notification: HIDPPNotification) -> bool:
return True

View File

@@ -36,15 +36,18 @@ from . import hidpp10_constants
from .common import Alert
from .common import Notification
from .device import Device
from .hidpp10_constants import InfoSubRegisters
from .hidpp10_constants import NotificationFlag
from .hidpp10_constants import Registers
if typing.TYPE_CHECKING:
from logitech_receiver import common
from .base import HIDPPNotification
logger = logging.getLogger(__name__)
_hidpp10 = hidpp10.Hidpp10()
_IR = hidpp10_constants.INFO_SUBREGISTERS
class LowLevelInterface(Protocol):
@@ -80,6 +83,53 @@ class Pairing:
error: Optional[any] = None
def extract_serial(response: bytes) -> str:
"""Extracts serial number from receiver response."""
return response.hex().upper()
def extract_max_devices(response: bytes) -> int:
"""Extracts maximum number of supported devices from response."""
max_devices = response[6]
return int(max_devices)
def extract_remaining_pairings(response: bytes) -> int:
ps = ord(response[2:3])
remaining_pairings = ps - 5 if ps >= 5 else -1
return int(remaining_pairings)
def extract_codename(response: bytes) -> str:
codename = response[2 : 2 + ord(response[1:2])]
return codename.decode("ascii")
def extract_power_switch_location(response: bytes) -> str:
"""Extracts power switch location from response."""
index = response[9] & 0x0F
return hidpp10_constants.PowerSwitchLocation.location(index).name.lower()
def extract_connection_count(response: bytes) -> int:
"""Extract connection count from receiver response."""
return ord(response[1:2])
def extract_wpid(response: bytes) -> str:
"""Extract wpid from receiver response."""
return response.hex().upper()
def extract_polling_rate(response: bytes) -> int:
"""Returns polling rate in milliseconds."""
return int(response[2])
def extract_device_kind(response: int) -> str:
return hidpp10_constants.DEVICE_KIND[response]
class Receiver:
"""A generic Receiver instance, mostly implementing the interface used on Unifying, Nano, and LightSpeed receivers"
The paired devices are available through the sequence interface.
@@ -124,11 +174,11 @@ class Receiver:
def initialize(self, product_info: dict):
# read the receiver information subregister, so we can find out max_devices
serial_reply = self.read_register(Registers.RECEIVER_INFO, _IR.receiver_information)
serial_reply = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.RECEIVER_INFORMATION)
if serial_reply:
self.serial = serial_reply[1:5].hex().upper()
self.max_devices = serial_reply[6]
if self.max_devices <= 0 or self.max_devices > 6:
self.serial = extract_serial(serial_reply[1:5])
self.max_devices = extract_max_devices(serial_reply)
if not (1 <= self.max_devices <= 6):
self.max_devices = product_info.get("max_devices", 1)
else: # handle receivers that don't have a serial number specially (i.e., c534)
self.serial = None
@@ -161,8 +211,7 @@ class Receiver:
if self._remaining_pairings is None or not cache:
ps = self.read_register(Registers.RECEIVER_CONNECTION)
if ps is not None:
ps = ord(ps[2:3])
self._remaining_pairings = ps - 5 if ps >= 5 else -1
self._remaining_pairings = extract_remaining_pairings(ps)
return self._remaining_pairings
def enable_connection_notifications(self, enable=True):
@@ -172,7 +221,7 @@ class Receiver:
return False
if enable:
set_flag_bits = hidpp10_constants.NOTIFICATION_FLAG.wireless | hidpp10_constants.NOTIFICATION_FLAG.software_present
set_flag_bits = NotificationFlag.WIRELESS | NotificationFlag.SOFTWARE_PRESENT
else:
set_flag_bits = 0
ok = _hidpp10.set_notification_flags(self, set_flag_bits)
@@ -181,16 +230,18 @@ class Receiver:
return None
flag_bits = _hidpp10.get_notification_flags(self)
flag_names = None if flag_bits is None else tuple(hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits))
if flag_bits is None:
flag_names = None
else:
flag_names = hidpp10_constants.NotificationFlag.flag_names(flag_bits)
if logger.isEnabledFor(logging.INFO):
logger.info("%s: receiver notifications %s => %s", self, "enabled" if enable else "disabled", flag_names)
return flag_bits
def device_codename(self, n):
codename = self.read_register(Registers.RECEIVER_INFO, _IR.device_name + n - 1)
codename = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.DEVICE_NAME + n - 1)
if codename:
codename = codename[2 : 2 + ord(codename[1:2])]
return codename.decode("ascii")
return extract_codename(codename)
def notify_devices(self):
"""Scan all devices."""
@@ -198,13 +249,13 @@ class Receiver:
if not self.write_register(Registers.RECEIVER_CONNECTION, 0x02):
logger.warning("%s: failed to trigger device link notifications", self)
def notification_information(self, number, notification):
def notification_information(self, number, notification: HIDPPNotification) -> tuple[bool, bool, typing.Any, str]:
"""Extract information from unifying-style notification"""
assert notification.address != 0x02
online = not bool(notification.data[0] & 0x40)
encrypted = bool(notification.data[0] & 0x20) or notification.address == 0x10
kind = hidpp10_constants.DEVICE_KIND[notification.data[0] & 0x0F]
wpid = (notification.data[2:3] + notification.data[1:2]).hex().upper()
kind = extract_device_kind(notification.data[0] & 0x0F)
wpid = extract_wpid(notification.data[2:3] + notification.data[1:2])
return online, encrypted, wpid, kind
def device_pairing_information(self, n: int) -> dict:
@@ -212,30 +263,31 @@ class Receiver:
polling_rate = ""
serial = None
power_switch = "(unknown)"
pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.pairing_information + n - 1)
pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.PAIRING_INFORMATION + n - 1)
if pair_info: # a receiver that uses Unifying-style pairing registers
wpid = pair_info[3:5].hex().upper()
kind = hidpp10_constants.DEVICE_KIND[pair_info[7] & 0x0F]
polling_rate = str(pair_info[2]) + "ms"
wpid = extract_wpid(pair_info[3:5])
kind = extract_device_kind(pair_info[7] & 0x0F)
polling_rate_ms = extract_polling_rate(pair_info)
polling_rate = f"{polling_rate_ms}ms"
elif not self.receiver_kind == "unifying": # may be an old Nano receiver
device_info = self.read_register(Registers.RECEIVER_INFO, 0x04) # undocumented
if device_info:
logger.warning("using undocumented register for device wpid")
wpid = device_info[3:5].hex().upper()
kind = hidpp10_constants.DEVICE_KIND[0x00] # unknown kind
wpid = extract_wpid(device_info[3:5])
kind = extract_device_kind(0x00) # unknown kind
else:
raise exceptions.NoSuchDevice(number=n, receiver=self, error="read pairing information - non-unifying")
else:
raise exceptions.NoSuchDevice(number=n, receiver=self, error="read pairing information")
pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.extended_pairing_information + n - 1)
pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.EXTENDED_PAIRING_INFORMATION + n - 1)
if pair_info:
power_switch = hidpp10_constants.POWER_SWITCH_LOCATION[pair_info[9] & 0x0F]
serial = pair_info[1:5].hex().upper()
power_switch = extract_power_switch_location(pair_info)
serial = extract_serial(pair_info[1:5])
else: # some Nano receivers?
pair_info = self.read_register(0x2D5) # undocumented and questionable
if pair_info:
logger.warning("using undocumented register for device serial number")
serial = pair_info[1:5].hex().upper()
serial = extract_serial(pair_info[1:5])
return {"wpid": wpid, "kind": kind, "polling": polling_rate, "serial": serial, "power_switch": power_switch}
def register_new_device(self, number, notification=None):
@@ -281,7 +333,9 @@ class Receiver:
def count(self):
count = self.read_register(Registers.RECEIVER_CONNECTION)
return 0 if count is None else ord(count[1:2])
if count is None:
return 0
return extract_connection_count(count)
def request(self, request_id, *params):
if bool(self):
@@ -406,21 +460,21 @@ class BoltReceiver(Receiver):
def initialize(self, product_info: dict):
serial_reply = self.read_register(Registers.BOLT_UNIQUE_ID)
self.serial = serial_reply.hex().upper()
self.serial = extract_serial(serial_reply)
self.max_devices = product_info.get("max_devices", 1)
def device_codename(self, n):
codename = self.read_register(Registers.RECEIVER_INFO, _IR.bolt_device_name + n, 0x01)
codename = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.BOLT_DEVICE_NAME + n, 0x01)
if codename:
codename = codename[3 : 3 + min(14, ord(codename[2:3]))]
return codename.decode("ascii")
def device_pairing_information(self, n: int) -> dict:
pair_info = self.read_register(Registers.RECEIVER_INFO, _IR.bolt_pairing_information + n)
pair_info = self.read_register(Registers.RECEIVER_INFO, InfoSubRegisters.BOLT_PAIRING_INFORMATION + n)
if pair_info:
wpid = (pair_info[3:4] + pair_info[2:3]).hex().upper()
kind = hidpp10_constants.DEVICE_KIND[pair_info[1] & 0x0F]
serial = pair_info[4:8].hex().upper()
wpid = extract_wpid(pair_info[3:4] + pair_info[2:3])
kind = extract_device_kind(pair_info[1] & 0x0F)
serial = extract_serial(pair_info[4:8])
return {"wpid": wpid, "kind": kind, "polling": None, "serial": serial, "power_switch": "(unknown)"}
else:
raise exceptions.NoSuchDevice(number=n, receiver=self, error="can't read Bolt pairing register")
@@ -478,8 +532,8 @@ class Ex100Receiver(Receiver):
assert notification.address == 0x02
online = True
encrypted = bool(notification.data[0] & 0x80)
kind = hidpp10_constants.DEVICE_KIND[_get_kind_from_index(self, number)]
wpid = "00" + notification.data[2:3].hex().upper()
kind = extract_device_kind(_get_kind_from_index(self, number))
wpid = "00" + extract_wpid(notification.data[2:3])
return online, encrypted, wpid, kind
def device_pairing_information(self, number: int) -> dict:
@@ -488,11 +542,11 @@ class Ex100Receiver(Receiver):
if not wpid:
logger.error("Unable to get wpid from udev for device %d of %s", number, self)
raise exceptions.NoSuchDevice(number=number, receiver=self, error="Not present 27Mhz device")
kind = hidpp10_constants.DEVICE_KIND[_get_kind_from_index(self, number)]
kind = extract_device_kind(_get_kind_from_index(self, number))
return {"wpid": wpid, "kind": kind, "polling": "", "serial": None, "power_switch": "(unknown)"}
def _get_kind_from_index(receiver, index):
def _get_kind_from_index(receiver, index: int) -> int:
"""Get device kind from 27Mhz device index"""
# From drivers/hid/hid-logitech-dj.c
if index == 1: # mouse
@@ -545,6 +599,6 @@ def create_receiver(low_level: LowLevelInterface, device_info, setting_callback=
except OSError as e:
logger.exception("open %s", device_info)
if e.errno == errno.EACCES:
raise
raise e
except Exception:
logger.exception("open %s", device_info)

View File

@@ -16,49 +16,36 @@
from __future__ import annotations
import logging
import math
import struct
import time
from enum import IntEnum
from typing import Any
from solaar.i18n import _
from . import common
from . import hidpp20_constants
from . import settings_validator
from .common import NamedInt
from .common import NamedInts
logger = logging.getLogger(__name__)
SENSITIVITY_IGNORE = "ignore"
KIND = NamedInts(
toggle=0x01,
choice=0x02,
range=0x04,
map_choice=0x0A,
multiple_toggle=0x10,
packed_range=0x20,
multiple_range=0x40,
hetero=0x80,
)
def bool_or_toggle(current: bool | str, new: bool | str) -> bool:
if isinstance(new, bool):
return new
try:
return bool(int(new))
except (TypeError, ValueError):
new = str(new).lower()
if new in ("true", "yes", "on", "t", "y"):
return True
if new in ("false", "no", "off", "f", "n"):
return False
if new in ("~", "toggle"):
return not current
return None
class Kind(IntEnum):
NONE = 0
TOGGLE = 0x01
CHOICE = 0x02
RANGE = 0x04
MAP_CHOICE = 0x0A
MULTIPLE_TOGGLE = 0x10
PACKED_RANGE = 0x20
MULTIPLE_RANGE = 0x40
HETERO = 0x80
MAP_RANGE = 0x102
COLOR = 0x200
class Setting:
@@ -71,6 +58,7 @@ class Setting:
rw_options = {}
validator_class = None
validator_options = {}
display = True # display setting in UI
def __init__(self, device, rw, validator):
self._device = device
@@ -103,14 +91,14 @@ class Setting:
assert hasattr(self, "_value")
assert hasattr(self, "_device")
return self._validator.choices if self._validator and self._validator.kind & KIND.choice else None
return self._validator.choices if self._validator and self._validator.kind & Kind.CHOICE else None
@property
def range(self):
assert hasattr(self, "_value")
assert hasattr(self, "_device")
if self._validator.kind == KIND.range:
if self._validator.kind == Kind.RANGE:
return self._validator.min_value, self._validator.max_value
def _pre_read(self, cached, key=None):
@@ -293,7 +281,7 @@ class Settings(Setting):
self._value[int(key)] = value
self._pre_write(save)
def write_key_value(self, key, value, save=True):
def write_key_value(self, key, value, save=True) -> Any | None:
assert hasattr(self, "_value")
assert hasattr(self, "_device")
assert key is not None
@@ -650,7 +638,10 @@ class FeatureRW:
def read(self, device, data_bytes=b""):
assert self.feature is not None
return device.feature_request(self.feature, self.read_fnid, self.prefix, self.read_prefix, data_bytes)
if self.read_fnid is not None:
return device.feature_request(self.feature, self.read_fnid, self.prefix, self.read_prefix, data_bytes)
else:
return b""
def write(self, device, data_bytes):
assert self.feature is not None
@@ -692,709 +683,6 @@ class FeatureRWMap(FeatureRW):
return reply if not self.no_reply else True
class Validator:
@classmethod
def build(cls, setting_class, device, **kwargs):
return cls(**kwargs)
@classmethod
def to_string(cls, value):
return str(value)
def compare(self, args, current):
if len(args) != 1:
return False
return args[0] == current
class BooleanValidator(Validator):
__slots__ = ("true_value", "false_value", "read_skip_byte_count", "write_prefix_bytes", "mask", "needs_current_value")
kind = KIND.toggle
default_true = 0x01
default_false = 0x00
# mask specifies all the affected bits in the value
default_mask = 0xFF
def __init__(
self,
true_value=default_true,
false_value=default_false,
mask=default_mask,
read_skip_byte_count=0,
write_prefix_bytes=b"",
):
if isinstance(true_value, int):
assert isinstance(false_value, int)
if mask is None:
mask = self.default_mask
else:
assert isinstance(mask, int)
assert true_value & false_value == 0
assert true_value & mask == true_value
assert false_value & mask == false_value
self.needs_current_value = mask != self.default_mask
elif isinstance(true_value, bytes):
if false_value is None or false_value == self.default_false:
false_value = b"\x00" * len(true_value)
else:
assert isinstance(false_value, bytes)
if mask is None or mask == self.default_mask:
mask = b"\xff" * len(true_value)
else:
assert isinstance(mask, bytes)
assert len(mask) == len(true_value) == len(false_value)
tv = common.bytes2int(true_value)
fv = common.bytes2int(false_value)
mv = common.bytes2int(mask)
assert tv != fv # true and false might be something other than bit values
assert tv & mv == tv
assert fv & mv == fv
self.needs_current_value = any(m != 0xFF for m in mask)
else:
raise Exception(f"invalid mask '{mask!r}', type {type(mask)}")
self.true_value = true_value
self.false_value = false_value
self.mask = mask
self.read_skip_byte_count = read_skip_byte_count
self.write_prefix_bytes = write_prefix_bytes
def validate_read(self, reply_bytes):
reply_bytes = reply_bytes[self.read_skip_byte_count :]
if isinstance(self.mask, int):
reply_value = ord(reply_bytes[:1]) & self.mask
if logger.isEnabledFor(logging.DEBUG):
logger.debug("BooleanValidator: validate read %r => %02X", reply_bytes, reply_value)
if reply_value == self.true_value:
return True
if reply_value == self.false_value:
return False
logger.warning(
"BooleanValidator: reply %02X mismatched %02X/%02X/%02X",
reply_value,
self.true_value,
self.false_value,
self.mask,
)
return False
count = len(self.mask)
mask = common.bytes2int(self.mask)
reply_value = common.bytes2int(reply_bytes[:count]) & mask
true_value = common.bytes2int(self.true_value)
if reply_value == true_value:
return True
false_value = common.bytes2int(self.false_value)
if reply_value == false_value:
return False
logger.warning(
"BooleanValidator: reply %r mismatched %r/%r/%r", reply_bytes, self.true_value, self.false_value, self.mask
)
return False
def prepare_write(self, new_value, current_value=None):
if new_value is None:
new_value = False
else:
assert isinstance(new_value, bool), f"New value {new_value} for boolean setting is not a boolean"
to_write = self.true_value if new_value else self.false_value
if isinstance(self.mask, int):
if current_value is not None and self.needs_current_value:
to_write |= ord(current_value[:1]) & (0xFF ^ self.mask)
if current_value is not None and to_write == ord(current_value[:1]):
return None
to_write = bytes([to_write])
else:
to_write = bytearray(to_write)
count = len(self.mask)
for i in range(0, count):
b = ord(to_write[i : i + 1])
m = ord(self.mask[i : i + 1])
assert b & m == b
# b &= m
if current_value is not None and self.needs_current_value:
b |= ord(current_value[i : i + 1]) & (0xFF ^ m)
to_write[i] = b
to_write = bytes(to_write)
if current_value is not None and to_write == current_value[: len(to_write)]:
return None
if logger.isEnabledFor(logging.DEBUG):
logger.debug("BooleanValidator: prepare_write(%s, %s) => %r", new_value, current_value, to_write)
return self.write_prefix_bytes + to_write
def acceptable(self, args, current):
if len(args) != 1:
return None
val = bool_or_toggle(current, args[0])
return [val] if val is not None else None
class BitFieldValidator(Validator):
__slots__ = ("byte_count", "options")
kind = KIND.multiple_toggle
def __init__(self, options, byte_count=None):
assert isinstance(options, list)
self.options = options
self.byte_count = (max(x.bit_length() for x in options) + 7) // 8
if byte_count:
assert isinstance(byte_count, int) and byte_count >= self.byte_count
self.byte_count = byte_count
def to_string(self, value):
def element_to_string(key, val):
k = next((k for k in self.options if int(key) == k), None)
return str(k) + ":" + str(val) if k is not None else "?"
return "{" + ", ".join([element_to_string(k, value[k]) for k in value]) + "}"
def validate_read(self, reply_bytes):
r = common.bytes2int(reply_bytes[: self.byte_count])
value = {int(k): False for k in self.options}
m = 1
for _ignore in range(8 * self.byte_count):
if m in self.options:
value[int(m)] = bool(r & m)
m <<= 1
return value
def prepare_write(self, new_value):
assert isinstance(new_value, dict)
w = 0
for k, v in new_value.items():
if v:
w |= int(k)
return common.int2bytes(w, self.byte_count)
def get_options(self):
return self.options
def acceptable(self, args, current):
if len(args) != 2:
return None
key = next((key for key in self.options if key == args[0]), None)
if key is None:
return None
val = bool_or_toggle(current[int(key)], args[1])
return None if val is None else [int(key), val]
def compare(self, args, current):
if len(args) != 2:
return False
key = next((key for key in self.options if key == args[0]), None)
if key is None:
return False
return args[1] == current[int(key)]
class BitFieldWithOffsetAndMaskValidator(Validator):
__slots__ = ("byte_count", "options", "_option_from_key", "_mask_from_offset", "_option_from_offset_mask")
kind = KIND.multiple_toggle
sep = 0x01
def __init__(self, options, om_method=None, byte_count=None):
assert isinstance(options, list)
# each element of options is an instance of a class
# that has an id (which is used as an index in other dictionaries)
# and where om_method is a method that returns a byte offset and byte mask
# that says how to access and modify the bit toggle for the option
self.options = options
self.om_method = om_method
# to retrieve the options efficiently:
self._option_from_key = {}
self._mask_from_offset = {}
self._option_from_offset_mask = {}
for opt in options:
offset, mask = om_method(opt)
self._option_from_key[int(opt)] = opt
try:
self._mask_from_offset[offset] |= mask
except KeyError:
self._mask_from_offset[offset] = mask
try:
mask_to_opt = self._option_from_offset_mask[offset]
except KeyError:
mask_to_opt = {}
self._option_from_offset_mask[offset] = mask_to_opt
mask_to_opt[mask] = opt
self.byte_count = (max(om_method(x)[1].bit_length() for x in options) + 7) // 8 # is this correct??
if byte_count:
assert isinstance(byte_count, int) and byte_count >= self.byte_count
self.byte_count = byte_count
def prepare_read(self):
r = []
for offset, mask in self._mask_from_offset.items():
b = offset << (8 * (self.byte_count + 1))
b |= (self.sep << (8 * self.byte_count)) | mask
r.append(common.int2bytes(b, self.byte_count + 2))
return r
def prepare_read_key(self, key):
option = self._option_from_key.get(key, None)
if option is None:
return None
offset, mask = option.om_method(option)
b = offset << (8 * (self.byte_count + 1))
b |= (self.sep << (8 * self.byte_count)) | mask
return common.int2bytes(b, self.byte_count + 2)
def validate_read(self, reply_bytes_dict):
values = {int(k): False for k in self.options}
for query, b in reply_bytes_dict.items():
offset = common.bytes2int(query[0:1])
b += (self.byte_count - len(b)) * b"\x00"
value = common.bytes2int(b[: self.byte_count])
mask_to_opt = self._option_from_offset_mask.get(offset, {})
m = 1
for _ignore in range(8 * self.byte_count):
if m in mask_to_opt:
values[int(mask_to_opt[m])] = bool(value & m)
m <<= 1
return values
def prepare_write(self, new_value):
assert isinstance(new_value, dict)
w = {}
for k, v in new_value.items():
option = self._option_from_key[int(k)]
offset, mask = self.om_method(option)
if offset not in w:
w[offset] = 0
if v:
w[offset] |= mask
return [
common.int2bytes(
(offset << (8 * (2 * self.byte_count + 1)))
| (self.sep << (16 * self.byte_count))
| (self._mask_from_offset[offset] << (8 * self.byte_count))
| value,
2 * self.byte_count + 2,
)
for offset, value in w.items()
]
def get_options(self):
return [int(opt) if isinstance(opt, int) else opt.as_int() for opt in self.options]
def acceptable(self, args, current):
if len(args) != 2:
return None
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
if key is None:
return None
val = bool_or_toggle(current[int(key)], args[1])
return None if val is None else [int(key), val]
def compare(self, args, current):
if len(args) != 2:
return False
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
if key is None:
return False
return args[1] == current[int(key)]
class ChoicesValidator(Validator):
"""Translates between NamedInts and a byte sequence.
:param choices: a list of NamedInts
:param byte_count: the size of the derived byte sequence. If None, it
will be calculated from the choices."""
kind = KIND.choice
def __init__(self, choices=None, byte_count=None, read_skip_byte_count=0, write_prefix_bytes=b""):
assert choices is not None
assert isinstance(choices, NamedInts)
assert len(choices) > 1
self.choices = choices
self.needs_current_value = False
max_bits = max(x.bit_length() for x in choices)
self._byte_count = (max_bits // 8) + (1 if max_bits % 8 else 0)
if byte_count:
assert self._byte_count <= byte_count
self._byte_count = byte_count
assert self._byte_count < 8
self._read_skip_byte_count = read_skip_byte_count
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
assert self._byte_count + self._read_skip_byte_count <= 14
assert self._byte_count + len(self._write_prefix_bytes) <= 14
def to_string(self, value):
return str(self.choices[value]) if isinstance(value, int) else str(value)
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])
valid_value = self.choices[reply_value]
assert valid_value is not None, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
return valid_value
def prepare_write(self, new_value, current_value=None):
if new_value is None:
value = self.choices[:][0]
else:
value = self.choice(new_value)
if value is None:
raise ValueError(f"invalid choice {new_value!r}")
assert isinstance(value, NamedInt)
return self._write_prefix_bytes + value.bytes(self._byte_count)
def choice(self, value):
if isinstance(value, int):
return self.choices[value]
try:
int(value)
if int(value) in self.choices:
return self.choices[int(value)]
except Exception:
pass
if value in self.choices:
return self.choices[value]
else:
return None
def acceptable(self, args, current):
choice = self.choice(args[0]) if len(args) == 1 else None
return None if choice is None else [choice]
class ChoicesMapValidator(ChoicesValidator):
kind = KIND.map_choice
def __init__(
self,
choices_map,
key_byte_count=0,
key_postfix_bytes=b"",
byte_count=0,
read_skip_byte_count=0,
write_prefix_bytes=b"",
extra_default=None,
mask=-1,
activate=0,
):
assert choices_map is not None
assert isinstance(choices_map, dict)
max_key_bits = 0
max_value_bits = 0
for key, choices in choices_map.items():
assert isinstance(key, NamedInt)
assert isinstance(choices, NamedInts)
max_key_bits = max(max_key_bits, key.bit_length())
for key_value in choices:
assert isinstance(key_value, NamedInt)
max_value_bits = max(max_value_bits, key_value.bit_length())
self._key_byte_count = (max_key_bits + 7) // 8
if key_byte_count:
assert self._key_byte_count <= key_byte_count
self._key_byte_count = key_byte_count
self._byte_count = (max_value_bits + 7) // 8
if byte_count:
assert self._byte_count <= byte_count
self._byte_count = byte_count
self.choices = choices_map
self.needs_current_value = False
self.extra_default = extra_default
self._key_postfix_bytes = key_postfix_bytes
self._read_skip_byte_count = read_skip_byte_count if read_skip_byte_count else 0
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
self.activate = activate
self.mask = mask
assert self._byte_count + self._read_skip_byte_count + self._key_byte_count <= 14
assert self._byte_count + len(self._write_prefix_bytes) + self._key_byte_count <= 14
def to_string(self, value):
def element_to_string(key, val):
k, c = next(((k, c) for k, c in self.choices.items() if int(key) == k), (None, None))
return str(k) + ":" + str(c[val]) if k is not None else "?"
return "{" + ", ".join([element_to_string(k, value[k]) for k in sorted(value)]) + "}"
def validate_read(self, reply_bytes, key):
start = self._key_byte_count + self._read_skip_byte_count
end = start + self._byte_count
reply_value = common.bytes2int(reply_bytes[start:end]) & self.mask
# reprogrammable keys starts out as 0, which is not a choice, so don't use assert here
if self.extra_default is not None and self.extra_default == reply_value:
return int(self.choices[key][0])
if reply_value not in self.choices[key]:
assert reply_value in self.choices[key], "%s: failed to validate read value %02X" % (
self.__class__.__name__,
reply_value,
)
return reply_value
def prepare_key(self, key):
return key.to_bytes(self._key_byte_count, "big") + self._key_postfix_bytes
def prepare_write(self, key, new_value):
choices = self.choices.get(key)
if choices is None or (new_value not in choices and new_value != self.extra_default):
logger.error("invalid choice %r for %s", new_value, key)
return None
new_value = new_value | self.activate
return self._write_prefix_bytes + new_value.to_bytes(self._byte_count, "big")
def acceptable(self, args, current):
if len(args) != 2:
return None
key, choices = next(((key, item) for key, item in self.choices.items() if key == args[0]), (None, None))
if choices is None or args[1] not in choices:
return None
choice = next((item for item in choices if item == args[1]), None)
return [int(key), int(choice)] if choice is not None else None
def compare(self, args, current):
if len(args) != 2:
return False
key = next((key for key in self.choices if key == int(args[0])), None)
if key is None:
return False
return args[1] == current[int(key)]
class RangeValidator(Validator):
kind = KIND.range
"""Translates between integers and a byte sequence.
:param min_value: minimum accepted value (inclusive)
:param max_value: maximum accepted value (inclusive)
:param byte_count: the size of the derived byte sequence. If None, it
will be calculated from the range."""
min_value = 0
max_value = 255
@classmethod
def build(cls, setting_class, device, **kwargs):
kwargs["min_value"] = setting_class.min_value
kwargs["max_value"] = setting_class.max_value
return cls(**kwargs)
def __init__(self, min_value=0, max_value=255, byte_count=1):
assert max_value > min_value
self.min_value = min_value
self.max_value = max_value
self.needs_current_value = True # read and check before write (needed for ADC power and probably a good idea anyway)
self._byte_count = math.ceil(math.log(max_value + 1, 256))
if byte_count:
assert self._byte_count <= byte_count
self._byte_count = byte_count
assert self._byte_count < 8
def validate_read(self, reply_bytes):
reply_value = common.bytes2int(reply_bytes[: self._byte_count])
assert reply_value >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
assert reply_value <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
return reply_value
def prepare_write(self, new_value, current_value=None):
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 = common.int2bytes(new_value, self._byte_count)
# current value is known and same as value to be written return None to signal not to write it
return None if current_value is not None and current_value == new_value else to_write
def acceptable(self, args, current):
arg = args[0]
# None if len(args) != 1 or type(arg) != int or arg < self.min_value or arg > self.max_value else args)
return None if len(args) != 1 or isinstance(arg, int) or arg < self.min_value or arg > self.max_value else args
def compare(self, args, current):
if len(args) == 1:
return args[0] == current
elif len(args) == 2:
return args[0] <= current <= args[1]
else:
return False
class HeteroValidator(Validator):
kind = KIND.hetero
@classmethod
def build(cls, setting_class, device, **kwargs):
return cls(**kwargs)
def __init__(self, data_class=None, options=None, readable=True):
assert data_class is not None and options is not None
self.data_class = data_class
self.options = options
self.readable = readable
self.needs_current_value = False
def validate_read(self, reply_bytes):
if self.readable:
reply_value = self.data_class.from_bytes(reply_bytes, options=self.options)
return reply_value
def prepare_write(self, new_value, current_value=None):
to_write = new_value.to_bytes(options=self.options)
return to_write
def acceptable(self, args, current): # should this actually do some checking?
return True
class PackedRangeValidator(Validator):
kind = KIND.packed_range
"""Several range values, all the same size, all the same min and max"""
min_value = 0
max_value = 255
count = 1
rsbc = 0
write_prefix_bytes = b""
def __init__(
self, keys, min_value=0, max_value=255, count=1, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b""
):
assert max_value > min_value
self.needs_current_value = True
self.keys = keys
self.min_value = min_value
self.max_value = max_value
self.count = count
self.bc = math.ceil(math.log(max_value + 1 - min(0, min_value), 256))
if byte_count:
assert self.bc <= byte_count
self.bc = byte_count
assert self.bc * self.count
self.rsbc = read_skip_byte_count
self.write_prefix_bytes = write_prefix_bytes
def validate_read(self, reply_bytes):
rvs = {
n: common.bytes2int(reply_bytes[self.rsbc + n * self.bc : self.rsbc + (n + 1) * self.bc], signed=True)
for n in range(self.count)
}
for n in range(self.count):
assert rvs[n] >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
assert rvs[n] <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
return rvs
def prepare_write(self, new_values):
if len(new_values) != self.count:
raise ValueError(f"wrong number of values {new_values!r}")
for new_value in new_values.values():
if new_value < self.min_value or new_value > self.max_value:
raise ValueError(f"invalid value {new_value!r}")
bytes = self.write_prefix_bytes + b"".join(
common.int2bytes(new_values[n], self.bc, signed=True) for n in range(self.count)
)
return bytes
def acceptable(self, args, current):
if len(args) != 2 or int(args[0]) < 0 or int(args[0]) >= self.count:
return None
return None if not isinstance(args[1], int) or args[1] < self.min_value or args[1] > self.max_value else args
def compare(self, args, current):
logger.warning("compare not implemented for packed range settings")
return False
class MultipleRangeValidator(Validator):
kind = KIND.multiple_range
def __init__(self, items, sub_items):
assert isinstance(items, list) # each element must have .index and its __int__ must return its id (not its index)
assert isinstance(sub_items, dict)
# sub_items: items -> class with .minimum, .maximum, .length (in bytes), .id (a string) and .widget (e.g. 'Scale')
self.items = items
self.keys = NamedInts(**{str(item): int(item) for item in items})
self._item_from_id = {int(k): k for k in items}
self.sub_items = sub_items
def prepare_read_item(self, item):
return common.int2bytes((self._item_from_id[int(item)].index << 1) | 0xFF, 2)
def validate_read_item(self, reply_bytes, item):
item = self._item_from_id[int(item)]
start = 0
value = {}
for sub_item in self.sub_items[item]:
r = reply_bytes[start : start + sub_item.length]
if len(r) < sub_item.length:
r += b"\x00" * (sub_item.length - len(value))
v = common.bytes2int(r)
if not (sub_item.minimum < v < sub_item.maximum):
logger.warning(
f"{self.__class__.__name__}: failed to validate read value for {item}.{sub_item}: "
+ f"{v} not in [{sub_item.minimum}..{sub_item.maximum}]"
)
value[str(sub_item)] = v
start += sub_item.length
return value
def prepare_write(self, value):
seq = []
w = b""
for item in value.keys():
_item = self._item_from_id[int(item)]
b = common.int2bytes(_item.index, 1)
for sub_item in self.sub_items[_item]:
try:
v = value[int(item)][str(sub_item)]
except KeyError:
return None
if not (sub_item.minimum <= v <= sub_item.maximum):
raise ValueError(
f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]"
)
b += common.int2bytes(v, sub_item.length)
if len(w) + len(b) > 15:
seq.append(b + b"\xff")
w = b""
w += b
seq.append(w + b"\xff")
return seq
def prepare_write_item(self, item, value):
_item = self._item_from_id[int(item)]
w = common.int2bytes(_item.index, 1)
for sub_item in self.sub_items[_item]:
try:
v = value[str(sub_item)]
except KeyError:
return None
if not (sub_item.minimum <= v <= sub_item.maximum):
raise ValueError(f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]")
w += common.int2bytes(v, sub_item.length)
return w + b"\xff"
def acceptable(self, args, current):
# just one item, with at least one sub-item
if not isinstance(args, list) or len(args) != 2 or not isinstance(args[1], dict):
return None
item = next((p for p in self.items if p.id == args[0] or str(p) == args[0]), None)
if not item:
return None
for sub_key, value in args[1].items():
sub_item = next((it for it in self.sub_items[item] if it.id == sub_key), None)
if not sub_item:
return None
if not isinstance(value, int) or not (sub_item.minimum <= value <= sub_item.maximum):
return None
return [int(item), {**args[1]}]
def compare(self, args, current):
logger.warning("compare not implemented for multiple range settings")
return False
class ActionSettingRW:
"""Special RW class for settings that turn on and off special processing when a key or button is depressed"""
@@ -1578,4 +866,4 @@ def apply_all_settings(device):
s.apply()
Setting.validator_class = BooleanValidator
Setting.validator_class = settings_validator.BooleanValidator

View File

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

View File

@@ -22,8 +22,8 @@ import struct
import traceback
from time import time
from typing import Any
from typing import Callable
from typing import Protocol
from solaar.i18n import _
@@ -32,19 +32,22 @@ from . import common
from . import descriptors
from . import desktop_notifications
from . import diversion
from . import hidpp10_constants
from . import exceptions
from . import hidpp20
from . import hidpp20_constants
from . import settings
from . import settings_new
from . import settings_validator
from . import special_keys
from .hidpp10_constants import Registers
from .hidpp20 import KeyFlag
from .hidpp20 import MappingFlag
from .hidpp20_constants import GestureId
from .hidpp20_constants import ParamId
logger = logging.getLogger(__name__)
_hidpp20 = hidpp20.Hidpp20()
_DK = hidpp10_constants.DEVICE_KIND
_F = hidpp20_constants.SupportedFeature
@@ -97,8 +100,11 @@ class State(enum.Enum):
# mask is used to keep only some bits from a sequence of bits, this can be an integer or a byte string,
# read_skip_byte_count is the number of bytes to ignore at the beginning of the read value (default 0),
# write_prefix_bytes is a byte string to write before the value (default empty).
# RangeValidator is for an integer in a range. It takes
# byte_count is number of bytes that the value is stored in (defaults to size of max_value).
# read_skip_byte_count is as for BooleanV
# write_prefix_bytes is as for BooleanV
# RangeValidator uses min_value and max_value from the setting class as minimum and maximum.
# ChoicesValidator is for symbolic choices. It takes one positional and three keyword arguments:
@@ -177,7 +183,7 @@ class RegisterDpi(settings.Setting):
description = _("Mouse movement sensitivity")
register = Registers.MOUSE_DPI
choices_universe = common.NamedInts.range(0x81, 0x8F, lambda x: str((x - 0x80) * 100))
validator_class = settings.ChoicesValidator
validator_class = settings_validator.ChoicesValidator
validator_options = {"choices": choices_universe}
@@ -251,7 +257,7 @@ class Backlight(settings.Setting):
description = _("Set illumination time for keyboard.")
feature = _F.BACKLIGHT
choices_universe = common.NamedInts(Off=0, Varying=2, VeryShort=5, Short=10, Medium=20, Long=60, VeryLong=180)
validator_class = settings.ChoicesValidator
validator_class = settings_validator.ChoicesValidator
validator_options = {"choices": choices_universe}
@@ -285,7 +291,7 @@ class Backlight2(settings.Setting):
backlight.write()
return True
class validator_class(settings.ChoicesValidator):
class validator_class(settings_validator.ChoicesValidator):
@classmethod
def build(cls, setting_class, device):
backlight = device.backlight
@@ -322,7 +328,7 @@ class Backlight2Level(settings.Setting):
device.backlight.write()
return True
class validator_class(settings.RangeValidator):
class validator_class(settings_validator.RangeValidator):
@classmethod
def build(cls, setting_class, device):
reply = device.feature_request(_F.BACKLIGHT2, 0x20)
@@ -334,7 +340,7 @@ class Backlight2Level(settings.Setting):
class Backlight2Duration(settings.Setting):
feature = _F.BACKLIGHT2
min_version = 3
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
min_value = 1
max_value = 600 # 10 minutes - actual maximum is 2 hours
validator_options = {"byte_count": 2}
@@ -363,7 +369,7 @@ class Backlight2DurationHandsOut(Backlight2Duration):
label = _("Backlight Delay Hands Out")
description = _("Delay in seconds until backlight fades out with hands away from keyboard.")
feature = _F.BACKLIGHT2
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
rw_options = {"field": "dho"}
@@ -372,7 +378,7 @@ class Backlight2DurationHandsIn(Backlight2Duration):
label = _("Backlight Delay Hands In")
description = _("Delay in seconds until backlight fades out with hands near keyboard.")
feature = _F.BACKLIGHT2
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
rw_options = {"field": "dhi"}
@@ -381,7 +387,7 @@ class Backlight2DurationPowered(Backlight2Duration):
label = _("Backlight Delay Powered")
description = _("Delay in seconds until backlight fades out with external power.")
feature = _F.BACKLIGHT2
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
rw_options = {"field": "dpow"}
@@ -391,7 +397,7 @@ class Backlight3(settings.Setting):
description = _("Set illumination time for keyboard.")
feature = _F.BACKLIGHT3
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20, "suffix": b"\x09"}
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
min_value = 0
max_value = 1000
validator_options = {"byte_count": 2}
@@ -455,7 +461,7 @@ class PointerSpeed(settings.Setting):
label = _("Sensitivity (Pointer Speed)")
description = _("Speed multiplier for mouse (256 is normal multiplier).")
feature = _F.POINTER_SPEED
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
min_value = 0x002E
max_value = 0x01FF
validator_options = {"byte_count": 2}
@@ -502,7 +508,7 @@ class OnboardProfiles(settings.Setting):
for i in range(1, 16):
choices_universe[i] = f"Profile {i}"
choices_universe[i + 0x100] = f"Read-Only Profile {i}"
validator_class = settings.ChoicesValidator
validator_class = settings_validator.ChoicesValidator
class rw_class:
def __init__(self, feature):
@@ -526,7 +532,7 @@ class OnboardProfiles(settings.Setting):
profile_change(device, common.bytes2int(data_bytes))
return result
class validator_class(settings.ChoicesValidator):
class validator_class(settings_validator.ChoicesValidator):
@classmethod
def build(cls, setting_class, device):
headers = hidpp20.OnboardProfiles.get_profile_headers(device)
@@ -556,7 +562,7 @@ class ReportRate(settings.Setting):
choices_universe[7] = "7ms"
choices_universe[8] = "8ms"
class validator_class(settings.ChoicesValidator):
class validator_class(settings_validator.ChoicesValidator):
@classmethod
def build(cls, setting_class, device):
# if device.wpid == '408E':
@@ -588,7 +594,7 @@ class ExtendedReportRate(settings.Setting):
choices_universe[5] = "250us"
choices_universe[6] = "125us"
class validator_class(settings.ChoicesValidator):
class validator_class(settings_validator.ChoicesValidator):
@classmethod
def build(cls, setting_class, device):
reply = device.feature_request(_F.EXTENDED_ADJUSTABLE_REPORT_RATE, 0x10)
@@ -640,7 +646,7 @@ class ScrollRatchet(settings.Setting):
description = _("Switch the mouse wheel between speed-controlled ratcheting and always freespin.")
feature = _F.SMART_SHIFT
choices_universe = common.NamedInts(**{_("Freespinning"): 1, _("Ratcheted"): 2})
validator_class = settings.ChoicesValidator
validator_class = settings_validator.ChoicesValidator
validator_options = {"choices": choices_universe}
@@ -683,7 +689,7 @@ class SmartShift(settings.Setting):
min_value = rw_class.MIN_VALUE
max_value = rw_class.MAX_VALUE
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
class SmartShiftEnhanced(SmartShift):
@@ -696,6 +702,38 @@ class ScrollRatchetEnhanced(ScrollRatchet):
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
class ScrollRatchetTorque(settings.Setting):
name = "scroll-ratchet-torque"
label = _("Scroll Wheel Ratchet Torque")
description = _("Change the torque needed to overcome the ratchet.")
feature = _F.SMART_SHIFT_ENHANCED
min_value = 1
max_value = 100
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
class rw_class(settings.FeatureRW):
def write(self, device, data_bytes):
ratchetSetting = next(filter(lambda s: s.name == "scroll-ratchet", device.settings), None)
if ratchetSetting: # for MX Master 4, the ratchet setting needs to be written for changes to take effect
ratchet_value = ratchetSetting.read(True)
data_bytes = ratchet_value.to_bytes(1, "big") + data_bytes[1:]
result = super().write(device, data_bytes)
return result
class validator_class(settings_validator.RangeValidator):
@classmethod
def build(cls, setting_class, device):
reply = device.feature_request(_F.SMART_SHIFT_ENHANCED, 0x00)
if reply[0] & 0x01: # device supports tunable torque
return cls(
min_value=setting_class.min_value,
max_value=setting_class.max_value,
byte_count=1,
write_prefix_bytes=b"\x00\x00", # don't change mode or disengage, but see above
read_skip_byte_count=2,
)
# the keys for the choice map are Logitech controls (from special_keys)
# each choice value is a NamedInt with the string from a task (to be shown to the user)
# and the integer being the control number for that task (to be written to the device)
@@ -730,7 +768,7 @@ class ReprogrammableKeys(settings.Settings):
key_struct.remap(special_keys.CONTROL[common.bytes2int(data_bytes)])
return True
class validator_class(settings.ChoicesMapValidator):
class validator_class(settings_validator.ChoicesMapValidator):
@classmethod
def build(cls, setting_class, device):
choices = {}
@@ -899,7 +937,7 @@ class DivertKeys(settings.Settings):
def read(self, device, key):
key_index = device.keys.index(key)
key_struct = device.keys[key_index]
return b"\x00\x00\x01" if "diverted" in key_struct.mapping_flags else b"\x00\x00\x00"
return b"\x00\x00\x01" if MappingFlag.DIVERTED in key_struct.mapping_flags else b"\x00\x00\x00"
def write(self, device, key, data_bytes):
key_index = device.keys.index(key)
@@ -907,7 +945,7 @@ class DivertKeys(settings.Settings):
key_struct.set_diverted(common.bytes2int(data_bytes) != 0) # not regular
return True
class validator_class(settings.ChoicesMapValidator):
class validator_class(settings_validator.ChoicesMapValidator):
def __init__(self, choices, key_byte_count=2, byte_count=1, mask=0x01):
super().__init__(choices, key_byte_count, byte_count, mask)
@@ -927,20 +965,20 @@ class DivertKeys(settings.Settings):
sliding = gestures = None
choices = {}
if device.keys:
for k in device.keys:
if "divertable" in k.flags and "virtual" not in k.flags:
if "raw XY" in k.flags:
choices[k.key] = setting_class.choices_gesture
for key in device.keys:
if KeyFlag.DIVERTABLE in key.flags and KeyFlag.VIRTUAL not in key.flags:
if KeyFlag.RAW_XY in key.flags:
choices[key.key] = setting_class.choices_gesture
if gestures is None:
gestures = MouseGesturesXY(device, name="MouseGestures")
if _F.ADJUSTABLE_DPI in device.features:
choices[k.key] = setting_class.choices_universe
choices[key.key] = setting_class.choices_universe
if sliding is None:
sliding = DpiSlidingXY(
device, name="DpiSliding", show_notification=desktop_notifications.show
)
else:
choices[k.key] = setting_class.choices_divert
choices[key.key] = setting_class.choices_divert
if not choices:
return None
validator = cls(choices, key_byte_count=2, byte_count=1, mask=0x01)
@@ -978,12 +1016,12 @@ def produce_dpi_list(feature, function, ignore, device, direction):
class AdjustableDpi(settings.Setting):
name = "dpi"
label = _("Sensitivity (DPI)")
description = _("Mouse movement sensitivity")
description = _("Mouse movement sensitivity") + "\n" + _("May need Onboard Profiles set to Disable to be effective.")
feature = _F.ADJUSTABLE_DPI
rw_options = {"read_fnid": 0x20, "write_fnid": 0x30}
choices_universe = common.NamedInts.range(100, 4000, str, 50)
class validator_class(settings.ChoicesValidator):
class validator_class(settings_validator.ChoicesValidator):
@classmethod
def build(cls, setting_class, device):
dpilist = produce_dpi_list(setting_class.feature, 0x10, 1, device, 0)
@@ -1011,9 +1049,9 @@ class ExtendedAdjustableDpi(settings.Setting):
rw_options = {"read_fnid": 0x50, "write_fnid": 0x60}
keys_universe = common.NamedInts(X=0, Y=1, LOD=2)
choices_universe = common.NamedInts.range(100, 4000, str, 50)
choices_universe[0] = "LOW"
choices_universe[1] = "MEDIUM"
choices_universe[2] = "HIGH"
choices_universe[1] = "LOW"
choices_universe[2] = "MEDIUM"
choices_universe[3] = "HIGH"
keys = common.NamedInts(X=0, Y=1, LOD=2)
def write_key_value(self, key, value, save=True):
@@ -1024,7 +1062,7 @@ class ExtendedAdjustableDpi(settings.Setting):
result = self.write(self._value, save)
return result[key] if isinstance(result, dict) else result
class validator_class(settings.ChoicesMapValidator):
class validator_class(settings_validator.ChoicesMapValidator):
@classmethod
def build(cls, setting_class, device):
reply = device.feature_request(setting_class.feature, 0x10, 0x00)
@@ -1107,12 +1145,12 @@ class SpeedChange(settings.Setting):
if self.device.persister:
self.device.persister["_speed-change"] = currentSpeed
class validator_class(settings.ChoicesValidator):
class validator_class(settings_validator.ChoicesValidator):
@classmethod
def build(cls, setting_class, device):
key_index = device.keys.index(special_keys.CONTROL.DPI_Change)
key = device.keys[key_index] if key_index is not None else None
if key is not None and "divertable" in key.flags:
if key is not None and KeyFlag.DIVERTABLE in key.flags:
keys = [setting_class.choices_extra, key.key]
return cls(choices=common.NamedInts.list(keys), byte_count=2)
@@ -1126,7 +1164,7 @@ class DisableKeyboardKeys(settings.BitFieldSetting):
_labels = {k: (None, _("Disables the %s key.") % k) for k in special_keys.DISABLE}
choices_universe = special_keys.DISABLE
class validator_class(settings.BitFieldValidator):
class validator_class(settings_validator.BitFieldValidator):
@classmethod
def build(cls, setting_class, device):
mask = device.feature_request(_F.KEYBOARD_DISABLE_KEYS, 0x00)[0]
@@ -1158,7 +1196,7 @@ class Multiplatform(settings.Setting):
# the problem here is how to construct the right values for the rules Set GUI,
# as, for example, the integer value for 'Windows' can be different on different devices
class validator_class(settings.ChoicesValidator):
class validator_class(settings_validator.ChoicesValidator):
@classmethod
def build(cls, setting_class, device):
def _str_os_versions(low, high):
@@ -1166,11 +1204,11 @@ class Multiplatform(settings.Setting):
if version == 0:
return ""
elif version & 0xFF:
return str(version >> 8) + "." + str(version & 0xFF)
return f"{str(version >> 8)}.{str(version & 0xFF)}"
else:
return str(version >> 8)
return "" if low == 0 and high == 0 else " " + _str_os_version(low) + "-" + _str_os_version(high)
return "" if low == 0 and high == 0 else f" {_str_os_version(low)}-{_str_os_version(high)}"
infos = device.feature_request(_F.MULTIPLATFORM)
assert infos, "Oops, multiplatform count cannot be retrieved!"
@@ -1200,7 +1238,7 @@ class DualPlatform(settings.Setting):
choices_universe[0x01] = "Android, Windows"
feature = _F.DUALPLATFORM
rw_options = {"read_fnid": 0x00, "write_fnid": 0x20}
validator_class = settings.ChoicesValidator
validator_class = settings_validator.ChoicesValidator
validator_options = {"choices": choices_universe}
@@ -1213,7 +1251,7 @@ class ChangeHost(settings.Setting):
rw_options = {"read_fnid": 0x00, "write_fnid": 0x10, "no_reply": True}
choices_universe = common.NamedInts(**{"Host " + str(i + 1): i for i in range(3)})
class validator_class(settings.ChoicesValidator):
class validator_class(settings_validator.ChoicesValidator):
@classmethod
def build(cls, setting_class, device):
infos = device.feature_request(_F.CHANGE_HOST)
@@ -1226,7 +1264,7 @@ class ChangeHost(settings.Setting):
choices = common.NamedInts()
for host in range(0, numHosts):
paired, hostName = hostNames.get(host, (True, ""))
choices[host] = str(host + 1) + ":" + hostName if hostName else str(host + 1)
choices[host] = f"{str(host + 1)}:{hostName}" if hostName else str(host + 1)
return cls(choices=choices, read_skip_byte_count=1) if choices and len(choices) > 1 else None
@@ -1325,7 +1363,7 @@ class Gesture2Gestures(settings.BitFieldWithOffsetAndMaskSetting):
choices_universe = hidpp20_constants.GestureId
_labels = _GESTURE2_GESTURES_LABELS
class validator_class(settings.BitFieldWithOffsetAndMaskValidator):
class validator_class(settings_validator.BitFieldWithOffsetAndMaskValidator):
@classmethod
def build(cls, setting_class, device, om_method=None):
options = [g for g in device.gestures.gestures.values() if g.can_be_enabled or g.default_enabled]
@@ -1342,7 +1380,7 @@ class Gesture2Divert(settings.BitFieldWithOffsetAndMaskSetting):
choices_universe = hidpp20_constants.GestureId
_labels = _GESTURE2_GESTURES_LABELS
class validator_class(settings.BitFieldWithOffsetAndMaskValidator):
class validator_class(settings_validator.BitFieldWithOffsetAndMaskValidator):
@classmethod
def build(cls, setting_class, device, om_method=None):
options = [g for g in device.gestures.gestures.values() if g.can_be_diverted]
@@ -1363,7 +1401,7 @@ class Gesture2Params(settings.LongSettings):
_labels = _GESTURE2_PARAMS_LABELS
_labels_sub = _GESTURE2_PARAMS_LABELS_SUB
class validator_class(settings.MultipleRangeValidator):
class validator_class(settings_validator.MultipleRangeValidator):
@classmethod
def build(cls, setting_class, device):
params = _hidpp20.get_gestures(device).params.values()
@@ -1397,7 +1435,7 @@ class MKeyLEDs(settings.BitFieldSetting):
def read(self, device): # no way to read, so just assume off
return b"\x00"
class validator_class(settings.BitFieldValidator):
class validator_class(settings_validator.BitFieldValidator):
@classmethod
def build(cls, setting_class, device):
number = device.feature_request(setting_class.feature, 0x00)[0]
@@ -1455,7 +1493,7 @@ class PersistentRemappableAction(settings.Settings):
v = ks.remap(data_bytes)
return v
class validator_class(settings.ChoicesMapValidator):
class validator_class(settings_validator.ChoicesMapValidator):
@classmethod
def build(cls, setting_class, device):
remap_keys = device.remap_keys
@@ -1494,7 +1532,7 @@ class Sidetone(settings.Setting):
label = _("Sidetone")
description = _("Set sidetone level.")
feature = _F.SIDETONE
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
min_value = 0
max_value = 100
@@ -1507,7 +1545,7 @@ class Equalizer(settings.RangeFieldSetting):
rw_options = {"read_fnid": 0x20, "write_fnid": 0x30, "read_prefix": b"\x00"}
keys_universe = []
class validator_class(settings.PackedRangeValidator):
class validator_class(settings_validator.PackedRangeValidator):
@classmethod
def build(cls, setting_class, device):
data = device.feature_request(_F.EQUALIZER, 0x00)
@@ -1533,8 +1571,9 @@ class ADCPower(settings.Setting):
label = _("Power Management")
description = _("Power off in minutes (0 for never).")
feature = _F.ADC_MEASUREMENT
min_version = 2 # documentation for version 1 does not mention this capability
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
min_value = 0x00
max_value = 0xFF
validator_options = {"byte_count": 1}
@@ -1546,7 +1585,7 @@ class BrightnessControl(settings.Setting):
description = _("Control overall brightness")
feature = _F.BRIGHTNESS_CONTROL
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
validator_class = settings.RangeValidator
validator_class = settings_validator.RangeValidator
def __init__(self, device, rw, validator):
super().__init__(device, rw, validator)
@@ -1570,7 +1609,7 @@ class BrightnessControl(settings.Setting):
return reply
return super().write(device, data_bytes)
class validator_class(settings.RangeValidator):
class validator_class(settings_validator.RangeValidator):
@classmethod
def build(cls, setting_class, device):
reply = device.feature_request(_F.BRIGHTNESS_CONTROL)
@@ -1591,7 +1630,7 @@ class LEDControl(settings.Setting):
feature = _F.COLOR_LED_EFFECTS
rw_options = {"read_fnid": 0x70, "write_fnid": 0x80}
choices_universe = common.NamedInts(Device=0, Solaar=1)
validator_class = settings.ChoicesValidator
validator_class = settings_validator.ChoicesValidator
validator_options = {"choices": choices_universe}
@@ -1601,16 +1640,15 @@ _LEDP = hidpp20.LEDParam
# an LED Zone has an index, a set of possible LED effects, and an LED effect setting
class LEDZoneSetting(settings.Setting):
name = "led_zone_"
name = "led_zone_" # the trailing underscore signals that this setting creates other settings
label = _("LED Zone Effects")
description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be set to Solaar to be effective.")
feature = _F.COLOR_LED_EFFECTS
color_field = {"name": _LEDP.color, "kind": settings.KIND.choice, "label": None, "choices": colors}
speed_field = {"name": _LEDP.speed, "kind": settings.KIND.range, "label": _("Speed"), "min": 0, "max": 255}
period_field = {"name": _LEDP.period, "kind": settings.KIND.range, "label": _("Period"), "min": 100, "max": 5000}
intensity_field = {"name": _LEDP.intensity, "kind": settings.KIND.range, "label": _("Intensity"), "min": 0, "max": 100}
ramp_field = {"name": _LEDP.ramp, "kind": settings.KIND.choice, "label": _("Ramp"), "choices": hidpp20.LEDRampChoices}
# form_field = {"name": _LEDP.form, "kind": settings.KIND.choice, "label": _("Form"), "choices": _hidpp20.LEDFormChoices}
color_field = {"name": _LEDP.color, "kind": settings.Kind.COLOR, "label": _("Color")}
speed_field = {"name": _LEDP.speed, "kind": settings.Kind.RANGE, "label": _("Speed"), "min": 0, "max": 255}
period_field = {"name": _LEDP.period, "kind": settings.Kind.RANGE, "label": _("Period"), "min": 100, "max": 5000}
intensity_field = {"name": _LEDP.intensity, "kind": settings.Kind.RANGE, "label": _("Intensity"), "min": 0, "max": 100}
ramp_field = {"name": _LEDP.ramp, "kind": settings.Kind.CHOICE, "label": _("Ramp"), "choices": hidpp20.LedRampChoice}
possible_fields = [color_field, speed_field, period_field, intensity_field, ramp_field]
@classmethod
@@ -1620,14 +1658,14 @@ class LEDZoneSetting(settings.Setting):
for zone in infos.zones:
prefix = common.int2bytes(zone.index, 1)
rw = settings.FeatureRW(cls.feature, read_fnid, write_fnid, prefix=prefix, suffix=suffix)
validator = settings.HeteroValidator(
data_class=hidpp20.LEDEffectSetting, options=zone.effects, readable=infos.readable
validator = settings_validator.HeteroValidator(
data_class=hidpp20.LEDEffectSetting, options=zone.effects, readable=infos.readable and read_fnid is not None
)
setting = cls(device, rw, validator)
setting.name = cls.name + str(int(zone.location))
setting.label = _("LEDs") + " " + str(hidpp20.LEDZoneLocations[zone.location])
choices = [hidpp20.LEDEffects[e.ID][0] for e in zone.effects if e.ID in hidpp20.LEDEffects]
ID_field = {"name": "ID", "kind": settings.KIND.choice, "label": None, "choices": choices}
ID_field = {"name": "ID", "kind": settings.Kind.CHOICE, "label": None, "choices": choices}
setting.possible_fields = [ID_field] + cls.possible_fields
setting.fields_map = hidpp20.LEDEffects
settings_.append(setting)
@@ -1645,19 +1683,19 @@ class RGBControl(settings.Setting):
feature = _F.RGB_EFFECTS
rw_options = {"read_fnid": 0x50, "write_fnid": 0x50}
choices_universe = common.NamedInts(Device=0, Solaar=1)
validator_class = settings.ChoicesValidator
validator_class = settings_validator.ChoicesValidator
validator_options = {"choices": choices_universe, "write_prefix_bytes": b"\x01", "read_skip_byte_count": 1}
class RGBEffectSetting(LEDZoneSetting):
name = "rgb_zone_"
name = "rgb_zone_" # the trailing underscore signals that this setting creates other settings
label = _("LED Zone Effects")
description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be set to Solaar to be effective.")
feature = _F.RGB_EFFECTS
@classmethod
def build(cls, device):
return cls.setup(device, 0xE0, 0x10, b"\x01")
return cls.setup(device, None, 0x10, b"\x01")
class PerKeyLighting(settings.Settings):
@@ -1723,7 +1761,7 @@ class PerKeyLighting(settings.Settings):
class rw_class(settings.FeatureRWMap):
pass
class validator_class(settings.ChoicesMapValidator):
class validator_class(settings_validator.ChoicesMapValidator):
@classmethod
def build(cls, setting_class, device):
choices_map = {}
@@ -1735,14 +1773,109 @@ class PerKeyLighting(settings.Settings):
key = (
setting_class.keys_universe[i]
if i in setting_class.keys_universe
else common.NamedInt(i, "KEY " + str(i))
else common.NamedInt(i, f"KEY {str(i)}")
)
choices_map[key] = setting_class.choices_universe
result = cls(choices_map) if choices_map else None
return result
SETTINGS = [
# Allow changes to force sensing buttons
class ForceSensing(settings_new.Settings):
name = "force-sensing"
label = _("Force Sensing Buttons")
description = _("Change the force required to activate button.")
feature = _F.FORCE_SENSING_BUTTON
setup = "force_buttons"
get = "get_current"
set = "set_current"
acceptable = "acceptable_current_key"
choices_universe = list(range(0, 256))
kind = settings.Kind.MAP_RANGE
@classmethod
def build(cls, device):
cls.check_properties(cls)
device_object = getattr(device, cls.setup)()
if device_object:
setting = cls(device, device_object)
if setting and len(device_object) == 1:
## If there is only one force button a simpler interface can be used
setting.label = _("Force Sensing Button")
setting.acceptable = "acceptable_current"
setting.min_value = device_object[0].min_value
setting.max_value = device_object[0].max_value
setting.kind = settings.Kind.RANGE
return setting
class HapticLevel(settings.Setting):
name = "haptic-level"
label = _("Haptic Feeback Level")
description = _("Change power of haptic feedback. (Zero to turn off.)")
feature = _F.HAPTIC
choices_universe = common.NamedInts(Off=0, Low=25, Medium=50, High=75, Maximum=100)
min_value = 0
max_value = 100
class rw_class(settings.FeatureRW):
def __init__(self, feature):
super().__init__(feature, read_fnid=0x10, write_fnid=0x20)
def read(self, device, data_bytes=b""):
result = device.feature_request(self.feature, 0x10)
if result[0] & 0x01 == 0: # disabled, return 0
return b"\x00"
else: # enabled, return second byte
return result[1:2]
def write(self, device, data_bytes):
if data_bytes == b"\x00":
write_bytes = b"\x00\x32" # disable, at 50 percent
else:
write_bytes = b"\x01" + data_bytes
reply = device.feature_request(self.feature, 0x20, write_bytes)
return reply
@classmethod
def build(cls, device):
response = device.feature_request(cls.feature, 0x10)
if response:
rw = cls.rw_class(cls.feature)
levels = response[2] & 0x01
if levels: # device only has four levels
validator = settings_validator.ChoicesValidator(choices=cls.choices_universe)
else: # device has all levels
validator = settings_validator.RangeValidator(min_value=cls.min_value, max_value=cls.max_value)
return cls(device, rw, validator)
# This setting is not displayed in the UI
# Use `solaar config <device> haptic-play <form>` to play a haptic form
class PlayHapticWaveForm(settings.Setting):
name = "haptic-play"
label = _("Play Haptic Waveform")
description = _("Tell device to play a haptic waveform.")
feature = _F.HAPTIC
choices_universe = hidpp20_constants.HapticWaveForms
rw_options = {"read_fnid": None, "write_fnid": 0x40} # nothing to read
persist = False # persisting this setting is useless
display = False # don't display in UI, interact using `solaar config ...`
class validator_class(settings_validator.ChoicesValidator):
@classmethod
def build(cls, setting_class, device):
response = device.feature_request(_F.HAPTIC, 0x00)
if response:
waves = common.NamedInts()
waveforms = int.from_bytes(response[4:8])
for waveform in hidpp20_constants.HapticWaveForms:
if (1 << int(waveform)) & waveforms:
waves[int(waveform)] = str(waveform)
return cls(choices=waves, byte_count=1)
SETTINGS: list[settings.Setting] = [
RegisterHandDetection, # simple
RegisterSmoothScroll, # simple
RegisterSideScroll, # simple
@@ -1754,6 +1887,7 @@ SETTINGS = [
HiresSmoothResolution, # working
HiresMode, # simple
ScrollRatchet, # simple
ScrollRatchetTorque,
SmartShift, # working
ScrollRatchetEnhanced,
SmartShiftEnhanced, # simple
@@ -1786,6 +1920,7 @@ SETTINGS = [
PersistentRemappableAction,
DivertKeys, # working
DisableKeyboardKeys, # working
ForceSensing,
CrownSmooth, # working
DivertCrown, # working
DivertGkeys, # working
@@ -1797,64 +1932,186 @@ SETTINGS = [
Gesture2Gestures, # working
Gesture2Divert,
Gesture2Params, # working
HapticLevel,
PlayHapticWaveForm,
Sidetone,
Equalizer,
ADCPower,
]
def check_feature(device, settings_class: settings.Setting) -> None | bool | Any:
class SettingsProtocol(Protocol):
@property
def name(self):
...
@property
def label(self):
...
@property
def description(self):
...
@property
def feature(self):
...
@property
def register(self):
...
@property
def kind(self):
...
@property
def min_version(self):
...
@property
def persist(self):
...
@property
def rw_options(self):
...
@property
def validator_class(self):
...
@property
def validator_options(self):
...
@classmethod
def build(cls, device):
...
def val_to_string(self, value):
...
@property
def choices(self):
...
@property
def range(self):
...
def _pre_read(self, cached, key=None):
...
def read(self, cached=True):
...
def _pre_write(self, save=True):
...
def update(self, value, save=True):
...
def write(self, value, save=True):
...
def acceptable(self, args, current):
...
def compare(self, args, current):
...
def apply(self):
...
def __str__(self):
...
def check_feature(device, settings_class: SettingsProtocol) -> None | bool | SettingsProtocol:
if settings_class.feature not in device.features:
return
if settings_class.min_version > device.features.get_feature_version(settings_class.feature):
return
if device.features.get_hidden(settings_class.feature):
return
try:
detected = settings_class.build(device)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("check_feature %s [%s] detected %s", settings_class.name, settings_class.feature, detected)
logger.debug("check_feature %s [%s] detected", settings_class.name, settings_class.feature)
return detected
except Exception as e:
logger.error(
"check_feature %s [%s] error %s\n%s", settings_class.name, settings_class.feature, e, traceback.format_exc()
)
return False # differentiate from an error-free determination that the setting is not supported
raise e # differentiate from an error-free determination that the setting is not supported
# Returns True if device was queried to find features, False otherwise
def check_feature_settings(device, already_known):
"""Auto-detect device settings by the HID++ 2.0 features they have."""
def check_feature_settings(device, already_known) -> bool:
"""Auto-detect device settings by the HID++ 2.0 features they have.
Returns
-------
bool
True, if device was fully queried to find features, False otherwise.
"""
if not device.features or not device.online:
return False
if device.protocol and device.protocol < 2.0:
return False
absent = device.persister.get("_absent", []) if device.persister else []
newAbsent = []
new_absent = []
for sclass in SETTINGS:
if sclass.feature:
known_present = device.persister and sclass.name in device.persister
if not any(s.name == sclass.name for s in already_known) and (known_present or sclass.name not in absent):
setting = check_feature(device, sclass)
try:
setting = check_feature(device, sclass)
except Exception as err:
# on an internal HID++ error, assume offline and stop further checking
if (
isinstance(err, exceptions.FeatureCallError)
and err.error == hidpp20_constants.ErrorCode.LOGITECH_ERROR
):
logger.warning(f"HID++ internal error checking feature {sclass.name}: make device not present")
device.online = False
device.present = False
return False
else:
logger.warning(f"ignore feature {sclass.name} because of error {err}")
if isinstance(setting, list):
for s in setting:
already_known.append(s)
if sclass.name in newAbsent:
newAbsent.remove(sclass.name)
if sclass.name in new_absent:
new_absent.remove(sclass.name)
elif setting:
already_known.append(setting)
if sclass.name in newAbsent:
newAbsent.remove(sclass.name)
if sclass.name in new_absent:
new_absent.remove(sclass.name)
elif setting is None:
if sclass.name not in newAbsent and sclass.name not in absent and sclass.name not in device.persister:
newAbsent.append(sclass.name)
if device.persister and newAbsent:
absent.extend(newAbsent)
if sclass.name not in new_absent and sclass.name not in absent and sclass.name not in device.persister:
new_absent.append(sclass.name)
if device.persister and new_absent:
absent.extend(new_absent)
device.persister["_absent"] = absent
return True
def check_feature_setting(device, setting_name):
def check_feature_setting(device, setting_name: str) -> settings.Setting | None:
for sclass in SETTINGS:
if sclass.feature and sclass.name == setting_name and device.features:
setting = check_feature(device, sclass)
if setting:
if (
sclass.feature
and device.features
and (sclass.name == setting_name or sclass.name.endswith("_") and setting_name.startswith(sclass.name))
):
try:
setting = check_feature(device, sclass)
except Exception:
return None
if isinstance(setting, list):
for s in setting:
if s.name == setting_name:
return s
elif setting:
return setting

View File

@@ -0,0 +1,745 @@
from __future__ import annotations
import logging
import math
from enum import IntEnum
from logitech_receiver import common
from logitech_receiver.common import NamedInt
from logitech_receiver.common import NamedInts
logger = logging.getLogger(__name__)
def bool_or_toggle(current: bool | str, new: bool | str) -> bool:
if isinstance(new, bool):
return new
try:
return bool(int(new))
except (TypeError, ValueError):
new = str(new).lower()
if new in ("true", "yes", "on", "t", "y"):
return True
if new in ("false", "no", "off", "f", "n"):
return False
if new in ("~", "toggle"):
return not current
return None
class Kind(IntEnum):
TOGGLE = 0x01
CHOICE = 0x02
RANGE = 0x04
MAP_CHOICE = 0x0A
MULTIPLE_TOGGLE = 0x10
PACKED_RANGE = 0x20
MULTIPLE_RANGE = 0x40
HETERO = 0x80
class Validator:
@classmethod
def build(cls, setting_class, device, **kwargs) -> Validator:
return cls(**kwargs)
@classmethod
def to_string(cls, value) -> str:
return str(value)
def compare(self, args, current):
if len(args) != 1:
return False
return args[0] == current
class BooleanValidator(Validator):
__slots__ = ("true_value", "false_value", "read_skip_byte_count", "write_prefix_bytes", "mask", "needs_current_value")
kind = Kind.TOGGLE
default_true = 0x01
default_false = 0x00
# mask specifies all the affected bits in the value
default_mask = 0xFF
def __init__(
self,
true_value=default_true,
false_value=default_false,
mask=default_mask,
read_skip_byte_count=0,
write_prefix_bytes=b"",
):
if isinstance(true_value, int):
assert isinstance(false_value, int)
if mask is None:
mask = self.default_mask
else:
assert isinstance(mask, int)
assert true_value & false_value == 0
assert true_value & mask == true_value
assert false_value & mask == false_value
self.needs_current_value = mask != self.default_mask
elif isinstance(true_value, bytes):
if false_value is None or false_value == self.default_false:
false_value = b"\x00" * len(true_value)
else:
assert isinstance(false_value, bytes)
if mask is None or mask == self.default_mask:
mask = b"\xff" * len(true_value)
else:
assert isinstance(mask, bytes)
assert len(mask) == len(true_value) == len(false_value)
tv = common.bytes2int(true_value)
fv = common.bytes2int(false_value)
mv = common.bytes2int(mask)
assert tv != fv # true and false might be something other than bit values
assert tv & mv == tv
assert fv & mv == fv
self.needs_current_value = any(m != 0xFF for m in mask)
else:
raise Exception(f"invalid mask '{mask!r}', type {type(mask)}")
self.true_value = true_value
self.false_value = false_value
self.mask = mask
self.read_skip_byte_count = read_skip_byte_count
self.write_prefix_bytes = write_prefix_bytes
def validate_read(self, reply_bytes):
reply_bytes = reply_bytes[self.read_skip_byte_count :]
if isinstance(self.mask, int):
reply_value = ord(reply_bytes[:1]) & self.mask
if logger.isEnabledFor(logging.DEBUG):
logger.debug("BooleanValidator: validate read %r => %02X", reply_bytes, reply_value)
if reply_value == self.true_value:
return True
if reply_value == self.false_value:
return False
logger.warning(
"BooleanValidator: reply %02X mismatched %02X/%02X/%02X",
reply_value,
self.true_value,
self.false_value,
self.mask,
)
return False
count = len(self.mask)
mask = common.bytes2int(self.mask)
reply_value = common.bytes2int(reply_bytes[:count]) & mask
true_value = common.bytes2int(self.true_value)
if reply_value == true_value:
return True
false_value = common.bytes2int(self.false_value)
if reply_value == false_value:
return False
logger.warning(
"BooleanValidator: reply %r mismatched %r/%r/%r", reply_bytes, self.true_value, self.false_value, self.mask
)
return False
def prepare_write(self, new_value, current_value=None):
if new_value is None:
new_value = False
else:
assert isinstance(new_value, bool), f"New value {new_value} for boolean setting is not a boolean"
to_write = self.true_value if new_value else self.false_value
if isinstance(self.mask, int):
if current_value is not None and self.needs_current_value:
to_write |= ord(current_value[:1]) & (0xFF ^ self.mask)
if current_value is not None and to_write == ord(current_value[:1]):
return None
to_write = bytes([to_write])
else:
to_write = bytearray(to_write)
count = len(self.mask)
for i in range(0, count):
b = ord(to_write[i : i + 1])
m = ord(self.mask[i : i + 1])
assert b & m == b
# b &= m
if current_value is not None and self.needs_current_value:
b |= ord(current_value[i : i + 1]) & (0xFF ^ m)
to_write[i] = b
to_write = bytes(to_write)
if current_value is not None and to_write == current_value[: len(to_write)]:
return None
if logger.isEnabledFor(logging.DEBUG):
logger.debug("BooleanValidator: prepare_write(%s, %s) => %r", new_value, current_value, to_write)
return self.write_prefix_bytes + to_write
def acceptable(self, args, current):
if len(args) != 1:
return None
val = bool_or_toggle(current, args[0])
return [val] if val is not None else None
class BitFieldValidator(Validator):
__slots__ = ("byte_count", "options")
kind = Kind.MULTIPLE_TOGGLE
def __init__(self, options, byte_count=None):
assert isinstance(options, list)
self.options = options
self.byte_count = (max(x.bit_length() for x in options) + 7) // 8
if byte_count:
assert isinstance(byte_count, int) and byte_count >= self.byte_count
self.byte_count = byte_count
def to_string(self, value) -> str:
def element_to_string(key, val):
k = next((k for k in self.options if int(key) == k), None)
return str(k) + ":" + str(val) if k is not None else "?"
return "{" + ", ".join([element_to_string(k, value[k]) for k in value]) + "}"
def validate_read(self, reply_bytes):
r = common.bytes2int(reply_bytes[: self.byte_count])
value = {int(k): False for k in self.options}
m = 1
for _ignore in range(8 * self.byte_count):
if m in self.options:
value[int(m)] = bool(r & m)
m <<= 1
return value
def prepare_write(self, new_value):
assert isinstance(new_value, dict)
w = 0
for k, v in new_value.items():
if v:
w |= int(k)
return common.int2bytes(w, self.byte_count)
def get_options(self):
return self.options
def acceptable(self, args, current):
if len(args) != 2:
return None
key = next((key for key in self.options if key == args[0]), None)
if key is None:
return None
val = bool_or_toggle(current[int(key)], args[1])
return None if val is None else [int(key), val]
def compare(self, args, current):
if len(args) != 2:
return False
key = next((key for key in self.options if key == args[0]), None)
if key is None:
return False
return args[1] == current[int(key)]
class BitFieldWithOffsetAndMaskValidator(Validator):
__slots__ = ("byte_count", "options", "_option_from_key", "_mask_from_offset", "_option_from_offset_mask")
kind = Kind.MULTIPLE_TOGGLE
sep = 0x01
def __init__(self, options, om_method=None, byte_count=None):
assert isinstance(options, list)
# each element of options is an instance of a class
# that has an id (which is used as an index in other dictionaries)
# and where om_method is a method that returns a byte offset and byte mask
# that says how to access and modify the bit toggle for the option
self.options = options
self.om_method = om_method
# to retrieve the options efficiently:
self._option_from_key = {}
self._mask_from_offset = {}
self._option_from_offset_mask = {}
for opt in options:
offset, mask = om_method(opt)
self._option_from_key[int(opt)] = opt
try:
self._mask_from_offset[offset] |= mask
except KeyError:
self._mask_from_offset[offset] = mask
try:
mask_to_opt = self._option_from_offset_mask[offset]
except KeyError:
mask_to_opt = {}
self._option_from_offset_mask[offset] = mask_to_opt
mask_to_opt[mask] = opt
self.byte_count = (max(om_method(x)[1].bit_length() for x in options) + 7) // 8 # is this correct??
if byte_count:
assert isinstance(byte_count, int) and byte_count >= self.byte_count
self.byte_count = byte_count
def prepare_read(self):
r = []
for offset, mask in self._mask_from_offset.items():
b = offset << (8 * (self.byte_count + 1))
b |= (self.sep << (8 * self.byte_count)) | mask
r.append(common.int2bytes(b, self.byte_count + 2))
return r
def prepare_read_key(self, key):
option = self._option_from_key.get(key, None)
if option is None:
return None
offset, mask = option.om_method(option)
b = offset << (8 * (self.byte_count + 1))
b |= (self.sep << (8 * self.byte_count)) | mask
return common.int2bytes(b, self.byte_count + 2)
def validate_read(self, reply_bytes_dict):
values = {int(k): False for k in self.options}
for query, b in reply_bytes_dict.items():
offset = common.bytes2int(query[0:1])
b += (self.byte_count - len(b)) * b"\x00"
value = common.bytes2int(b[: self.byte_count])
mask_to_opt = self._option_from_offset_mask.get(offset, {})
m = 1
for _ignore in range(8 * self.byte_count):
if m in mask_to_opt:
values[int(mask_to_opt[m])] = bool(value & m)
m <<= 1
return values
def prepare_write(self, new_value):
assert isinstance(new_value, dict)
w = {}
for k, v in new_value.items():
option = self._option_from_key[int(k)]
offset, mask = self.om_method(option)
if offset not in w:
w[offset] = 0
if v:
w[offset] |= mask
return [
common.int2bytes(
(offset << (8 * (2 * self.byte_count + 1)))
| (self.sep << (16 * self.byte_count))
| (self._mask_from_offset[offset] << (8 * self.byte_count))
| value,
2 * self.byte_count + 2,
)
for offset, value in w.items()
]
def get_options(self):
return [int(opt) if isinstance(opt, int) else opt.as_int() for opt in self.options]
def acceptable(self, args, current):
if len(args) != 2:
return None
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
if key is None:
return None
val = bool_or_toggle(current[int(key)], args[1])
return None if val is None else [int(key), val]
def compare(self, args, current):
if len(args) != 2:
return False
key = next((option.id for option in self.options if option.as_int() == args[0]), None)
if key is None:
return False
return args[1] == current[int(key)]
class ChoicesValidator(Validator):
"""Translates between NamedInts and a byte sequence.
:param choices: a list of NamedInts
:param byte_count: the size of the derived byte sequence. If None, it
will be calculated from the choices."""
kind = Kind.CHOICE
def __init__(self, choices=None, byte_count=None, read_skip_byte_count=0, write_prefix_bytes=b""):
assert choices is not None
assert isinstance(choices, NamedInts)
assert len(choices) > 1
self.choices = choices
self.needs_current_value = False
max_bits = max(x.bit_length() for x in choices)
self._byte_count = (max_bits // 8) + (1 if max_bits % 8 else 0)
if byte_count:
assert self._byte_count <= byte_count
self._byte_count = byte_count
assert self._byte_count < 8
self._read_skip_byte_count = read_skip_byte_count
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
assert self._byte_count + self._read_skip_byte_count <= 14
assert self._byte_count + len(self._write_prefix_bytes) <= 14
def to_string(self, value) -> str:
return str(self.choices[value]) if isinstance(value, int) else str(value)
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])
valid_value = self.choices[reply_value]
assert valid_value is not None, f"{self.__class__.__name__}: failed to validate read value {reply_value:02X}"
return valid_value
def prepare_write(self, new_value, current_value=None):
if new_value is None:
value = self.choices[:][0]
else:
value = self.choice(new_value)
if value is None:
raise ValueError(f"invalid choice {new_value!r}")
assert isinstance(value, NamedInt)
return self._write_prefix_bytes + value.bytes(self._byte_count)
def choice(self, value):
if isinstance(value, int):
return self.choices[value]
try:
int(value)
if int(value) in self.choices:
return self.choices[int(value)]
except Exception:
pass
if value in self.choices:
return self.choices[value]
else:
return None
def acceptable(self, args, current):
choice = self.choice(args[0]) if len(args) == 1 else None
return None if choice is None else [choice]
class ChoicesMapValidator(ChoicesValidator):
kind = Kind.MAP_CHOICE
def __init__(
self,
choices_map,
key_byte_count=0,
key_postfix_bytes=b"",
byte_count=0,
read_skip_byte_count=0,
write_prefix_bytes=b"",
extra_default=None,
mask=-1,
activate=0,
):
assert choices_map is not None
assert isinstance(choices_map, dict)
max_key_bits = 0
max_value_bits = 0
for key, choices in choices_map.items():
assert isinstance(key, NamedInt)
assert isinstance(choices, NamedInts)
max_key_bits = max(max_key_bits, key.bit_length())
for key_value in choices:
assert isinstance(key_value, NamedInt)
max_value_bits = max(max_value_bits, key_value.bit_length())
self._key_byte_count = (max_key_bits + 7) // 8
if key_byte_count:
assert self._key_byte_count <= key_byte_count
self._key_byte_count = key_byte_count
self._byte_count = (max_value_bits + 7) // 8
if byte_count:
assert self._byte_count <= byte_count
self._byte_count = byte_count
self.choices = choices_map
self.needs_current_value = False
self.extra_default = extra_default
self._key_postfix_bytes = key_postfix_bytes
self._read_skip_byte_count = read_skip_byte_count if read_skip_byte_count else 0
self._write_prefix_bytes = write_prefix_bytes if write_prefix_bytes else b""
self.activate = activate
self.mask = mask
assert self._byte_count + self._read_skip_byte_count + self._key_byte_count <= 14
assert self._byte_count + len(self._write_prefix_bytes) + self._key_byte_count <= 14
def to_string(self, value) -> str:
def element_to_string(key, val):
k, c = next(((k, c) for k, c in self.choices.items() if int(key) == k), (None, None))
return str(k) + ":" + str(c[val]) if k is not None else "?"
return "{" + ", ".join([element_to_string(k, value[k]) for k in sorted(value)]) + "}"
def validate_read(self, reply_bytes, key):
start = self._key_byte_count + self._read_skip_byte_count
end = start + self._byte_count
reply_value = common.bytes2int(reply_bytes[start:end]) & self.mask
# reprogrammable keys starts out as 0, which is not a choice, so don't use assert here
if self.extra_default is not None and self.extra_default == reply_value:
return int(self.choices[key][0])
if reply_value not in self.choices[key]:
assert reply_value in self.choices[key], "%s: failed to validate read value %02X" % (
self.__class__.__name__,
reply_value,
)
return reply_value
def prepare_key(self, key):
return key.to_bytes(self._key_byte_count, "big") + self._key_postfix_bytes
def prepare_write(self, key, new_value):
choices = self.choices.get(key)
if choices is None or (new_value not in choices and new_value != self.extra_default):
logger.error("invalid choice %r for %s", new_value, key)
return None
new_value = new_value | self.activate
return self._write_prefix_bytes + new_value.to_bytes(self._byte_count, "big")
def acceptable(self, args, current):
if len(args) != 2:
return None
key, choices = next(((key, item) for key, item in self.choices.items() if key == args[0]), (None, None))
if choices is None or args[1] not in choices:
return None
choice = next((item for item in choices if item == args[1]), None)
return [int(key), int(choice)] if choice is not None else None
def compare(self, args, current):
if len(args) != 2:
return False
key = next((key for key in self.choices if key == int(args[0])), None)
if key is None:
return False
return args[1] == current[int(key)]
class RangeValidator(Validator):
kind = Kind.RANGE
"""Translates between integers and a byte sequence.
:param min_value: minimum accepted value (inclusive)
:param max_value: maximum accepted value (inclusive)
:param byte_count: the size of the derived byte sequence. If None, it
will be calculated from the range."""
min_value = 0
max_value = 255
@classmethod
def build(cls, setting_class, device, **kwargs):
kwargs["min_value"] = setting_class.min_value
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""):
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.needs_current_value = True # read and check before write (needed for ADC power and probably a good idea anyway)
self._byte_count = math.ceil(math.log(max_value + 1, 256))
if byte_count:
assert self._byte_count <= byte_count
self._byte_count = byte_count
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])
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
def prepare_write(self, new_value, current_value=None):
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)
# 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
def compare(self, args, current):
if len(args) == 1:
return args[0] == current
elif len(args) == 2:
return args[0] <= current <= args[1]
else:
return False
class HeteroValidator(Validator):
kind = Kind.HETERO
@classmethod
def build(cls, setting_class, device, **kwargs):
return cls(**kwargs)
def __init__(self, data_class=None, options=None, readable=True):
assert data_class is not None and options is not None
self.data_class = data_class
self.options = options
self.readable = readable
self.needs_current_value = False
def validate_read(self, reply_bytes):
if self.readable:
reply_value = self.data_class.from_bytes(reply_bytes, options=self.options)
return reply_value
def prepare_write(self, new_value, current_value=None):
to_write = new_value.to_bytes(options=self.options)
return to_write
def acceptable(self, args, current): # should this actually do some checking?
return True
class PackedRangeValidator(Validator):
kind = Kind.PACKED_RANGE
"""Several range values, all the same size, all the same min and max"""
min_value = 0
max_value = 255
count = 1
rsbc = 0
write_prefix_bytes = b""
def __init__(
self, keys, min_value=0, max_value=255, count=1, byte_count=1, read_skip_byte_count=0, write_prefix_bytes=b""
):
assert max_value > min_value
self.needs_current_value = True
self.keys = keys
self.min_value = min_value
self.max_value = max_value
self.count = count
self.bc = math.ceil(math.log(max_value + 1 - min(0, min_value), 256))
if byte_count:
assert self.bc <= byte_count
self.bc = byte_count
assert self.bc * self.count
self.rsbc = read_skip_byte_count
self.write_prefix_bytes = write_prefix_bytes
def validate_read(self, reply_bytes):
rvs = {
n: common.bytes2int(reply_bytes[self.rsbc + n * self.bc : self.rsbc + (n + 1) * self.bc], signed=True)
for n in range(self.count)
}
for n in range(self.count):
assert rvs[n] >= self.min_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
assert rvs[n] <= self.max_value, f"{self.__class__.__name__}: failed to validate read value {rvs[n]:02X}"
return rvs
def prepare_write(self, new_values):
if len(new_values) != self.count:
raise ValueError(f"wrong number of values {new_values!r}")
for new_value in new_values.values():
if new_value < self.min_value or new_value > self.max_value:
raise ValueError(f"invalid value {new_value!r}")
bytes = self.write_prefix_bytes + b"".join(
common.int2bytes(new_values[n], self.bc, signed=True) for n in range(self.count)
)
return bytes
def acceptable(self, args, current):
if len(args) != 2 or int(args[0]) < 0 or int(args[0]) >= self.count:
return None
return None if not isinstance(args[1], int) or args[1] < self.min_value or args[1] > self.max_value else args
def compare(self, args, current):
logger.warning("compare not implemented for packed range settings")
return False
class MultipleRangeValidator(Validator):
kind = Kind.MULTIPLE_RANGE
def __init__(self, items, sub_items):
assert isinstance(items, list) # each element must have .index and its __int__ must return its id (not its index)
assert isinstance(sub_items, dict)
# sub_items: items -> class with .minimum, .maximum, .length (in bytes), .id (a string) and .widget (e.g. 'Scale')
self.items = items
self.keys = NamedInts(**{str(item): int(item) for item in items})
self._item_from_id = {int(k): k for k in items}
self.sub_items = sub_items
def prepare_read_item(self, item):
return common.int2bytes((self._item_from_id[int(item)].index << 1) | 0xFF, 2)
def validate_read_item(self, reply_bytes, item):
item = self._item_from_id[int(item)]
start = 0
value = {}
for sub_item in self.sub_items[item]:
r = reply_bytes[start : start + sub_item.length]
if len(r) < sub_item.length:
r += b"\x00" * (sub_item.length - len(value))
v = common.bytes2int(r)
if not (sub_item.minimum < v < sub_item.maximum):
logger.warning(
f"{self.__class__.__name__}: failed to validate read value for {item}.{sub_item}: "
+ f"{v} not in [{sub_item.minimum}..{sub_item.maximum}]"
)
value[str(sub_item)] = v
start += sub_item.length
return value
def prepare_write(self, value):
seq = []
w = b""
for item in value.keys():
_item = self._item_from_id[int(item)]
b = common.int2bytes(_item.index, 1)
for sub_item in self.sub_items[_item]:
try:
v = value[int(item)][str(sub_item)]
except KeyError:
return None
if not (sub_item.minimum <= v <= sub_item.maximum):
raise ValueError(
f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]"
)
b += common.int2bytes(v, sub_item.length)
if len(w) + len(b) > 15:
seq.append(b + b"\xff")
w = b""
w += b
seq.append(w + b"\xff")
return seq
def prepare_write_item(self, item, value):
_item = self._item_from_id[int(item)]
w = common.int2bytes(_item.index, 1)
for sub_item in self.sub_items[_item]:
try:
v = value[str(sub_item)]
except KeyError:
return None
if not (sub_item.minimum <= v <= sub_item.maximum):
raise ValueError(f"invalid choice for {item}.{sub_item}: {v} not in [{sub_item.minimum}..{sub_item.maximum}]")
w += common.int2bytes(v, sub_item.length)
return w + b"\xff"
def acceptable(self, args, current):
# just one item, with at least one sub-item
if not isinstance(args, list) or len(args) != 2 or not isinstance(args[1], dict):
return None
item = next((p for p in self.items if p.id == args[0] or str(p) == args[0]), None)
if not item:
return None
for sub_key, value in args[1].items():
sub_item = next((it for it in self.sub_items[item] if it.id == sub_key), None)
if not sub_item:
return None
if not isinstance(value, int) or not (sub_item.minimum <= value <= sub_item.maximum):
return None
return [int(item), {**args[1]}]
def compare(self, args, current):
logger.warning("compare not implemented for multiple range settings")
return False

View File

@@ -29,19 +29,22 @@ from .common import UnsortedNamedInts
_XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser(os.path.join("~", ".config"))
_keys_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "keys.yaml")
# Original set done as
# <controls.xml awk -F\" '/<Control /{sub(/^LD_FINFO_(CTRLID_)?/, "", $2);printf("\t%s=0x%04X,\n", $2, $4)}' | sort -t= -k2
# Keys added afterwards based on information from Logitech and users
CONTROL = NamedInts(
{
"Volume_Up": 0x0001,
"Volume_Down": 0x0002,
"Volume_Up_old": 0x0001,
"Volume_Down_old": 0x0002,
"Mute": 0x0003,
"Play__Pause": 0x0004,
"Play__Pause_old": 0x0004,
"Next": 0x0005,
"Previous": 0x0006,
"Stop": 0x0007,
"Application_Switcher": 0x0008,
"Burn": 0x0009,
"Calculator": 0x000A, # Craft Keyboard top 4th from right
"Calculator": 0x000A, # Craft Keyboard top 4th from right; Logitech
"Calendar": 0x000B,
"Close": 0x000C,
"Eject": 0x000D,
@@ -55,7 +58,7 @@ CONTROL = NamedInts(
"Undo_As_HID": 0x0015,
"Redo_As_Ctrl_Y": 0x0016,
"Redo_As_HID": 0x0017,
"Print_As_Ctrl_P": 0x0018,
"Print_As_Ctrl_P": 0x0018, # Logitech, modified
"Print_As_HID": 0x0019,
"Save_As_Ctrl_S": 0x001A,
"Save_As_HID": 0x001B,
@@ -110,13 +113,13 @@ CONTROL = NamedInts(
"Pause_Break": 0x004D,
"Scroll_Lock": 0x004E,
"Contextual_Menu": 0x004F,
"Left_Button": 0x0050, # LEFT_CLICK
"Right_Button": 0x0051, # RIGHT_CLICK
"Middle_Button": 0x0052, # MIDDLE_BUTTON
"Back_Button": 0x0053, # from M510v2 was BACK_AS_BUTTON_4
"Left_Button": 0x0050, # LEFT_CLICK; Logitech
"Right_Button": 0x0051, # RIGHT_CLICK; Logitech
"Middle_Button": 0x0052, # MIDDLE_BUTTON; Logitech
"Back_Button": 0x0053, # from M510v2 was BACK_AS_BUTTON_4; Logitech
"Back": 0x0054, # BACK_AS_HID
"Back_As_Alt_Win_Arrow": 0x0055,
"Forward_Button": 0x0056, # from M510v2 was FORWARD_AS_BUTTON_5
"Forward_Button": 0x0056, # from M510v2 was FORWARD_AS_BUTTON_5; Logitech
"Forward_As_HID": 0x0057,
"Forward_As_Alt_Win_Arrow": 0x0058,
"Button_6": 0x0059,
@@ -140,8 +143,8 @@ CONTROL = NamedInts(
"Button_22": 0x006B,
"Button_23": 0x006C,
"Button_24": 0x006D,
"Show_Desktop": 0x006E, # Craft Keyboard Fn F5
"Lock_PC": 0x006F, # Craft Keyboard top 1st from right
"Show_Desktop": 0x006E, # Craft Keyboard Fn F5; Logitch
"Screen_Lock": 0x006F, # Craft Keyboard top 1st from right; Logitech
"Fn_F1": 0x0070,
"Fn_F2": 0x0071,
"Fn_F3": 0x0072,
@@ -189,7 +192,7 @@ CONTROL = NamedInts(
"Metro_Search": 0x00A3,
"Combo_Sleep": 0x00A4,
"Metro_Share": 0x00A5,
"Metro_Settings": 0x00A6,
"OS_Settings": 0x00A6, # Logitech
"Metro_Devices": 0x00A7,
"Metro_Start_Screen": 0x00A9,
"Zoomin": 0x00AA,
@@ -212,23 +215,23 @@ CONTROL = NamedInts(
"Fn_Down": 0x00C0,
"Fn_Up": 0x00C1,
"Multiplatform_Lock": 0x00C2,
"Mouse_Gesture_Button": 0x00C3, # Thumb_Button on MX Master - Logitech name App_Switch_Gesture
"Smart_Shift": 0x00C4, # Top_Button on MX Master
"Mouse_Gesture_Button": 0x00C3, # Thumb_Button on MX Master - Logitech name App_Switch_Gesture; Logitech
"Smart_Shift": 0x00C4, # Top_Button on MX Master; Logitech
"Microphone": 0x00C5,
"Wifi": 0x00C6,
"Brightness_Down": 0x00C7, # Craft Keyboard Fn F1
"Brightness_Up": 0x00C8, # Craft Keyboard Fn F2
"Brightness_Down": 0x00C7, # Craft Keyboard Fn F1, Logitech
"Brightness_Up": 0x00C8, # Craft Keyboard Fn F2, Logitech
"Display_Out__Project_Screen_": 0x00C9,
"View_Open_Apps": 0x00CA,
"View_All_Apps": 0x00CB,
"Switch_App": 0x00CC,
"Fn_Inversion_Change": 0x00CD,
"MultiPlatform_Back": 0x00CE,
"MultiPlatform_Back": 0x00CE, # Logitech
"MultiPlatform_Forward": 0x00CF,
"MultiPlatform_Gesture_Button": 0x00D0,
"Host_Switch_Channel_1": 0x00D1, # Craft Keyboard
"Host_Switch_Channel_2": 0x00D2, # Craft Keyboard
"Host_Switch_Channel_3": 0x00D3, # Craft Keyboard
"Host_Switch_Channel_1": 0x00D1, # Craft Keyboard; Logitech
"Host_Switch_Channel_2": 0x00D2, # Craft Keyboard; Logitech
"Host_Switch_Channel_3": 0x00D3, # Craft Keyboard; Logitech
"MultiPlatform_Search": 0x00D4,
"MultiPlatform_Home__Mission_Control": 0x00D5,
"MultiPlatform_Menu__Show__Hide_Virtual_Keyboard__Launchpad": 0x00D6,
@@ -241,21 +244,21 @@ CONTROL = NamedInts(
"Multi_Platform_Language_Switch": 0x00DD,
"F_Lock": 0x00DE,
"Switch_Highlight": 0x00DF,
"Mission_Control__Task_View": 0x00E0, # Craft Keyboard Fn F3 Switch_Workspace
"Mission_Control__Task_View": 0x00E0, # Craft Keyboard Fn F3 Switch_Workspace; Logitech
"Dashboard_Launchpad__Action_Center": 0x00E1, # Craft Keyboard Fn F4 Application_Launcher
"Backlight_Down": 0x00E2, # Craft Keyboard Fn F6
"Backlight_Up": 0x00E3, # Craft Keyboard Fn F7
"Previous_Fn": 0x00E4, # Craft Keyboard Fn F8 Previous_Track
"Play__Pause_Fn": 0x00E5, # Craft Keyboard Fn F9 Play__Pause
"Next_Fn": 0x00E6, # Craft Keyboard Fn F10 Next_Track
"Mute_Fn": 0x00E7, # Craft Keyboard Fn F11 Mute
"Volume_Down_Fn": 0x00E8, # Craft Keyboard Fn F12 Volume_Down
"Volume_Up_Fn": 0x00E9, # Craft Keyboard next to F12 Volume_Down
"Backlight_Down": 0x00E2, # Craft Keyboard Fn F6, Logitech
"Backlight_Up": 0x00E3, # Craft Keyboard Fn F7, Logitech
"Previous_Track": 0x00E4, # Craft Keyboard Fn F8 Previous_Track; Logitech
"Play__Pause": 0x00E5, # Craft Keyboard Fn F9 Play__Pause; Logitech
"Next_Track": 0x00E6, # Craft Keyboard Fn F10 Next_Track; Logitech
"Mute_Sound": 0x00E7, # Craft Keyboard Fn F11 Mute; Logitech
"Volume_Down": 0x00E8, # Craft Keyboard Fn F12 Volume_Down; Logitech
"Volume_Up": 0x00E9, # Craft Keyboard next to F12 Volume_Down; Logitech
"App_Contextual_Menu__Right_Click": 0x00EA, # Craft Keyboard top 2nd from right
"Right_Arrow": 0x00EB,
"Left_Arrow": 0x00EC,
"DPI_Change": 0x00ED,
"New_Tab": 0x00EE,
"Open_New_Tab": 0x00EE, # Logitech
"F2": 0x00EF,
"F3": 0x00F0,
"F4": 0x00F1,
@@ -271,20 +274,20 @@ CONTROL = NamedInts(
"Laser_Button_Short_Press": 0x00FB,
"Laser_Button_Long_Press": 0x00FC,
"DPI_Switch": 0x00FD,
"Multiplatform_Home__Show_Desktop": 0x00FE,
"Multiplatform_Home__Show_Desktop": 0x00FE, # Logitech
"Multiplatform_App_Switch__Show_Dashboard": 0x00FF,
"Multiplatform_App_Switch_2": 0x0100, # Multiplatform_App_Switch
"Fn_Inversion__Hot_Key": 0x0101,
"LeftAndRightClick": 0x0102,
"Voice_Dictation": 0x0103, # MX Keys for Business Fn F5 ; MX Mini Fn F6 Dictation
"Emoji_Smiley_Heart_Eyes": 0x0104,
"Emoji_Crying_Face": 0x0105,
"Emoji_Smiley": 0x0106,
"Emoji_Smilie_With_Tears": 0x0107,
"Open_Emoji_Panel": 0x0108, # MX Keys for Business Fn F6 ; MX Mini Fn F7 Emoji
"Multiplatform_App_Switch__Launchpad": 0x0109,
"Snipping_Tool": 0x010A, # MX Keys for Business top 3rd from right; MX Mini Fn F8 Screenshot
"Grave_Accent": 0x010B,
"Dictation": 0x0103, # MX Keys for Business Fn F5 ; MX Mini Fn F6 Dictation; Logitech
"Emoji_Smiley_Heart_Eyes": 0x0104, # Logitech
"Emoji_Crying_Face": 0x0105, # Logitech
"Emoji_Smiley": 0x0106, # Logitech
"Emoji_Smilie_With_Tears": 0x0107, # Logitech
"Emoji": 0x0108, # MX Keys for Business Fn F6 ; MX Mini Fn F7 Emoji, Logitech
"Multiplatform_App_Switch__Launchpad": 0x0109, # Logitech
"Screen_Capture": 0x010A, # MX Keys for Business top 3rd from right; MX Mini Fn F8 Screenshot; Logitech
"Grave_Accent": 0x010B, # Logitech
"Tab_Key": 0x010C,
"Caps_Lock": 0x010D,
"Left_Shift": 0x010E,
@@ -297,306 +300,297 @@ CONTROL = NamedInts(
"Right_Shift": 0x0115,
"Insert": 0x0116,
"Delete": 0x0117, # MX Mini Lock (on delete key in function row)
"Home": 0x118,
"End": 0x119,
"Home": 0x118, # Logitech
"End": 0x119, # Logitech
"Page_Up": 0x11A,
"Page_Down": 0x11B,
"Mute_Microphone": 0x11C, # MX Keys for Business Fn F7 ; MX Mini Fn F9 Microphone Mute
"Do_Not_Disturb": 0x11D,
"Mute_Microphone": 0x11C, # MX Keys for Business Fn F7 ; MX Mini Fn F9 Microphone Mute; Logitech
"Do_Not_Disturb": 0x11D, # Logitech
"Backslash": 0x11E,
"Refresh": 0x11F,
"Refresh": 0x11F, # Logitech
"Close_Tab": 0x120,
"Lang_Switch": 0x121,
"Lang_Switch": 0x121, # Logitech
"Standard_Key_A": 0x122,
"Standard_Key_B": 0x123,
"Standard_Key_C": 0x124, # There are lots more of these
"Right_Option__Start__2": 0x013C, # On MX Mechanical Mini
"Play_Pause": 0x0141, # On MX Mechanical Mini
"Play__Pause_mini": 0x0141, # On MX Mechanical Mini
"Haptic": 0x01A0, # Logitech
"Circle": 0x01A3,
"Triangle": 0x01A4,
"Diamond": 0x01A5,
"Star": 0x01A6,
"Cut": 0x1A9, # Logitech
"Copy": 0x1AA, # Logitech
"Paste": 0x1AB, # Logitech
"Video_On_Off": 0x01AC, # Logitech
"AI": 0x1B4, # Logitech
}
)
for i in range(1, 33): # add in G keys - these are not really Logitech Controls
CONTROL[0x1000 + i] = "G" + str(i)
CONTROL[0x1000 + i] = f"G{str(i)}"
for i in range(1, 9): # add in M keys - these are not really Logitech Controls
CONTROL[0x1100 + i] = "M" + str(i)
CONTROL[0x1100 + i] = f"M{str(i)}"
CONTROL[0x1200] = "MR" # add in MR key - this is not really a Logitech Control
CONTROL._fallback = lambda x: f"unknown:{x:04X}"
# <tasks.xml awk -F\" '/<Task /{gsub(/ /, "_", $6); printf("\t%s=0x%04X,\n", $6, $4)}'
TASK = NamedInts(
Volume_Up=0x0001,
Volume_Down=0x0002,
Mute=0x0003,
class Task(IntEnum):
"""
<tasks.xml awk -F\" '/<Task /{gsub(/ /, "_", $6); printf("\t%s=0x%04X,\n", $6, $4)}'
"""
VOLUME_UP = 0x0001
VOLUME_DOWN = 0x0002
MUTE = 0x0003
# Multimedia tasks:
Play__Pause=0x0004,
Next=0x0005,
Previous=0x0006,
Stop=0x0007,
Application_Switcher=0x0008,
BurnMediaPlayer=0x0009,
Calculator=0x000A,
Calendar=0x000B,
Close_Application=0x000C,
Eject=0x000D,
Email=0x000E,
Help=0x000F,
OffDocument=0x0010,
OffSpreadsheet=0x0011,
OffPowerpnt=0x0012,
Undo=0x0013,
Redo=0x0014,
Print=0x0015,
Save=0x0016,
SmartKeySet=0x0017,
Favorites=0x0018,
GadgetsSet=0x0019,
HomePage=0x001A,
WindowsRestore=0x001B,
WindowsMinimize=0x001C,
Music=0x001D, # also known as MediaPlayer
PLAY_PAUSE = 0x0004
NEXT = 0x0005
PREVIOUS = 0x0006
STOP = 0x0007
APPLICATION_SWITCHER = 0x0008
BURN_MEDIA_PLAYER = 0x0009
CALCULATOR = 0x000A
CALENDAR = 0x000B
CLOSE_APPLICATION = 0x000C
EJECT = 0x000D
EMAIL = 0x000E
HELP = 0x000F
OFF_DOCUMENT = 0x0010
OFF_SPREADSHEET = 0x0011
OFF_POWERPNT = 0x0012
UNDO = 0x0013
REDO = 0x0014
PRINT = 0x0015
SAVE = 0x0016
SMART_KEY_SET = 0x0017
FAVORITES = 0x0018
GADGETS_SET = 0x0019
HOME_PAGE = 0x001A
WINDOWS_RESTORE = 0x001B
WINDOWS_MINIMIZE = 0x001C
MUSIC = 0x001D # also known as MediaPlayer
# Both 0x001E and 0x001F are known as MediaCenterSet
Media_Center_Logitech=0x001E,
Media_Center_Microsoft=0x001F,
UserMenu=0x0020,
Messenger=0x0021,
PersonalFolders=0x0022,
MyMusic=0x0023,
Webcam=0x0024,
PicturesFolder=0x0025,
MyVideos=0x0026,
My_Computer=0x0027,
PictureAppSet=0x0028,
Search=0x0029, # also known as AdvSmartSearch
RecordMediaPlayer=0x002A,
BrowserRefresh=0x002B,
RotateRight=0x002C,
Search_Files=0x002D, # SearchForFiles
MM_SHUFFLE=0x002E,
Sleep=0x002F, # also known as StandBySet
BrowserStop=0x0030,
OneTouchSync=0x0031,
ZoomSet=0x0032,
ZoomBtnInSet2=0x0033,
ZoomBtnInSet=0x0034,
ZoomBtnOutSet2=0x0035,
ZoomBtnOutSet=0x0036,
ZoomBtnResetSet=0x0037,
Left_Click=0x0038, # LeftClick
Right_Click=0x0039, # RightClick
Mouse_Middle_Button=0x003A, # from M510v2 was MiddleMouseButton
Back=0x003B,
Mouse_Back_Button=0x003C, # from M510v2 was BackEx
BrowserForward=0x003D,
Mouse_Forward_Button=0x003E, # from M510v2 was BrowserForwardEx
Mouse_Scroll_Left_Button_=0x003F, # from M510v2 was HorzScrollLeftSet
Mouse_Scroll_Right_Button=0x0040, # from M510v2 was HorzScrollRightSet
QuickSwitch=0x0041,
BatteryStatus=0x0042,
Show_Desktop=0x0043, # ShowDesktop
WindowsLock=0x0044,
FileLauncher=0x0045,
FolderLauncher=0x0046,
GotoWebAddress=0x0047,
GenericMouseButton=0x0048,
KeystrokeAssignment=0x0049,
LaunchProgram=0x004A,
MinMaxWindow=0x004B,
VOLUMEMUTE_NoOSD=0x004C,
New=0x004D,
Copy=0x004E,
CruiseDown=0x004F,
CruiseUp=0x0050,
Cut=0x0051,
Do_Nothing=0x0052,
PageDown=0x0053,
PageUp=0x0054,
Paste=0x0055,
SearchPicture=0x0056,
Reply=0x0057,
PhotoGallerySet=0x0058,
MM_REWIND=0x0059,
MM_FASTFORWARD=0x005A,
Send=0x005B,
ControlPanel=0x005C,
UniversalScroll=0x005D,
AutoScroll=0x005E,
GenericButton=0x005F,
MM_NEXT=0x0060,
MM_PREVIOUS=0x0061,
Do_Nothing_One=0x0062, # also known as Do_Nothing
SnapLeft=0x0063,
SnapRight=0x0064,
WinMinRestore=0x0065,
WinMaxRestore=0x0066,
WinStretch=0x0067,
SwitchMonitorLeft=0x0068,
SwitchMonitorRight=0x0069,
ShowPresentation=0x006A,
ShowMobilityCenter=0x006B,
HorzScrollNoRepeatSet=0x006C,
TouchBackForwardHorzScroll=0x0077,
MetroAppSwitch=0x0078,
MetroAppBar=0x0079,
MetroCharms=0x007A,
Calculator_VKEY=0x007B, # also known as Calculator
MetroSearch=0x007C,
MetroStartScreen=0x0080,
MetroShare=0x007D,
MetroSettings=0x007E,
MetroDevices=0x007F,
MetroBackLeftHorz=0x0082,
MetroForwRightHorz=0x0083,
Win8_Back=0x0084, # also known as MetroCharms
Win8_Forward=0x0085, # also known as AppSwitchBar
Win8Charm_Appswitch_GifAnimation=0x0086,
Win8BackHorzLeft=0x008B, # also known as Back
Win8ForwardHorzRight=0x008C, # also known as BrowserForward
MetroSearch2=0x0087,
MetroShare2=0x0088,
MetroSettings2=0x008A,
MetroDevices2=0x0089,
Win8MetroWin7Forward=0x008D, # also known as MetroStartScreen
Win8ShowDesktopWin7Back=0x008E, # also known as ShowDesktop
MetroApplicationSwitch=0x0090, # also known as MetroStartScreen
ShowUI=0x0092,
MEDIA_CENTER_LOGITECH = 0x001E
MEDIA_CENTER_MICROSOFT = 0x001F
USER_MENU = 0x0020
MESSENGER = 0x0021
PERSONAL_FOLDERS = 0x0022
MY_MUSIC = 0x0023
WEBCAM = 0x0024
PICTURES_FOLDER = 0x0025
MY_VIDEOS = 0x0026
MY_COMPUTER = 0x0027
PICTURE_APP_SET = 0x0028
SEARCH = 0x0029 # also known as AdvSmartSearch
RECORD_MEDIA_PLAYER = 0x002A
BROWSER_REFRESH = 0x002B
ROTATE_RIGHT = 0x002C
SEARCH_FILES = 0x002D # SearchForFiles
MM_SHUFFLE = 0x002E
SLEEP = 0x002F # also known as StandBySet
BROWSER_STOP = 0x0030
ONE_TOUCH_SYNC = 0x0031
ZOOM_SET = 0x0032
ZOOM_BTN_IN_SET_2 = 0x0033
ZOOM_BTN_IN_SET = 0x0034
ZOOM_BTN_OUT_SET_2 = 0x0035
ZOOM_BTN_OUT_SET = 0x0036
ZOOM_BTN_RESET_SET = 0x0037
LEFT_CLICK = 0x0038 # LeftClick
RIGHT_CLICK = 0x0039 # RightClick
MOUSE_MIDDLE_BUTTON = 0x003A # from M510v2 was MiddleMouseButton
BACK = 0x003B
MOUSE_BACK_BUTTON = 0x003C # from M510v2 was BackEx
BROWSER_FORWARD = 0x003D
MOUSE_FORWARD_BUTTON = 0x003E # from M510v2 was BrowserForwardEx
MOUSE_SCROLL_LEFT_BUTTON = 0x003F # from M510v2 was HorzScrollLeftSet
MOUSE_SCROLL_RIGHT_BUTTON = 0x0040 # from M510v2 was HorzScrollRightSet
QUICK_SWITCH = 0x0041
BATTERY_STATUS = 0x0042
SHOW_DESKTOP = 0x0043 # ShowDesktop
WINDOWS_LOCK = 0x0044
FILE_LAUNCHER = 0x0045
FOLDER_LAUNCHER = 0x0046
GOTO_WEB_ADDRESS = 0x0047
GENERIC_MOUSE_BUTTON = 0x0048
KEYSTROKE_ASSIGNMENT = 0x0049
LAUNCH_PROGRAM = 0x004A
MIN_MAX_WINDOW = 0x004B
VOLUME_MUTE_NO_OSD = 0x004C
NEW = 0x004D
COPY = 0x004E
CRUISE_DOWN = 0x004F
CRUISE_UP = 0x0050
CUT = 0x0051
DO_NOTHING = 0x0052
PAGE_DOWN = 0x0053
PAGE_UP = 0x0054
PASTE = 0x0055
SEARCH_PICTURE = 0x0056
REPLY = 0x0057
PHOTO_GALLERY_SET = 0x0058
MM_REWIND = 0x0059
MM_FASTFORWARD = 0x005A
SEND = 0x005B
CONTROL_PANEL = 0x005C
UNIVERSAL_SCROLL = 0x005D
AUTO_SCROLL = 0x005E
GENERIC_BUTTON = 0x005F
MM_NEXT = 0x0060
MM_PREVIOUS = 0x0061
DO_NOTHING_ONE = 0x0062 # also known as Do_Nothing
SNAP_LEFT = 0x0063
SNAP_RIGHT = 0x0064
WIN_MIN_RESTORE = 0x0065
WIN_MAX_RESTORE = 0x0066
WIN_STRETCH = 0x0067
SWITCH_MONITOR_LEFT = 0x0068
SWITCH_MONITOR_RIGHT = 0x0069
SHOW_PRESENTATION = 0x006A
SHOW_MOBILITY_CENTER = 0x006B
HORZ_SCROLL_NO_REPEAT_SET = 0x006C
TOUCH_BACK_FORWARD_HORZ_SCROLL = 0x0077
METRO_APP_SWITCH = 0x0078
METRO_APP_BAR = 0x0079
METRO_CHARMS = 0x007A
CALCULATOR_VKEY = 0x007B # also known as Calculator
METRO_SEARCH = 0x007C
METRO_START_SCREEN = 0x0080
METRO_SHARE = 0x007D
METRO_SETTINGS = 0x007E
METRO_DEVICES = 0x007F
METRO_BACK_LEFT_HORZ = 0x0082
METRO_FORW_RIGHT_HORZ = 0x0083
WIN8_BACK = 0x0084 # also known as MetroCharms
WIN8_FORWARD = 0x0085 # also known as AppSwitchBar
WIN8_CHARM_APPSWITCH_GIF_ANIMATION = 0x0086
WIN8_BACK_HORZ_LEFT = 0x008B # also known as Back
WIN8_FORWARD_HORZ_RIGHT = 0x008C # also known as BrowserForward
METRO_SEARCH_2 = 0x0087
METROA_SHARE_2 = 0x0088
METRO_SETTINGS_2 = 0x008A
METRO_DEVICES_2 = 0x0089
WIN8_METRO_WIN7_FORWARD = 0x008D # also known as MetroStartScreen
WIN8_SHOW_DESKTOP_WIN7_BACK = 0x008E # also known as ShowDesktop
METRO_APPLICATION_SWITCH = 0x0090 # also known as MetroStartScreen
SHOW_UI = 0x0092
# https://docs.google.com/document/d/1Dpx_nWRQAZox_zpZ8SNc9nOkSDE9svjkghOCbzopabc/edit
# Extract to csv. Eliminate extra linefeeds and spaces. Turn / into __ and space into _
# awk -F, '/0x/{gsub(" \\+ ","_",$2); gsub("_-","_Down",$2); gsub("_\\+","_Up",$2);
# gsub("[()\"-]","",$2); gsub(" ","_",$2); printf("\t%s=0x%04X,\n", $2, $1)}' < tasks.csv > tasks.py
Switch_Presentation__Switch_Screen=0x0093, # on K400 Plus
Minimize_Window=0x0094,
Maximize_Window=0x0095, # on K400 Plus
MultiPlatform_App_Switch=0x0096,
MultiPlatform_Home=0x0097,
MultiPlatform_Menu=0x0098,
MultiPlatform_Back=0x0099,
Switch_Language=0x009A, # Mac_switch_language
Screen_Capture=0x009B, # Mac_screen_Capture, on Craft Keyboard
Gesture_Button=0x009C,
Smart_Shift=0x009D,
AppExpose=0x009E,
Smart_Zoom=0x009F,
Lookup=0x00A0,
Microphone_on__off=0x00A1,
Wifi_on__off=0x00A2,
Brightness_Down=0x00A3,
Brightness_Up=0x00A4,
Display_Out=0x00A5,
View_Open_Apps=0x00A6,
View_All_Open_Apps=0x00A7,
AppSwitch=0x00A8,
Gesture_Button_Navigation=0x00A9, # Mouse_Thumb_Button on MX Master
Fn_inversion=0x00AA,
Multiplatform_Back=0x00AB,
Multiplatform_Forward=0x00AC,
Multiplatform_Gesture_Button=0x00AD,
HostSwitch_Channel_1=0x00AE,
HostSwitch_Channel_2=0x00AF,
HostSwitch_Channel_3=0x00B0,
Multiplatform_Search=0x00B1,
Multiplatform_Home__Mission_Control=0x00B2,
Multiplatform_Menu__Launchpad=0x00B3,
Virtual_Gesture_Button=0x00B4,
Cursor=0x00B5,
Keyboard_Right_Arrow=0x00B6,
SW_Custom_Highlight=0x00B7,
Keyboard_Left_Arrow=0x00B8,
TBD=0x00B9,
Multiplatform_Language_Switch=0x00BA,
SW_Custom_Highlight_2=0x00BB,
Fast_Forward=0x00BC,
Fast_Backward=0x00BD,
Switch_Highlighting=0x00BE,
Mission_Control__Task_View=0x00BF, # Switch_Workspace on Craft Keyboard
Dashboard_Launchpad__Action_Center=0x00C0, # Application_Launcher on Craft Keyboard
Backlight_Down=0x00C1, # Backlight_Down_FW_internal_function
Backlight_Up=0x00C2, # Backlight_Up_FW_internal_function
Right_Click__App_Contextual_Menu=0x00C3, # Context_Menu on Craft Keyboard
DPI_Change=0x00C4,
New_Tab=0x00C5,
F2=0x00C6,
F3=0x00C7,
F4=0x00C8,
F5=0x00C9,
F6=0x00CA,
F7=0x00CB,
F8=0x00CC,
F1=0x00CD,
Laser_Button=0x00CE,
Laser_Button_Long_Press=0x00CF,
Start_Presentation=0x00D0,
Blank_Screen=0x00D1,
DPI_Switch=0x00D2, # AdjustDPI on MX Vertical
Home__Show_Desktop=0x00D3,
App_Switch__Dashboard=0x00D4,
App_Switch=0x00D5,
Fn_Inversion=0x00D6,
LeftAndRightClick=0x00D7,
Voice_Dictation=0x00D8,
Emoji_Smiling_Face_With_Heart_Shaped_Eyes=0x00D9,
Emoji_Loudly_Crying_Face=0x00DA,
Emoji_Smiley=0x00DB,
Emoji_Smiley_With_Tears=0x00DC,
Open_Emoji_Panel=0x00DD,
Multiplatform_App_Switch__Launchpad=0x00DE,
Snipping_Tool=0x00DF,
Grave_Accent=0x00E0,
Standard_Tab_Key=0x00E1,
Caps_Lock=0x00E2,
Left_Shift=0x00E3,
Left_Control=0x00E4,
Left_Option__Start=0x00E5,
Left_Command__Alt=0x00E6,
Right_Command__Alt=0x00E7,
Right_Option__Start=0x00E8,
Right_Control=0x00E9,
Right_Shift=0x0EA,
Insert=0x00EB,
Delete=0x00EC,
Home=0x00ED,
End=0x00EE,
Page_Up=0x00EF,
Page_Down=0x00F0,
Mute_Microphone=0x00F1,
Do_Not_Disturb=0x00F2,
Backslash=0x00F3,
Refresh=0x00F4,
Close_Tab=0x00F5,
Lang_Switch=0x00F6,
Standard_Alphabetical_Key=0x00F7,
Right_Option__Start__2=0x00F8,
Left_Option=0x00F9,
Right_Option=0x00FA,
Left_Cmd=0x00FB,
Right_Cmd=0x00FC,
)
TASK._fallback = lambda x: f"unknown:{x:04X}"
# Capabilities and desired software handling for a control
# Ref: https://drive.google.com/file/d/10imcbmoxTJ1N510poGdsviEhoFfB_Ua4/view
# We treat bytes 4 and 8 of `getCidInfo` as a single bitfield
KEY_FLAG = NamedInts(
analytics_key_events=0x400,
force_raw_XY=0x200,
raw_XY=0x100,
virtual=0x80,
persistently_divertable=0x40,
divertable=0x20,
reprogrammable=0x10,
FN_sensitive=0x08,
nonstandard=0x04,
is_FN=0x02,
mse=0x01,
)
# Flags describing the reporting method of a control
# We treat bytes 2 and 5 of `get/setCidReporting` as a single bitfield
MAPPING_FLAG = NamedInts(
analytics_key_events_reporting=0x100,
force_raw_XY_diverted=0x40,
raw_XY_diverted=0x10,
persistently_diverted=0x04,
diverted=0x01,
)
SWITCH_PRESENTATION_SWITCH_SCREEN = 0x0093 # on K400 Plus
MINIMIZE_WINDOW = 0x0094
MAXIMIZE_WINDOW = 0x0095 # on K400 Plus
MULTI_PLATFORM_APP_SWITCH = 0x0096
MULTI_PLATFORM_HOME = 0x0097
MULTI_PLATFORM_MENU = 0x0098
MULTI_PLATFORM_BACK = 0x0099
SWITCH_LANGUAGE = 0x009A # Mac_switch_language
SCREEN_CAPTURE = 0x009B # Mac_screen_Capture, on Craft Keyboard
GESTURE_BUTTON = 0x009C
SMART_SHIFT = 0x009D
APP_EXPOSE = 0x009E
SMART_ZOOM = 0x009F
LOOKUP = 0x00A0
MICROPHEON_ON_OFF = 0x00A1
WIFI_ON_OFF = 0x00A2
BRIGHTNESS_DOWN = 0x00A3
BRIGHTNESS_UP = 0x00A4
DISPLAY_OUT = 0x00A5
VIEW_OPEN_APPS = 0x00A6
VIEW_ALL_OPEN_APPS = 0x00A7
APP_SWITCH = 0x00A8
GESTURE_BUTTON_NAVIGATION = 0x00A9 # Mouse_Thumb_Button on MX Master
FN_INVERSION = 0x00AA
MULTI_PLATFORM_BACK_2 = 0x00AB # Alternative
MULTI_PLATFORM_FORWARD = 0x00AC
MULTI_PLATFORM_Gesture_Button = 0x00AD
HostSwitch_Channel_1 = 0x00AE
HostSwitch_Channel_2 = 0x00AF
HostSwitch_Channel_3 = 0x00B0
MULTI_PLATFORM_SEARCH = 0x00B1
MULTI_PLATFORM_HOME_MISSION_CONTROL = 0x00B2
MULTI_PLATFORM_MENU_LAUNCHPAD = 0x00B3
VIRTUAL_GESTURE_BUTTON = 0x00B4
CURSOR = 0x00B5
KEYBOARD_RIGHT_ARROW = 0x00B6
SW_CUSTOM_HIGHLIGHT = 0x00B7
KEYBOARD_LEFT_ARROW = 0x00B8
TBD = 0x00B9
MULTI_PLATFORM_Language_Switch = 0x00BA
SW_CUSTOM_HIGHLIGHT_2 = 0x00BB
FAST_FORWARD = 0x00BC
FAST_BACKWARD = 0x00BD
SWITCH_HIGHLIGHTING = 0x00BE
MISSION_CONTROL_TASK_VIEW = 0x00BF # Switch_Workspace on Craft Keyboard
DASHBOARD_LAUNCHPAD_ACTION_CENTER = 0x00C0 # Application_Launcher on Craft
# Keyboard
BACKLIGHT_DOWN = 0x00C1 # Backlight_Down_FW_internal_function
BACKLIGHT_UP = 0x00C2 # Backlight_Up_FW_internal_function
RIGHT_CLICK_APP_CONTEXT_MENU = 0x00C3 # Context_Menu on Craft Keyboard
DPI_Change = 0x00C4
NEW_TAB = 0x00C5
F2 = 0x00C6
F3 = 0x00C7
F4 = 0x00C8
F5 = 0x00C9
F6 = 0x00CA
F7 = 0x00CB
F8 = 0x00CC
F1 = 0x00CD
LASER_BUTTON = 0x00CE
LASER_BUTTON_LONG_PRESS = 0x00CF
START_PRESENTATION = 0x00D0
BLANK_SCREEN = 0x00D1
DPI_Switch = 0x00D2 # AdjustDPI on MX Vertical
HOME_SHOW_DESKTOP = 0x00D3
APP_SWITCH_DASHBOARD = 0x00D4
APP_SWITCH_2 = 0x00D5 # Alternative
FN_INVERSION_2 = 0x00D6 # Alternative
LEFT_AND_RIGHT_CLICK = 0x00D7
VOICE_DICTATION = 0x00D8
EMOJI_SMILING_FACE_WITH_HEART_SHAPED_EYES = 0x00D9
EMOJI_LOUDLY_CRYING_FACE = 0x00DA
EMOJI_SMILEY = 0x00DB
EMOJI_SMILE_WITH_TEARS = 0x00DC
OPEN_EMOJI_PANEL = 0x00DD
MULTI_PLATFORM_APP_SWITCH_LAUNCHPAD = 0x00DE
SNIPPING_TOOL = 0x00DF
GRAVE_ACCENT = 0x00E0
STANDARD_TAB_KEY = 0x00E1
CAPS_LOCK = 0x00E2
LEFT_SHIFT = 0x00E3
LEFT_CONTROL = 0x00E4
LEFT_OPTION_START = 0x00E5
LEFT_COMMAND_ALT = 0x00E6
RIGHT_COMMAND_ALT = 0x00E7
RIGHT_OPTION_START = 0x00E8
RIGHT_CONTROL = 0x00E9
RIGHT_SHIFT = 0x0EA
INSERT = 0x00EB
DELETE = 0x00EC
HOME = 0x00ED
END = 0x00EE
PAGE_UP_2 = 0x00EF # Alternative
PAGE_DOWN_2 = 0x00F0 # Alternative
MUTE_MICROPHONE = 0x00F1
DO_NOT_DISTURB = 0x00F2
BACKSLASH = 0x00F3
REFRESH = 0x00F4
CLOSE_TAB = 0x00F5
LANG_SWITCH = 0x00F6
STANDARD_ALPHABETICAL_KEY = 0x00F7
RRIGH_OPTION_START_2 = 0x00F8
LEFT_OPTION = 0x00F9
RIGHT_OPTION = 0x00FA
LEFT_CMD = 0x00FB
RIGHT_CMD = 0x00FC
def __str__(self):
return self.name.replace("_", " ").title()
class CIDGroupBit(IntEnum):
@@ -1226,11 +1220,11 @@ MOUSE_BUTTONS = NamedInts(
)
MOUSE_BUTTONS._fallback = lambda x: f"unknown mouse button:{x:04X}"
HORIZONTAL_SCROLL = NamedInts(
Horizontal_Scroll_Left=0x4000,
Horizontal_Scroll_Right=0x8000,
)
HORIZONTAL_SCROLL._fallback = lambda x: f"unknown horizontal scroll:{x:04X}"
class HorizontalScroll(IntEnum):
Left = 0x4000
Right = 0x8000
# Construct universe for Persistent Remappable Keys setting (only for supported values)
KEYS = UnsortedNamedInts()
@@ -1265,7 +1259,7 @@ for code in MOUSE_BUTTONS:
KEYS[(ACTIONID.Mouse << 24) + (int(code) << 8)] = str(code)
# Add Horizontal Scroll
for code in HORIZONTAL_SCROLL:
for code in HorizontalScroll:
KEYS[(ACTIONID.Hscroll << 24) + (int(code) << 8)] = str(code)

View File

@@ -39,7 +39,7 @@ def _create_parser():
)
subparsers = parser.add_subparsers(title="actions", help="command-line action to perform")
sp = subparsers.add_parser("show", 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,28 +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")
@@ -88,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(
@@ -96,7 +94,7 @@ 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.",
@@ -116,8 +114,7 @@ def _receivers(dev_path=None):
continue
try:
r = receiver.create_receiver(base, dev_info)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("[%s] => %s", dev_info.path, r)
logger.debug("[%s] => %s", dev_info.path, r)
if r:
yield r
except Exception as e:
@@ -135,8 +132,7 @@ def _receivers_and_devices(dev_path=None):
else:
d = receiver.create_receiver(base, dev_info)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("[%s] => %s", dev_info.path, d)
logger.debug("[%s] => %s", dev_info.path, d)
if d is not None:
yield d
except Exception as e:

View File

@@ -19,6 +19,7 @@ import yaml
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 solaar import configuration
@@ -30,9 +31,9 @@ def _print_setting(s, verbose=True):
if verbose:
if s.description:
print("#", s.description.replace("\n", " "))
if s.kind == settings.KIND.toggle:
if s.kind == settings.Kind.TOGGLE:
print("# possible values: on/true/t/yes/y/1 or off/false/f/no/n/0 or Toggle/~")
elif s.kind == settings.KIND.choice:
elif s.kind == settings.Kind.CHOICE:
print(
"# possible values: one of [",
", ".join(str(v) for v in s.choices),
@@ -53,7 +54,7 @@ def _print_setting_keyed(s, key, verbose=True):
if verbose:
if s.description:
print("#", s.description.replace("\n", " "))
if s.kind == settings.KIND.multiple_toggle:
if s.kind == settings.Kind.MULTIPLE_TOGGLE:
k = next((k for k in s._labels if key == k), None)
if k is None:
print(s.name, "=? (key not found)")
@@ -64,7 +65,7 @@ def _print_setting_keyed(s, key, verbose=True):
print(s.name, "= ? (failed to read from device)")
else:
print(s.name, s.val_to_string({k: value[str(int(k))]}))
elif s.kind == settings.KIND.map_choice:
elif s.kind == settings.Kind.MAP_CHOICE:
k = next((k for k in s.choices.keys() if key == k), None)
if k is None:
print(s.name, "=? (key not found)")
@@ -209,32 +210,33 @@ 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
def set(dev, setting, args, save):
if setting.kind == settings.KIND.toggle:
def set(dev, setting: SettingsProtocol, args, save):
if setting.kind == settings.Kind.TOGGLE:
value = select_toggle(args.value_key, setting)
args.value_key = value
message = f"Setting {setting.name} of {dev.name} to {value}"
result = setting.write(value, save=save)
elif setting.kind == settings.KIND.range:
elif setting.kind == settings.Kind.RANGE:
value = select_range(args.value_key, setting)
args.value_key = value
message = f"Setting {setting.name} of {dev.name} to {value}"
result = setting.write(value, save=save)
elif setting.kind == settings.KIND.choice:
elif setting.kind == settings.Kind.CHOICE:
value = select_choice(args.value_key, setting.choices, setting, None)
args.value_key = int(value)
message = f"Setting {setting.name} of {dev.name} to {value}"
result = setting.write(value, save=save)
elif setting.kind == settings.KIND.map_choice:
elif setting.kind == settings.Kind.MAP_CHOICE:
if args.extra_subkey is None:
_print_setting_keyed(setting, args.value_key)
return None, None, None
@@ -252,7 +254,7 @@ def set(dev, setting, args, save):
message = f"Setting {setting.name} of {dev.name} key {k!r} to {value!r}"
result = setting.write_key_value(int(k), value, save=save)
elif setting.kind == settings.KIND.multiple_toggle:
elif setting.kind == settings.Kind.MULTIPLE_TOGGLE:
if args.extra_subkey is None:
_print_setting_keyed(setting, args.value_key)
return None, None, None
@@ -271,7 +273,7 @@ def set(dev, setting, args, save):
message = f"Setting {setting.name} key {k!r} to {value!r}"
result = setting.write_key_value(str(int(k)), value, save=save)
elif setting.kind == settings.KIND.multiple_range:
elif setting.kind == settings.Kind.MULTIPLE_RANGE:
if args.extra_subkey is None:
raise Exception(f"{setting.name}: setting needs both key and value to set")
key = args.value_key
@@ -294,7 +296,25 @@ def set(dev, setting, args, save):
result = setting.write_key_value(int(k), item, save=save)
value = item
elif setting.kind == settings.Kind.MAP_RANGE:
if args.extra_subkey is None:
_print_setting_keyed(setting, args.value_key)
return None, None, None
key = int(args.value_key)
value = int(args.extra_subkey)
if key not in setting._device_object:
raise Exception(f"{setting.name}: key '{key}' not in setting")
message = f"Setting {setting.name} of {dev.name} key {key} to {value}"
result = setting.write_key_value(key, value, save=save)
elif setting.kind == settings.Kind.HETERO:
value = yaml.safe_load(args.value_key)
args.value_key = value
message = f"Setting {setting.name} of {dev.name} to {value}"
result = setting.write(value, save=save)
else:
print(f"Setting {setting.name}, with kind {setting.kind.name}, not implemented")
raise Exception("NotImplemented")
return result, message, value

View File

@@ -38,9 +38,9 @@ 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
if not (old_notification_flags & hidpp10_constants.NOTIFICATION_FLAG.wireless):
_hidpp10.set_notification_flags(receiver, old_notification_flags | hidpp10_constants.NOTIFICATION_FLAG.wireless)
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)
# get all current devices
known_devices = [dev.number for dev in receiver]
@@ -121,7 +121,7 @@ def run(receivers, args, find_receiver, _ignore):
if n:
receiver.handle.notifications_hook(n)
if not (old_notification_flags & hidpp10_constants.NOTIFICATION_FLAG.wireless):
if not (old_notification_flags & hidpp10_constants.NotificationFlag.WIRELESS):
# only clear the flags if they weren't set before, otherwise a
# concurrently running Solaar app might stop working properly
_hidpp10.set_notification_flags(receiver, old_notification_flags)

View File

@@ -45,29 +45,29 @@ def run(receivers, args, find_receiver, _ignore):
print("")
print(" Register Dump")
rgst = receiver.read_register(Registers.NOTIFICATIONS)
print(" Notifications %#04x: %s" % (Registers.NOTIFICATIONS % 0x100, "0x" + strhex(rgst) if rgst else "None"))
print(" Notifications %#04x: %s" % (Registers.NOTIFICATIONS % 0x100, f"0x{strhex(rgst)}" if rgst else "None"))
rgst = receiver.read_register(Registers.RECEIVER_CONNECTION)
print(
" Connection State %#04x: %s"
% (Registers.RECEIVER_CONNECTION % 0x100, "0x" + strhex(rgst) if rgst else "None")
% (Registers.RECEIVER_CONNECTION % 0x100, f"0x{strhex(rgst)}" if rgst else "None")
)
rgst = receiver.read_register(Registers.DEVICES_ACTIVITY)
print(
" Device Activity %#04x: %s" % (Registers.DEVICES_ACTIVITY % 0x100, "0x" + strhex(rgst) if rgst else "None")
" Device Activity %#04x: %s" % (Registers.DEVICES_ACTIVITY % 0x100, f"0x{strhex(rgst)}" if rgst else "None")
)
for sub_reg in range(0, 16):
rgst = receiver.read_register(Registers.RECEIVER_INFO, sub_reg)
print(
" Pairing Register %#04x %#04x: %s"
% (Registers.RECEIVER_INFO % 0x100, sub_reg, "0x" + strhex(rgst) if rgst else "None")
% (Registers.RECEIVER_INFO % 0x100, sub_reg, f"0x{strhex(rgst)}" if rgst else "None")
)
for device in range(0, 7):
for sub_reg in [0x10, 0x20, 0x30, 0x50]:
rgst = receiver.read_register(Registers.RECEIVER_INFO, sub_reg + device)
print(
" Pairing Register %#04x %#04x: %s"
% (Registers.RECEIVER_INFO % 0x100, sub_reg + device, "0x" + strhex(rgst) if rgst else "None")
% (Registers.RECEIVER_INFO % 0x100, sub_reg + device, f"0x{strhex(rgst)}" if rgst else "None")
)
rgst = receiver.read_register(Registers.RECEIVER_INFO, 0x40 + device)
print(
@@ -90,7 +90,7 @@ def run(receivers, args, find_receiver, _ignore):
rgst = receiver.read_register(Registers.FIRMWARE, sub_reg)
print(
" Firmware %#04x %#04x: %s"
% (Registers.FIRMWARE % 0x100, sub_reg, "0x" + strhex(rgst) if rgst is not None else "None")
% (Registers.FIRMWARE % 0x100, sub_reg, f"0x{strhex(rgst)}" if rgst is not None else "None")
)
print("")

View File

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

View File

@@ -55,8 +55,8 @@ def _print_receiver(receiver):
notification_flags = _hidpp10.get_notification_flags(receiver)
if notification_flags is not None:
if notification_flags:
notification_names = hidpp10_constants.NOTIFICATION_FLAG.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X})")
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags.value:06X})")
else:
print(" Notifications: (none)")
@@ -82,8 +82,8 @@ def _battery_line(dev):
level, nextLevel, status, voltage = battery.level, battery.next_level, battery.status, battery.voltage
text = _battery_text(level)
if voltage is not None:
text = text + f" {voltage}mV "
nextText = "" if nextLevel is None else ", next level " + _battery_text(nextLevel)
text = f"{text} {voltage}mV "
nextText = "" if nextLevel is None else f", next level {_battery_text(nextLevel)}"
print(f" Battery: {text}, {status}{nextText}.")
else:
print(" Battery status unavailable.")
@@ -131,14 +131,14 @@ def _print_device(dev, num=None):
notification_flags = _hidpp10.get_notification_flags(dev)
if notification_flags is not None:
if notification_flags:
notification_names = hidpp10_constants.NOTIFICATION_FLAG.flag_names(notification_flags)
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X}).")
else:
print(" Notifications: (none).")
device_features = _hidpp10.get_device_features(dev)
if device_features is not None:
if device_features:
device_features_names = hidpp10_constants.DEVICE_FEATURES.flag_names(device_features)
device_features_names = hidpp10_constants.DeviceFeature.flag_names(device_features)
print(f" Features: {', '.join(device_features_names)} (0x{device_features:06X})")
else:
print(" Features: (none)")
@@ -151,8 +151,8 @@ def _print_device(dev, num=None):
if isinstance(feature, str):
feature_bytes = bytes.fromhex(feature[-4:])
else:
feature_bytes = feature.to_bytes(2)
feature_int = int.from_bytes(feature_bytes)
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)
@@ -260,7 +260,8 @@ 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.val_to_string(setting.read(False))
v = setting.read(False)
v = setting.val_to_string(v)
except exceptions.FeatureCallError as e:
v = "HID++ error " + str(e)
except AssertionError as e:
@@ -277,8 +278,11 @@ def _print_device(dev, num=None):
print(" %2d: %-26s, default: %-27s => %-26s" % (k.index, k.key, k.default_task, k.mapped_to))
gmask_fmt = ",".join(k.group_mask)
gmask_fmt = gmask_fmt if gmask_fmt else "empty"
print(f" {', '.join(k.flags)}, pos:{int(k.pos)}, group:{int(k.group):1}, group mask:{gmask_fmt}")
report_fmt = ", ".join(k.mapping_flags)
flag_names = list(common.flag_names(hidpp20.KeyFlag, k.flags.value))
print(
f" {', '.join(flag_names)}, pos:{int(k.pos)}, group:{int(k.group):1}, group mask:{gmask_fmt}"
)
report_fmt = list(common.flag_names(hidpp20.MappingFlag, k.mapping_flags.value))
report_fmt = report_fmt if report_fmt else "default"
print(f" reporting: {report_fmt}")
if dev.online and dev.remap_keys:

View File

@@ -26,8 +26,8 @@ def run(receivers, args, find_receiver, find_device):
if not dev.receiver.may_unpair:
print(
"Receiver with USB id %s for %s [%s:%s] does not unpair, but attempting anyway."
% (dev.receiver.product_id, dev.name, dev.wpid, dev.serial)
f"Receiver with USB id {dev.receiver.product_id} for {dev.name} [{dev.wpid}:{dev.serial}] does not unpair,",
"but attempting anyway.",
)
try:
# query these now, it's last chance to get them

View File

@@ -62,8 +62,7 @@ def _load():
loaded_config = _convert_json(loaded_config)
else:
path = None
if logger.isEnabledFor(logging.DEBUG):
logger.debug("load => %s", loaded_config)
logger.debug("load => %s", loaded_config)
global _config
_config = _parse_config(loaded_config, path)
@@ -78,14 +77,13 @@ def _parse_config(loaded_config, config_path):
loaded_version = loaded_config[0]
discard_derived_properties = loaded_version != current_version
if discard_derived_properties:
if logger.isEnabledFor(logging.INFO):
logger.info(
"config file '%s' was generated by another version of solaar "
"(config: %s, current: %s). refreshing detected device capabilities",
config_path,
loaded_version,
current_version,
)
logger.info(
"config file '%s' was generated by another version of solaar "
"(config: %s, current: %s). refreshing detected device capabilities",
config_path,
loaded_version,
current_version,
)
for device in loaded_config[1:]:
assert isinstance(device, dict)
@@ -154,8 +152,7 @@ def do_save():
try:
with open(_yaml_file_path, "w") as config_file:
yaml.dump(_config, config_file, default_flow_style=None, width=150)
if logger.isEnabledFor(logging.INFO):
logger.info("saved %s to %s", _config, _yaml_file_path)
logger.info("saved %s to %s", _config, _yaml_file_path)
except Exception as e:
logger.error("failed to save to %s: %s", _yaml_file_path, e)
@@ -251,11 +248,9 @@ def persister(device):
break
if not entry:
if not device.online: # don't create entry for offline devices
if logger.isEnabledFor(logging.INFO):
logger.info("not setting up persister for offline device %s", device._name)
logger.info("not setting up persister for offline device %s", device._name)
return
if logger.isEnabledFor(logging.INFO):
logger.info("setting up persister for device %s", device.name)
logger.info("setting up persister for device %s", device.name)
entry = _DeviceEntry()
_config.append(entry)
entry.update(device.name, device.wpid, device.serial, modelId, unitId)

View File

@@ -0,0 +1,28 @@
import logging
class CustomLogger(logging.Logger):
"""Logger, that avoids unnecessary string computations.
Does not compute messages for disabled log levels.
"""
def debug(self, msg, *args, **kwargs):
if self.isEnabledFor(logging.DEBUG):
super().debug(msg, *args, **kwargs)
def info(self, msg, *args, **kwargs):
if self.isEnabledFor(logging.INFO):
super().info(msg, *args, **kwargs)
def warning(self, msg, *args, **kwargs):
if self.isEnabledFor(logging.WARNING):
super().warning(msg, *args, **kwargs)
def error(self, msg, *args, **kwargs):
if self.isEnabledFor(logging.ERROR):
super().error(msg, *args, **kwargs)
def critical(self, msg, *args, **kwargs):
if self.isEnabledFor(logging.CRITICAL):
super().critical(msg, *args, **kwargs)

View File

@@ -68,8 +68,7 @@ def watch_suspend_resume(
dbus_interface=_LOGIND_INTERFACE,
path=_LOGIND_PATH,
)
if logger.isEnabledFor(logging.INFO):
logger.info("connected to system dbus, watching for suspend/resume events")
logger.info("connected to system dbus, watching for suspend/resume events")
_BLUETOOTH_PATH_PREFIX = "/org/bluez/hci0/dev_"

View File

@@ -36,7 +36,9 @@ from solaar import configuration
from solaar import dbus
from solaar import listener
from solaar import ui
from solaar.custom_logger import CustomLogger
logging.setLoggerClass(CustomLogger)
logger = logging.getLogger(__name__)
@@ -54,9 +56,12 @@ tray_icon_size = None
temp = tempfile.NamedTemporaryFile(prefix="Solaar_", mode="w", delete=True)
def _parse_arguments():
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",
@@ -71,7 +76,7 @@ def _parse_arguments():
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",
@@ -99,7 +104,11 @@ def _parse_arguments():
choices=cli.actions,
help="command-line action to perform (optional); append ' --help' to show args",
)
return arg_parser
def _parse_arguments():
arg_parser = create_parser()
args = arg_parser.parse_args()
if args.help_actions:
@@ -128,9 +137,8 @@ def _parse_arguments():
logging.getLogger("").addHandler(stream_handler)
if not args.action:
if logger.isEnabledFor(logging.INFO):
language, encoding = locale.getlocale()
logger.info("version %s, language %s (%s)", __version__, language, encoding)
language, encoding = locale.getlocale()
logger.info("version %s, language %s (%s)", __version__, language, encoding)
return args
@@ -154,10 +162,15 @@ def main():
args = _parse_arguments()
if not args:
# explicit close before return
temp.close()
return
if args.action:
# if any argument, run comandline and exit
return cli.run(args.action, args.hidraw_path)
result = cli.run(args.action, args.hidraw_path)
# explicit close before return
temp.close()
return result
gi = _require("gi", "python3-gi (in Ubuntu) or python3-gobject (in Fedora)")
_require("gi.repository.Gtk", "gir1.2-gtk-3.0", gi, "Gtk", "3.0")
@@ -169,7 +182,8 @@ def main():
udev_file = "42-logitech-unify-permissions.rules"
if (
logger.isEnabledFor(logging.WARNING)
platform.system() == "Linux"
and logger.isEnabledFor(logging.WARNING)
and not os.path.isfile("/etc/udev/rules.d/" + udev_file)
and not os.path.isfile("/usr/lib/udev/rules.d/" + udev_file)
and not os.path.isfile("/usr/local/lib/udev/rules.d/" + udev_file)

View File

@@ -16,6 +16,7 @@
import gettext
import locale
import logging
import os
import sys
@@ -25,30 +26,37 @@ from solaar import NAME
_LOCALE_DOMAIN = NAME.lower()
logger = logging.getLogger(__name__)
def _find_locale_path(locale_domain: str) -> str:
prefix_share = os.path.normpath(os.path.join(os.path.realpath(sys.path[0]), ".."))
src_share = os.path.normpath(os.path.join(os.path.realpath(sys.path[0]), "..", "share"))
for location in prefix_share, src_share:
mo_files = glob(os.path.join(location, "locale", "*", "LC_MESSAGES", locale_domain + ".mo"))
mo_files = glob(os.path.join(location, "locale", "*", "LC_MESSAGES", f"{locale_domain}.mo"))
if mo_files:
return os.path.join(location, "locale")
raise FileNotFoundError(f"Could not find locale path for {locale_domain}")
def set_locale_to_system_default():
def set_locale_to_system_default() -> None:
"""Sets locale for translations to the system default.
If locale is unsupported, fallback to standard English without
translation 'C'.
Set LC_ALL environment variable to enforce a locale setting e.g.
'de_DE.UTF-8'. Run Solaar with your desired localization, for German
use:
'LC_ALL=de_DE.UTF-8 solaar'
"""
try:
locale.setlocale(locale.LC_ALL, "")
except PermissionError:
pass
locale.setlocale(locale.LC_ALL, "") # system default
except locale.Error:
logger.error("User locale not supported by system, using no translation.")
locale.setlocale(locale.LC_ALL, "C") # untranslated (English)
return
try:
path = _find_locale_path(_LOCALE_DOMAIN)

View File

@@ -15,13 +15,17 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from __future__ import annotations
import errno
import logging
import subprocess
import time
import typing
from collections import namedtuple
from functools import partial
from typing import Callable
import gi
import logitech_receiver
@@ -35,26 +39,29 @@ from logitech_receiver import notifications
from . import configuration
from . import dbus
from . import i18n
from .ui import common
if typing.TYPE_CHECKING:
from hidapi.common import DeviceInfo
gi.require_version("Gtk", "3.0") # NOQA: E402
from gi.repository import GLib # NOQA: E402 # isort:skip
if typing.TYPE_CHECKING:
from logitech_receiver.device import Device
logger = logging.getLogger(__name__)
ACTION_ADD = "add"
_GHOST_DEVICE = namedtuple("_GHOST_DEVICE", ("receiver", "number", "name", "kind", "online"))
_GHOST_DEVICE = namedtuple("_GHOST_DEVICE", ("receiver", "number", "name", "kind", "online", "path"))
_GHOST_DEVICE.__bool__ = lambda self: False
_GHOST_DEVICE.__nonzero__ = _GHOST_DEVICE.__bool__
def _ghost(device):
return _GHOST_DEVICE(
receiver=device.receiver,
number=device.number,
name=device.name,
kind=device.kind,
online=False,
receiver=device.receiver, number=device.number, name=device.name, kind=device.kind, online=False, path=None
)
@@ -68,15 +75,13 @@ class SolaarListener(listener.EventsListener):
receiver.status_callback = self._status_changed
def has_started(self):
if logger.isEnabledFor(logging.INFO):
logger.info("%s: notifications listener has started (%s)", self.receiver, self.receiver.handle)
logger.info("%s: notifications listener has started (%s)", self.receiver, self.receiver.handle)
nfs = self.receiver.enable_connection_notifications()
if logger.isEnabledFor(logging.WARNING):
if not self.receiver.isDevice and not ((nfs if nfs else 0) & hidpp10_constants.NOTIFICATION_FLAG.wireless):
logger.warning(
"Receiver on %s might not support connection notifications, GUI might not show its devices",
self.receiver.path,
)
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,
)
self.receiver.notification_flags = nfs
self.receiver.notify_devices()
self._status_changed(self.receiver)
@@ -84,8 +89,7 @@ class SolaarListener(listener.EventsListener):
def has_stopped(self):
r, self.receiver = self.receiver, None
assert r is not None
if logger.isEnabledFor(logging.INFO):
logger.info("%s: notifications listener has stopped", r)
logger.info("%s: notifications listener has stopped", r)
# because udev is not notifying us about device removal, make sure to clean up in _all_listeners
_all_listeners.pop(r.path, None)
@@ -133,8 +137,7 @@ class SolaarListener(listener.EventsListener):
if not device:
# Device was unpaired, and isn't valid anymore.
# We replace it with a ghost so that the UI has something to work with while cleaning up.
if logger.isEnabledFor(logging.INFO):
logger.info("device %s was unpaired, ghosting", device)
logger.info("device %s was unpaired, ghosting", device)
device = _ghost(device)
self.status_changed_callback(device, alert, reason)
@@ -152,20 +155,17 @@ class SolaarListener(listener.EventsListener):
# a notification that came in to the device listener - strange, but nothing needs to be done here
if self.receiver.isDevice:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Notification %s via device %s being ignored.", n, self.receiver)
logger.debug("Notification %s via device %s being ignored.", n, self.receiver)
return
# DJ pairing notification - ignore - hid++ 1.0 pairing notification is all that is needed
if n.sub_id == 0x41 and n.report_id == base.DJ_MESSAGE_ID:
if logger.isEnabledFor(logging.INFO):
logger.info("ignoring DJ pairing notification %s", n)
logger.info("ignoring DJ pairing notification %s", n)
return
# a device notification
if not (0 < n.devnumber <= 16): # some receivers have devices past their max # devices
if logger.isEnabledFor(logging.WARNING):
logger.warning("Unexpected device number (%s) in notification %s.", n.devnumber, n)
logger.warning("Unexpected device number (%s) in notification %s.", n.devnumber, n)
return
already_known = n.devnumber in self.receiver
@@ -187,7 +187,7 @@ class SolaarListener(listener.EventsListener):
if (
self.receiver.read_register(
hidpp10_constants.Registers.RECEIVER_INFO,
hidpp10_constants.INFO_SUBREGISTERS.pairing_information + n.devnumber - 1,
hidpp10_constants.InfoSubRegisters.PAIRING_INFORMATION + n.devnumber - 1,
)
is None
):
@@ -210,8 +210,7 @@ class SolaarListener(listener.EventsListener):
# Apply settings every time the device connects
if n.sub_id == 0x41:
if logger.isEnabledFor(logging.INFO):
logger.info("connection %s for device wpid %s kind %s serial %s", n, dev.wpid, dev.kind, dev._serial)
logger.info("connection %s for device wpid %s kind %s serial %s", n, dev.wpid, dev.kind, dev._serial)
# If there are saved configs, bring the device's settings up-to-date.
# They will be applied when the device is marked as online.
configuration.attach_to(dev)
@@ -223,10 +222,8 @@ class SolaarListener(listener.EventsListener):
if self.receiver.pairing.lock_open and not already_known:
# this should be the first notification after a device was paired
if logger.isEnabledFor(logging.WARNING):
logger.warning("first notification was not a connection notification")
if logger.isEnabledFor(logging.INFO):
logger.info("%s: pairing detected new device", self.receiver)
logger.warning("first notification was not a connection notification")
logger.info("%s: pairing detected new device", self.receiver)
self.receiver.pairing.new_device = dev
elif dev.online is None:
dev.ping()
@@ -235,36 +232,33 @@ class SolaarListener(listener.EventsListener):
return f"<SolaarListener({self.receiver.path},{self.receiver.handle})>"
def _process_bluez_dbus(device, path, dictionary, signature):
def _process_bluez_dbus(device: Device, path, dictionary: dict, signature):
"""Process bluez dbus property changed signals for device status
changes to discover disconnections and connections.
"""
if device:
if dictionary.get("Connected") is not None:
connected = dictionary.get("Connected")
if logger.isEnabledFor(logging.INFO):
logger.info("bluez dbus for %s: %s", device, "CONNECTED" if connected else "DISCONNECTED")
logger.info("bluez dbus for %s: %s", device, "CONNECTED" if connected else "DISCONNECTED")
device.changed(connected, reason=i18n._("connected") if connected else i18n._("disconnected"))
elif device is not None:
if logger.isEnabledFor(logging.INFO):
logger.info("bluez cleanup for %s", device)
logger.info("bluez cleanup for %s", device)
_cleanup_bluez_dbus(device)
def _cleanup_bluez_dbus(device):
def _cleanup_bluez_dbus(device: Device):
"""Remove dbus signal receiver for device"""
if logger.isEnabledFor(logging.INFO):
logger.info("bluez cleanup for %s", device)
logger.info("bluez cleanup for %s", device)
dbus.watch_bluez_connect(device.hid_serial, None)
_all_listeners = {} # all known receiver listeners, listeners that stop on their own may remain here
def _start(device_info):
def _start(device_info: DeviceInfo):
assert _status_callback and _setting_callback
isDevice = device_info.isDevice
if not isDevice:
if not device_info.isDevice:
receiver_ = logitech_receiver.receiver.create_receiver(base, device_info, _setting_callback)
else:
receiver_ = logitech_receiver.device.create_device(base, device_info, _setting_callback)
@@ -285,8 +279,7 @@ def _start(device_info):
def start_all():
stop_all() # just in case this it called twice in a row...
if logger.isEnabledFor(logging.INFO):
logger.info("starting receiver listening threads")
logger.info("starting receiver listening threads")
for device_info in base.receivers_and_devices():
_process_receiver_event(ACTION_ADD, device_info)
@@ -295,8 +288,7 @@ def stop_all():
listeners = list(_all_listeners.values())
_all_listeners.clear()
if listeners:
if logger.isEnabledFor(logging.INFO):
logger.info("stopping receiver listening threads %s", listeners)
logger.info("stopping receiver listening threads %s", listeners)
for listener_thread in listeners:
listener_thread.stop()
configuration.save()
@@ -308,8 +300,7 @@ def stop_all():
# after a resume, the device may have been off so mark its saved status to ensure
# that the status is pushed to the device when it comes back
def ping_all(resuming=False):
if logger.isEnabledFor(logging.INFO):
logger.info("ping all devices%s", " when resuming" if resuming else "")
logger.info("ping all devices%s", " when resuming" if resuming else "")
for listener_thread in _all_listeners.values():
if listener_thread.receiver.isDevice:
if resuming:
@@ -336,7 +327,7 @@ _setting_callback = None # GUI callback to change UI in response to changes to
_error_callback = None # GUI callback to report errors
def setup_scanner(status_changed_callback, setting_changed_callback, error_callback):
def setup_scanner(status_changed_callback: Callable, setting_changed_callback: Callable, error_callback: Callable):
global _status_callback, _error_callback, _setting_callback
assert _status_callback is None, "scanner was already set-up"
_status_callback = status_changed_callback
@@ -345,25 +336,24 @@ def setup_scanner(status_changed_callback, setting_changed_callback, error_callb
base.notify_on_receivers_glib(GLib, _process_receiver_event)
def _process_add(device_info, retry):
def _process_add(device_info: DeviceInfo, retry):
try:
_start(device_info)
except OSError as e:
if e.errno == errno.EACCES:
try:
output = subprocess.check_output(["/usr/bin/getfacl", "-p", device_info.path], text=True)
if logger.isEnabledFor(logging.WARNING):
logger.warning("Missing permissions on %s\n%s.", device_info.path, output)
output = subprocess.check_output(["getfacl", "-p", device_info.path], text=True)
logger.warning("Missing permissions on %s\n%s.", device_info.path, output)
except Exception:
pass
if retry:
GLib.timeout_add(2000.0, _process_add, device_info, retry - 1)
else:
_error_callback("permissions", device_info.path)
_error_callback(common.ErrorReason.PERMISSIONS, device_info.path)
else:
_error_callback("nodevice", device_info.path)
_error_callback(common.ErrorReason.NO_DEVICE, device_info.path)
except exceptions.NoReceiver:
_error_callback("nodevice", device_info.path)
_error_callback(common.ErrorReason.NO_DEVICE, device_info.path)
# receiver add/remove events will start/stop listener threads
@@ -371,8 +361,7 @@ def _process_receiver_event(action, device_info):
assert action is not None
assert device_info is not None
assert _error_callback
if logger.isEnabledFor(logging.INFO):
logger.info("receiver event %s %s", action, device_info)
logger.info("receiver event %s %s", action, device_info)
# whatever the action, stop any previous receivers at this path
listener_thread = _all_listeners.pop(device_info.path, None)
if listener_thread is not None:

View File

@@ -46,8 +46,7 @@ class TaskRunner(Thread):
def run(self):
self.alive = True
if logger.isEnabledFor(logging.DEBUG):
logger.debug("started")
logger.debug("started")
while self.alive:
task = self.queue.get()
@@ -59,5 +58,4 @@ class TaskRunner(Thread):
except Exception:
logger.exception("calling %s", function)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("stopped")
logger.debug("stopped")

View File

@@ -17,6 +17,7 @@
import logging
from enum import Enum
from typing import Callable
import gi
@@ -48,9 +49,14 @@ assert Gtk.get_major_version() > 2, "Solaar requires Gtk 3 python bindings"
APP_ID = "io.github.pwr_solaar.solaar"
class GtkSignal(Enum):
ACTIVATE = "activate"
COMMAND_LINE = "command-line"
SHUTDOWN = "shutdown"
def _startup(app, startup_hook, use_tray, show_window):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("startup registered=%s, remote=%s", app.get_is_registered(), app.get_is_remote())
logger.debug("startup registered=%s, remote=%s", app.get_is_registered(), app.get_is_remote())
common.start_async()
desktop_notifications.init()
if use_tray:
@@ -60,8 +66,7 @@ def _startup(app, startup_hook, use_tray, show_window):
def _activate(app):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("activate")
logger.debug("activate")
if app.get_windows():
window.popup()
else:
@@ -74,8 +79,7 @@ def _command_line(app, command_line):
if not args:
_activate(app)
elif args[0] == "config": # config call from remote instance
if logger.isEnabledFor(logging.INFO):
logger.info("remote command line %s", args)
logger.info("remote command line %s", args)
dev = find_device(args[1])
if dev:
setting = next((s for s in dev.settings if s.name == args[2]), None)
@@ -85,8 +89,7 @@ def _command_line(app, command_line):
def _shutdown(_app, shutdown_hook):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("shutdown")
logger.debug("shutdown")
shutdown_hook()
common.stop_async()
tray.destroy()
@@ -108,9 +111,9 @@ def run_loop(
lambda app, startup_hook: _startup(app, startup_hook, use_tray, show_window),
startup_hook,
)
application.connect("command-line", _command_line)
application.connect("activate", _activate)
application.connect("shutdown", _shutdown, shutdown_hook)
application.connect(GtkSignal.COMMAND_LINE.value, _command_line)
application.connect(GtkSignal.ACTIVATE.value, _activate)
application.connect(GtkSignal.SHUTDOWN.value, _shutdown, shutdown_hook)
application.register()
if application.get_is_remote():
@@ -120,8 +123,7 @@ def run_loop(
def _status_changed(device, alert, reason, refresh=False):
assert device is not None
if logger.isEnabledFor(logging.DEBUG):
logger.debug("status changed: %s (%s) %s", device, alert, reason)
logger.debug("status changed: %s (%s) %s", device, alert, reason)
if alert is None:
alert = Alert.NONE

View File

@@ -13,8 +13,7 @@
## 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 enum import Enum
from typing import List
from typing import Tuple
from typing import Union
@@ -24,6 +23,10 @@ from gi.repository import Gtk
from solaar import NAME
class GtkSignal(Enum):
RESPONSE = "response"
class AboutView:
def __init__(self) -> None:
self.view: Union[Gtk.AboutDialog, None] = None
@@ -31,10 +34,10 @@ class AboutView:
def init_ui(self) -> None:
self.view = Gtk.AboutDialog()
self.view.set_program_name(NAME)
self.view.set_icon_name(NAME.lower())
self.view.set_logo_icon_name(NAME.lower())
self.view.set_license_type(Gtk.License.GPL_2_0)
self.view.connect("response", lambda x, y: self.handle_close(x))
self.view.connect(GtkSignal.RESPONSE.value, lambda x, y: self.handle_close(x))
def update_version_info(self, version: str) -> None:
self.view.set_version(version)

View File

@@ -14,14 +14,19 @@
## 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 enum import Enum
from gi.repository import Gdk
from gi.repository import Gtk
from solaar.i18n import _
from solaar.ui import common
from . import pair_window
from .common import error_dialog
class GtkSignal(Enum):
ACTIVATE = "activate"
def make_image_menu_item(label, icon_name, function, *args):
@@ -33,7 +38,7 @@ def make_image_menu_item(label, icon_name, function, *args):
menu_item = Gtk.MenuItem()
menu_item.add(box)
menu_item.show_all()
menu_item.connect("activate", function, *args)
menu_item.connect(GtkSignal.ACTIVATE.value, function, *args)
menu_item.label = label
menu_item.icon = icon
return menu_item
@@ -45,7 +50,7 @@ def make(name, label, function, stock_id=None, *args):
if stock_id is not None:
action.set_stock_id(stock_id)
if function:
action.connect("activate", function, *args)
action.connect(GtkSignal.ACTIVATE.value, function, *args)
return action
@@ -54,7 +59,7 @@ def make_toggle(name, label, function, stock_id=None, *args):
action.set_icon_name(name)
if stock_id is not None:
action.set_stock_id(stock_id)
action.connect("activate", function, *args)
action.connect(GtkSignal.ACTIVATE.value, function, *args)
return action
@@ -95,4 +100,4 @@ def unpair(window, device):
try:
del receiver[device_number]
except Exception:
error_dialog("unpair", device)
common.error_dialog(common.ErrorReason.UNPAIR, device)

View File

@@ -16,6 +16,7 @@
import logging
from enum import Enum
from typing import Tuple
import gi
@@ -30,22 +31,28 @@ from gi.repository import Gtk # NOQA: E402
logger = logging.getLogger(__name__)
def _create_error_text(reason: str, object_) -> Tuple[str, str]:
if reason == "permissions":
class ErrorReason(Enum):
PERMISSIONS = "Permissions"
NO_DEVICE = "No device"
UNPAIR = "Unpair"
def _create_error_text(reason: ErrorReason, object_) -> Tuple[str, str]:
if reason == ErrorReason.PERMISSIONS:
title = _("Permissions error")
text = (
_("Found a Logitech receiver or device (%s), but did not have permission to open it.") % object_
+ "\n\n"
+ _("If you've just installed Solaar, try disconnecting the receiver or device and then reconnecting it.")
)
elif reason == "nodevice":
elif reason == ErrorReason.NO_DEVICE:
title = _("Cannot connect to device error")
text = (
_("Found a Logitech receiver or device at %s, but encountered an error connecting to it.") % object_
+ "\n\n"
+ _("Try disconnecting the device and then reconnecting it or turning it off and then on.")
)
elif reason == "unpair":
elif reason == ErrorReason.UNPAIR:
title = _("Unpairing failed")
text = (
_("Failed to unpair %{device} from %{receiver}.").format(
@@ -56,11 +63,11 @@ def _create_error_text(reason: str, object_) -> Tuple[str, str]:
+ _("The receiver returned an error, with no further details.")
)
else:
raise Exception("ui.error_dialog: don't know how to handle (%s, %s)", reason, object_)
raise Exception("ui.error_dialog: don't know how to handle (%s, %s)", reason.name, object_)
return title, text
def _error_dialog(reason: str, object_):
def _error_dialog(reason: ErrorReason, object_):
logger.error("error: %s %s", reason, object_)
title, text = _create_error_text(reason, object_)
@@ -70,8 +77,7 @@ def _error_dialog(reason: str, object_):
m.destroy()
def error_dialog(reason, object_):
assert reason is not None
def error_dialog(reason: ErrorReason, object_):
GLib.idle_add(_error_dialog, reason, object_)
@@ -91,5 +97,6 @@ def stop_async():
def ui_async(function, *args, **kwargs):
"""Runs a function asynchronously."""
if _task_runner:
_task_runner(function, *args, **kwargs)

View File

@@ -16,8 +16,8 @@
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import logging
import traceback
from enum import Enum
from threading import Timer
import gi
@@ -38,14 +38,24 @@ from gi.repository import Gtk # NOQA: E402
logger = logging.getLogger(__name__)
class GtkSignal(Enum):
ACTIVATE = "activate"
CHANGED = "changed"
CLICKED = "clicked"
MATCH_SELECTED = "match_selected"
NOTIFY_ACTIVE = "notify::active"
TOGGLED = "toggled"
VALUE_CHANGED = "value-changed"
COLOR_SET = "color-set"
def _read_async(setting, force_read, sbox, device_is_online, sensitive):
def _do_read(s, force, sb, online, sensitive):
try:
v = s.read(not force)
except Exception as e:
v = None
if logger.isEnabledFor(logging.WARNING):
logger.warning("%s: error reading so use None (%s): %s", s.name, s._device, repr(e))
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)
ui_async(_do_read, setting, force_read, sbox, device_is_online, sensitive)
@@ -60,7 +70,6 @@ def _write_async(setting, value, sbox, sensitive=True, key=None):
v = setting.write_key_value(key, v)
v = {key: v}
except Exception:
traceback.print_exc()
v = None
if sb:
GLib.idle_add(_update_setting_item, sb, v, True, sensitive, priority=99)
@@ -105,7 +114,7 @@ class Control:
def layout(self, sbox, label, change, spinner, failed):
sbox.pack_start(label, False, False, 0)
sbox.pack_end(change, False, False, 0)
fill = sbox.setting.kind == settings.KIND.range or sbox.setting.kind == settings.KIND.hetero
fill = sbox.setting.kind == settings.Kind.RANGE or sbox.setting.kind == settings.Kind.HETERO
sbox.pack_end(self, fill, fill, 0)
sbox.pack_end(spinner, False, False, 0)
sbox.pack_end(failed, False, False, 0)
@@ -116,7 +125,7 @@ class ToggleControl(Gtk.Switch, Control):
def __init__(self, sbox, delegate=None):
super().__init__(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
self.init(sbox, delegate)
self.connect("notify::active", self.changed)
self.connect(GtkSignal.NOTIFY_ACTIVE.value, self.changed)
def set_value(self, value):
if value is not None:
@@ -135,7 +144,12 @@ class SliderControl(Gtk.Scale, Control):
self.set_round_digits(0)
self.set_digits(0)
self.set_increments(1, 5)
self.connect("value-changed", self.changed)
self.connect(GtkSignal.VALUE_CHANGED.value, self.changed)
def set_value(self, value):
if isinstance(value, dict):
value = next(iter(value.values()))
return super().set_value(value)
def get_value(self):
return int(super().get_value())
@@ -167,7 +181,7 @@ class ChoiceControlLittle(Gtk.ComboBoxText, Control):
self.choices = choices if choices is not None else sbox.setting.choices
for entry in self.choices:
self.append(str(int(entry)), str(entry))
self.connect("changed", self.changed)
self.connect(GtkSignal.CHANGED.value, self.changed)
def get_value(self):
return int(self.get_active_id()) if self.get_active_id() is not None else None
@@ -205,9 +219,9 @@ class ChoiceControlBig(Gtk.Entry, Control):
completion.set_match_func(lambda completion, key, it: norm(key) in norm(completion.get_model()[it][1]))
completion.set_text_column(1)
self.set_completion(completion)
self.connect("changed", self.changed)
self.connect("activate", self.activate)
completion.connect("match_selected", self.select)
self.connect(GtkSignal.CHANGED.value, self.changed)
self.connect(GtkSignal.ACTIVATE.value, self.activate)
completion.connect(GtkSignal.MATCH_SELECTED.value, self.select)
def get_value(self):
choice = self.get_choice()
@@ -221,6 +235,9 @@ class ChoiceControlBig(Gtk.Entry, Control):
key = self.get_text()
return next((x for x in self.choices if x == key), None)
def set_choices(self, choices):
self.choices = choices
def changed(self, *args):
self.value = self.get_choice()
icon = "dialog-warning" if self.value is None else "dialog-question" if self.get_sensitive() else ""
@@ -253,7 +270,7 @@ class MapChoiceControl(Gtk.HBox, Control):
self.valueBox = _create_choice_control(sbox.setting, choices=self.value_choices, delegate=self)
self.pack_start(self.keyBox, False, False, 0)
self.pack_end(self.valueBox, False, False, 0)
self.keyBox.connect("changed", self.map_value_notify_key)
self.keyBox.connect(GtkSignal.CHANGED.value, self.map_value_notify_key)
def get_value(self):
key_choice = int(self.keyBox.get_active_id())
@@ -273,7 +290,6 @@ class MapChoiceControl(Gtk.HBox, Control):
choices = self.sbox.setting.choices[key_choice]
if choices != self.value_choices:
self.value_choices = choices
self.valueBox.remove_all()
self.valueBox.set_choices(choices)
current = self.sbox.setting._value.get(key_choice) if self.sbox.setting._value else None
if current is not None:
@@ -301,7 +317,7 @@ class MultipleControl(Gtk.ListBox, Control):
self._showing = True
self.setup(sbox.setting) # set up the data and boxes for the sub-controls
btn = Gtk.Button(label=button_label)
btn.connect("clicked", self.toggle_display)
btn.connect(GtkSignal.CLICKED.value, self.toggle_display)
self._button = btn
hbox = Gtk.HBox(homogeneous=False, spacing=6)
hbox.pack_end(change, False, False, 0)
@@ -349,7 +365,7 @@ class MultipleToggleControl(MultipleControl):
h.set_tooltip_text(lbl_tooltip or " ")
control = Gtk.Switch()
control._setting_key = int(k)
control.connect("notify::active", self.toggle_notify)
control.connect(GtkSignal.NOTIFY_ACTIVE.value, self.toggle_notify)
h.pack_start(lbl, False, False, 0)
h.pack_end(control, False, False, 0)
lbl.set_margin_start(30)
@@ -376,7 +392,7 @@ class MultipleToggleControl(MultipleControl):
elem.set_state(v)
if elem.get_state():
active += 1
to_join.append(lbl.get_text() + ": " + str(elem.get_state()))
to_join.append(f"{lbl.get_text()}: {str(elem.get_state())}")
b = ", ".join(to_join)
self._button.set_label(f"{active} / {total}")
self._button.set_tooltip_text(b)
@@ -426,7 +442,7 @@ class MultipleRangeControl(MultipleControl):
h.pack_end(control, False, False, 0)
else:
raise NotImplementedError
control.connect("value-changed", self.changed, item, sub_item)
control.connect(GtkSignal.VALUE_CHANGED.value, self.changed, item, sub_item)
item_lb.add(h)
h._setting_sub_item = sub_item
h._label, h._control = sub_item_lbl, control
@@ -460,7 +476,7 @@ class MultipleRangeControl(MultipleControl):
item = ch._setting_item
v = value.get(int(item), None)
if v is not None:
b += str(item) + ": ("
b += f"{str(item)}: ("
to_join = []
for c in ch._sub_items:
sub_item = c._setting_sub_item
@@ -470,7 +486,7 @@ class MultipleRangeControl(MultipleControl):
sub_item_value = c._control.get_value()
c._control.set_value(sub_item_value)
n += 1
to_join.append(str(sub_item) + f"={sub_item_value}")
to_join.append(f"{str(sub_item)}={sub_item_value}")
b += ", ".join(to_join) + ") "
lbl_text = ngettext("%d value", "%d values", n) % n
self._button.set_label(lbl_text)
@@ -487,7 +503,7 @@ class PackedRangeControl(MultipleRangeControl):
control = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, validator.min_value, validator.max_value, 1)
control.set_round_digits(0)
control.set_digits(0)
control.connect("value-changed", self.changed, validator.keys[item])
control.connect(GtkSignal.VALUE_CHANGED.value, self.changed, validator.keys[item])
h.pack_start(lbl, False, False, 0)
h.pack_end(control, True, True, 0)
h._setting_item = validator.keys[item]
@@ -523,7 +539,7 @@ class PackedRangeControl(MultipleRangeControl):
h.control.set_value(v)
else:
v = self.sbox.setting._value[int(item)]
b += str(item) + ": (" + str(v) + ") "
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)
@@ -544,19 +560,23 @@ class HeteroKeyControl(Gtk.HBox, Control):
item_lblbox = None
item_box = ComboBoxText()
if item["kind"] == settings.KIND.choice:
if item["kind"] == settings.Kind.CHOICE:
for entry in item["choices"]:
item_box.append(str(int(entry)), str(entry))
item_box.set_active(0)
item_box.connect("changed", self.changed)
item_box.connect(GtkSignal.CHANGED.value, self.changed)
self.pack_start(item_box, False, False, 0)
elif item["kind"] == settings.KIND.range:
elif item["kind"] == settings.Kind.COLOR:
item_box = Gtk.ColorButton()
item_box.connect(GtkSignal.COLOR_SET.value, self.changed)
self.pack_start(item_box, False, False, 0)
elif item["kind"] == settings.Kind.RANGE:
item_box = Scale()
item_box.set_range(item["min"], item["max"])
item_box.set_round_digits(0)
item_box.set_digits(0)
item_box.set_increments(1, 5)
item_box.connect("value-changed", self.changed)
item_box.connect(GtkSignal.VALUE_CHANGED.value, self.changed)
self.pack_start(item_box, True, True, 0)
item_box.set_visible(False)
self._items[str(item["name"])] = (item_lblbox, item_box)
@@ -564,7 +584,14 @@ class HeteroKeyControl(Gtk.HBox, Control):
def get_value(self):
result = {}
for k, (_lblbox, box) in self._items.items():
result[str(k)] = box.get_value()
if isinstance(box, Gtk.ColorButton):
rgba = box.get_rgba()
r = int(rgba.red * 255)
g = int(rgba.green * 255)
b = int(rgba.blue * 255)
result[str(k)] = (r << 16) | (g << 8) | b
else:
result[str(k)] = box.get_value()
result = hidpp20.LEDEffectSetting(**result)
return result
@@ -574,7 +601,13 @@ class HeteroKeyControl(Gtk.HBox, Control):
for k, v in value.__dict__.items():
if k in self._items:
(lblbox, box) = self._items[k]
box.set_value(v)
if isinstance(box, Gtk.ColorButton):
rgba = Gdk.RGBA()
color_string = f"#{v:06X}" # e.g. "#FF0000"
rgba.parse(color_string)
box.set_rgba(rgba)
else:
box.set_value(v)
else:
self.sbox._failed.set_visible(True)
self.setup_visibles(value.ID if value is not None else 0)
@@ -643,6 +676,8 @@ def _change_icon(allowed, icon):
def _create_sbox(s, _device):
if not s.display:
return
sbox = Gtk.HBox(homogeneous=False, spacing=6)
sbox.setting = s
sbox.kind = s.kind
@@ -664,27 +699,26 @@ def _create_sbox(s, _device):
change.set_relief(Gtk.ReliefStyle.NONE)
change.add(change_icon)
change.set_sensitive(True)
change.connect("clicked", _change_click, sbox)
change.connect(GtkSignal.CLICKED.value, _change_click, sbox)
if s.kind == settings.KIND.toggle:
if s.kind == settings.Kind.TOGGLE:
control = ToggleControl(sbox)
elif s.kind == settings.KIND.range:
elif s.kind == settings.Kind.RANGE:
control = SliderControl(sbox)
elif s.kind == settings.KIND.choice:
elif s.kind == settings.Kind.CHOICE:
control = _create_choice_control(sbox)
elif s.kind == settings.KIND.map_choice:
elif s.kind == settings.Kind.MAP_CHOICE:
control = MapChoiceControl(sbox)
elif s.kind == settings.KIND.multiple_toggle:
elif s.kind == settings.Kind.MULTIPLE_TOGGLE:
control = MultipleToggleControl(sbox, change)
elif s.kind == settings.KIND.multiple_range:
elif s.kind == settings.Kind.MULTIPLE_RANGE:
control = MultipleRangeControl(sbox, change)
elif s.kind == settings.KIND.packed_range:
elif s.kind == settings.Kind.PACKED_RANGE:
control = PackedRangeControl(sbox, change)
elif s.kind == settings.KIND.hetero:
elif s.kind == settings.Kind.HETERO:
control = HeteroKeyControl(sbox, change)
else:
if logger.isEnabledFor(logging.WARNING):
logger.warning("setting %s display not implemented", s.label)
logger.warning("setting %s display not implemented", s.label)
return None
control.set_sensitive(False) # the first read will enable it
@@ -706,7 +740,10 @@ def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=
return
sbox._failed.set_visible(False)
sbox._control.set_sensitive(False)
sbox._control.set_value(value)
try: # a call was producing a TypeError so guard against that
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)
_change_icon(sensitive, sbox._change_icon)
@@ -810,10 +847,9 @@ def record_setting(device, setting, values):
def _record_setting(device, setting_class, values):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("on %s changing setting %s to %s", device, setting_class.name, values)
logger.debug("on %s changing setting %s to %s", device, setting_class.name, values)
setting = next((s for s in device.settings if s.name == setting_class.name), None)
if setting is None and logger.isEnabledFor(logging.DEBUG):
if setting is None:
logger.debug(
"No setting for %s found on %s when trying to record a change made elsewhere",
setting_class.name,

View File

@@ -58,8 +58,7 @@ if available:
global available
if available:
if not Notify.is_initted():
if logger.isEnabledFor(logging.INFO):
logger.info("starting desktop notifications")
logger.info("starting desktop notifications")
try:
return Notify.init(NAME.lower())
except Exception:
@@ -70,8 +69,7 @@ if available:
def uninit():
"""Stop desktop notifications."""
if available and Notify.is_initted():
if logger.isEnabledFor(logging.INFO):
logger.info("stopping desktop notifications")
logger.info("stopping desktop notifications")
_notifications.clear()
Notify.uninit()
@@ -124,7 +122,7 @@ if available:
n.set_hint("value", GLib.Variant("i", progress))
try:
n.show()
return n.show()
except Exception:
logger.exception(f"showing {n}")

View File

@@ -35,11 +35,11 @@ from typing import Optional
from gi.repository import Gdk
from gi.repository import GObject
from gi.repository import Gtk
from logitech_receiver import diversion as _DIV
from logitech_receiver import diversion
from logitech_receiver.common import NamedInt
from logitech_receiver.common import NamedInts
from logitech_receiver.common import UnsortedNamedInts
from logitech_receiver.settings import KIND as _SKIND
from logitech_receiver.settings import Kind
from logitech_receiver.settings import Setting
from logitech_receiver.settings_templates import SETTINGS
@@ -56,6 +56,40 @@ _diversion_dialog = None
_rule_component_clipboard = None
class GtkSignal(Enum):
ACTIVATE = "activate"
BUTTON_RELEASE_EVENT = "button-release-event"
CHANGED = "changed"
CLICKED = "clicked"
DELETE_EVENT = "delete-event"
KEY_PRESS_EVENT = "key-press-event"
NOTIFY_ACTIVE = "notify::active"
TOGGLED = "toggled"
VALUE_CHANGED = "value_changed"
def create_all_settings(all_settings: list[Setting]) -> dict[str, list[Setting]]:
settings = {}
for s in sorted(all_settings, key=lambda setting: setting.label):
if s.name not in settings:
settings[s.name] = [s]
else:
prev_setting = settings[s.name][0]
prev_kind = prev_setting.validator_class.kind
if prev_kind != s.validator_class.kind:
logger.warning(
"ignoring setting {} - same name of {}, but different kind ({} != {})".format(
s.__name__, prev_setting.__name__, prev_kind, s.validator_class.kind
)
)
continue
settings[s.name].append(s)
return settings
ALL_SETTINGS = create_all_settings(SETTINGS)
class RuleComponentWrapper(GObject.GObject):
def __init__(self, component, level=0, editable=False):
self.component = component
@@ -64,7 +98,7 @@ class RuleComponentWrapper(GObject.GObject):
GObject.GObject.__init__(self)
def display_left(self):
if isinstance(self.component, _DIV.Rule):
if isinstance(self.component, diversion.Rule):
if self.level == 0:
return _("Built-in rules") if not self.editable else _("User-defined rules")
if self.level == 1:
@@ -82,7 +116,7 @@ class RuleComponentWrapper(GObject.GObject):
def display_icon(self):
if self.component is None:
return ""
if isinstance(self.component, _DIV.Rule) and self.level == 0:
if isinstance(self.component, diversion.Rule) and self.level == 0:
return "emblem-system" if not self.editable else "avatar-default"
return self.__component_ui().icon_name()
@@ -143,17 +177,17 @@ def _populate_model(
return
if editable is None:
editable = model[it][0].editable if it is not None else False
if isinstance(rule_component, _DIV.Rule):
if isinstance(rule_component, diversion.Rule):
editable = editable or (rule_component.source is not None)
wrapped = RuleComponentWrapper(rule_component, level, editable=editable)
piter = model.insert(it, pos, (wrapped,))
if isinstance(rule_component, (_DIV.Rule, _DIV.And, _DIV.Or, _DIV.Later)):
if isinstance(rule_component, (diversion.Rule, diversion.And, diversion.Or, diversion.Later)):
for c in rule_component.components:
ed = editable or (isinstance(c, _DIV.Rule) and c.source is not None)
ed = editable or (isinstance(c, diversion.Rule) and c.source is not None)
_populate_model(model, piter, c, level + 1, editable=ed)
if len(rule_component.components) == 0:
_populate_model(model, piter, None, level + 1, editable=editable)
elif isinstance(rule_component, _DIV.Not):
elif isinstance(rule_component, diversion.Not):
_populate_model(model, piter, rule_component.component, level + 1, editable=editable)
@@ -177,13 +211,13 @@ def allowed_actions(m: Gtk.TreeStore, it: Gtk.TreeIter) -> AllowedActions:
parent_c = m[parent_it][0].component if wrapped.level > 0 else None
can_wrap = wrapped.editable and wrapped.component is not None and wrapped.level >= 2
can_delete = wrapped.editable and not isinstance(parent_c, _DIV.Not) and c is not None and wrapped.level >= 1
can_insert = wrapped.editable and not isinstance(parent_c, _DIV.Not) and wrapped.level >= 2
can_delete = wrapped.editable and not isinstance(parent_c, diversion.Not) and c is not None and wrapped.level >= 1
can_insert = wrapped.editable and not isinstance(parent_c, diversion.Not) and wrapped.level >= 2
can_insert_only_rule = wrapped.editable and wrapped.level == 1
can_flatten = (
wrapped.editable
and not isinstance(parent_c, _DIV.Not)
and isinstance(c, (_DIV.Rule, _DIV.And, _DIV.Or))
and not isinstance(parent_c, diversion.Not)
and isinstance(c, (diversion.Rule, diversion.And, diversion.Or))
and wrapped.level >= 2
and len(c.components)
)
@@ -242,7 +276,7 @@ class ActionMenu:
p2 = self._menu_paste(m, it, below=True)
p2.set_label(_("Paste below"))
menu.append(p2)
elif enabled_actions.insert_only_rule and isinstance(_rule_component_clipboard, _DIV.Rule):
elif enabled_actions.insert_only_rule and isinstance(_rule_component_clipboard, diversion.Rule):
p = self._menu_paste(m, it)
menu.append(p)
if enabled_actions.c is None:
@@ -252,7 +286,7 @@ class ActionMenu:
p2 = self._menu_paste(m, it, below=True)
p2.set_label(_("Paste rule below"))
menu.append(p2)
elif enabled_actions.insert_root and isinstance(_rule_component_clipboard, _DIV.Rule):
elif enabled_actions.insert_root and isinstance(_rule_component_clipboard, diversion.Rule):
p = self._menu_paste(m, m.iter_nth_child(it, 0))
p.set_label(_("Paste rule"))
menu.append(p)
@@ -296,7 +330,7 @@ class ActionMenu:
parent_it = m.iter_parent(it)
parent_c = m[parent_it][0].component
idx = parent_c.components.index(c)
if isinstance(c, _DIV.Not):
if isinstance(c, diversion.Not):
parent_c.components = [*parent_c.components[:idx], c.component, *parent_c.components[idx + 1 :]]
children = [next(m[it].iterchildren())[0].component]
else:
@@ -311,7 +345,7 @@ class ActionMenu:
def _menu_flatten(self, m, it):
menu_flatten = Gtk.MenuItem(_("Flatten"))
menu_flatten.connect("activate", self.menu_do_flatten, m, it)
menu_flatten.connect(GtkSignal.ACTIVATE.value, self.menu_do_flatten, m, it)
menu_flatten.show()
return menu_flatten
@@ -324,8 +358,8 @@ class ActionMenu:
idx = 0
else:
idx = parent_c.components.index(c)
if isinstance(new_c, _DIV.Rule) and wrapped.level == 1:
new_c.source = _DIV._file_path # new rules will be saved to the YAML file
if isinstance(new_c, diversion.Rule) and wrapped.level == 1:
new_c.source = diversion._file_path # new rules will be saved to the YAML file
idx += int(below)
parent_c.components.insert(idx, new_c)
self._populate_model_func(m, parent_it, new_c, level=wrapped.level, pos=idx)
@@ -334,7 +368,7 @@ class ActionMenu:
m.remove(it) # remove placeholder in the end
new_iter = m.iter_nth_child(parent_it, idx)
self.tree_view.get_selection().select_iter(new_iter)
if isinstance(new_c, (_DIV.Rule, _DIV.And, _DIV.Or, _DIV.Not)):
if isinstance(new_c, (diversion.Rule, diversion.And, diversion.Or, diversion.Not)):
self.tree_view.expand_row(m.get_path(new_iter), True)
def _menu_do_insert_new(self, _mitem, m, it, cls, initial_value, below=False):
@@ -345,37 +379,37 @@ class ActionMenu:
elements = [
_("Insert"),
[
(_("Sub-rule"), _DIV.Rule, []),
(_("Or"), _DIV.Or, []),
(_("And"), _DIV.And, []),
(_("Sub-rule"), diversion.Rule, []),
(_("Or"), diversion.Or, []),
(_("And"), diversion.And, []),
[
_("Condition"),
[
(_("Feature"), _DIV.Feature, rule_conditions.FeatureUI.FEATURES_WITH_DIVERSION[0]),
(_("Report"), _DIV.Report, 0),
(_("Process"), _DIV.Process, ""),
(_("Mouse process"), _DIV.MouseProcess, ""),
(_("Modifiers"), _DIV.Modifiers, []),
(_("Key"), _DIV.Key, ""),
(_("KeyIsDown"), _DIV.KeyIsDown, ""),
(_("Active"), _DIV.Active, ""),
(_("Device"), _DIV.Device, ""),
(_("Host"), _DIV.Host, ""),
(_("Setting"), _DIV.Setting, [None, "", None]),
(_("Test"), _DIV.Test, next(iter(_DIV.TESTS))),
(_("Test bytes"), _DIV.TestBytes, [0, 1, 0]),
(_("Mouse Gesture"), _DIV.MouseGesture, ""),
(_("Feature"), diversion.Feature, rule_conditions.FeatureUI.FEATURES_WITH_DIVERSION[0]),
(_("Report"), diversion.Report, 0),
(_("Process"), diversion.Process, ""),
(_("Mouse process"), diversion.MouseProcess, ""),
(_("Modifiers"), diversion.Modifiers, []),
(_("Key"), diversion.Key, ""),
(_("KeyIsDown"), diversion.KeyIsDown, ""),
(_("Active"), diversion.Active, ""),
(_("Device"), diversion.Device, ""),
(_("Host"), diversion.Host, ""),
(_("Setting"), diversion.Setting, [None, "", None]),
(_("Test"), diversion.Test, next(iter(diversion.TESTS))),
(_("Test bytes"), diversion.TestBytes, [0, 1, 0]),
(_("Mouse Gesture"), diversion.MouseGesture, ""),
],
],
[
_("Action"),
[
(_("Key press"), _DIV.KeyPress, "space"),
(_("Mouse scroll"), _DIV.MouseScroll, [0, 0]),
(_("Mouse click"), _DIV.MouseClick, ["left", 1]),
(_("Set"), _DIV.Set, [None, "", None]),
(_("Execute"), _DIV.Execute, [""]),
(_("Later"), _DIV.Later, [1]),
(_("Key press"), diversion.KeyPress, "space"),
(_("Mouse scroll"), diversion.MouseScroll, [0, 0]),
(_("Mouse click"), diversion.MouseClick, ["left", 1]),
(_("Set"), diversion.Set, [None, "", None]),
(_("Execute"), diversion.Execute, [""]),
(_("Later"), diversion.Later, [1]),
],
],
],
@@ -394,7 +428,7 @@ class ActionMenu:
label, feature, *args = spec
item = Gtk.MenuItem(label)
args = [a.copy() if isinstance(a, list) else a for a in args]
item.connect("activate", self._menu_do_insert_new, m, it, feature, *args, below)
item.connect(GtkSignal.ACTIVATE.value, self._menu_do_insert_new, m, it, feature, *args, below)
return item
else:
return None
@@ -405,7 +439,7 @@ class ActionMenu:
def _menu_create_rule(self, m, it, below=False) -> Gtk.MenuItem:
menu_create_rule = Gtk.MenuItem(_("Insert new rule"))
menu_create_rule.connect("activate", self._menu_do_insert_new, m, it, _DIV.Rule, [], below)
menu_create_rule.connect(GtkSignal.ACTIVATE.value, self._menu_do_insert_new, m, it, diversion.Rule, [], below)
menu_create_rule.show()
return menu_create_rule
@@ -425,7 +459,7 @@ class ActionMenu:
def _menu_delete(self, m, it) -> Gtk.MenuItem:
menu_delete = Gtk.MenuItem(_("Delete"))
menu_delete.connect("activate", self.menu_do_delete, m, it)
menu_delete.connect(GtkSignal.ACTIVATE.value, self.menu_do_delete, m, it)
menu_delete.show()
return menu_delete
@@ -434,20 +468,20 @@ class ActionMenu:
c = wrapped.component
parent_it = m.iter_parent(it)
parent_c = m[parent_it][0].component
if isinstance(c, _DIV.Not): # avoid double negation
if isinstance(c, diversion.Not): # avoid double negation
self.menu_do_flatten(_mitem, m, it)
self.tree_view.expand_row(m.get_path(parent_it), True)
elif isinstance(parent_c, _DIV.Not): # avoid double negation
elif isinstance(parent_c, diversion.Not): # avoid double negation
self.menu_do_flatten(_mitem, m, parent_it)
else:
idx = parent_c.components.index(c)
self._menu_do_insert_new(_mitem, m, it, _DIV.Not, c, below=True)
self._menu_do_insert_new(_mitem, m, it, diversion.Not, c, below=True)
self.menu_do_delete(_mitem, m, m.iter_nth_child(parent_it, idx))
self._on_update()
def _menu_negate(self, m, it) -> Gtk.MenuItem:
menu_negate = Gtk.MenuItem(_("Negate"))
menu_negate.connect("activate", self.menu_do_negate, m, it)
menu_negate.connect(GtkSignal.ACTIVATE.value, self.menu_do_negate, m, it)
menu_negate.show()
return menu_negate
@@ -456,7 +490,7 @@ class ActionMenu:
c = wrapped.component
parent_it = m.iter_parent(it)
parent_c = m[parent_it][0].component
if isinstance(parent_c, _DIV.Not):
if isinstance(parent_c, diversion.Not):
new_c = cls([c], warn=False)
parent_c.component = new_c
m.remove(it)
@@ -475,9 +509,9 @@ class ActionMenu:
menu_sub_rule = Gtk.MenuItem(_("Sub-rule"))
menu_and = Gtk.MenuItem(_("And"))
menu_or = Gtk.MenuItem(_("Or"))
menu_sub_rule.connect("activate", self.menu_do_wrap, m, it, _DIV.Rule)
menu_and.connect("activate", self.menu_do_wrap, m, it, _DIV.And)
menu_or.connect("activate", self.menu_do_wrap, m, it, _DIV.Or)
menu_sub_rule.connect(GtkSignal.ACTIVATE.value, self.menu_do_wrap, m, it, diversion.Rule)
menu_and.connect(GtkSignal.ACTIVATE.value, self.menu_do_wrap, m, it, diversion.And)
menu_or.connect(GtkSignal.ACTIVATE.value, self.menu_do_wrap, m, it, diversion.Or)
submenu_wrap.append(menu_sub_rule)
submenu_wrap.append(menu_and)
submenu_wrap.append(menu_or)
@@ -490,7 +524,7 @@ class ActionMenu:
wrapped = m[it][0]
c = wrapped.component
_rule_component_clipboard = _DIV.RuleComponent().compile(c.data())
_rule_component_clipboard = diversion.RuleComponent().compile(c.data())
def menu_do_cut(self, _mitem, m, it):
global _rule_component_clipboard
@@ -501,7 +535,7 @@ class ActionMenu:
def _menu_cut(self, m, it):
menu_cut = Gtk.MenuItem(_("Cut"))
menu_cut.connect("activate", self.menu_do_cut, m, it)
menu_cut.connect(GtkSignal.ACTIVATE.value, self.menu_do_cut, m, it)
menu_cut.show()
return menu_cut
@@ -511,19 +545,19 @@ class ActionMenu:
c = _rule_component_clipboard
_rule_component_clipboard = None
if c:
_rule_component_clipboard = _DIV.RuleComponent().compile(c.data())
_rule_component_clipboard = diversion.RuleComponent().compile(c.data())
self._menu_do_insert(_mitem, m, it, new_c=c, below=below)
self._on_update()
def _menu_paste(self, m, it, below=False):
menu_paste = Gtk.MenuItem(_("Paste"))
menu_paste.connect("activate", self.menu_do_paste, m, it, below)
menu_paste.connect(GtkSignal.ACTIVATE.value, self.menu_do_paste, m, it, below)
menu_paste.show()
return menu_paste
def _menu_copy(self, m, it):
menu_copy = Gtk.MenuItem(_("Copy"))
menu_copy.connect("activate", self.menu_do_copy, m, it)
menu_copy.connect(GtkSignal.ACTIVATE.value, self.menu_do_copy, m, it)
menu_copy.show()
return menu_copy
@@ -532,7 +566,7 @@ class DiversionDialog:
def __init__(self, action_menu):
window = Gtk.Window()
window.set_title(_("Solaar Rule Editor"))
window.connect("delete-event", self._closing)
window.connect(GtkSignal.DELETE_EVENT.value, self._closing)
vbox = Gtk.VBox()
self.top_panel, self.view = self._create_top_panel()
@@ -575,7 +609,7 @@ class DiversionDialog:
window.show_all()
window.connect("delete-event", lambda w, e: w.hide_on_delete() or True)
window.connect(GtkSignal.DELETE_EVENT.value, lambda w, e: w.hide_on_delete() or True)
style = window.get_style_context()
style.add_class("solaar")
@@ -604,13 +638,13 @@ class DiversionDialog:
self.dirty = False
for c in self.selected_rule_edit_panel.get_children():
self.selected_rule_edit_panel.remove(c)
_DIV.load_config_rule_file()
diversion.load_config_rule_file()
self.model = self._create_model()
self.view.set_model(self.model)
self.view.expand_all()
def _save_yaml_file(self):
if _DIV._save_config_rule_file():
if diversion._save_config_rule_file():
self.dirty = False
self.save_btn.set_sensitive(False)
self.discard_btn.set_sensitive(False)
@@ -623,9 +657,9 @@ class DiversionDialog:
view.set_enable_tree_lines(True)
view.set_reorderable(False)
view.connect("key-press-event", self._event_key_pressed)
view.connect("button-release-event", self._event_button_released)
view.get_selection().connect("changed", self._selection_changed)
view.connect(GtkSignal.KEY_PRESS_EVENT.value, self._event_key_pressed)
view.connect(GtkSignal.BUTTON_RELEASE_EVENT.value, self._event_button_released)
view.get_selection().connect(GtkSignal.CHANGED.value, self._selection_changed)
sw.add(view)
sw.set_size_request(0, 300) # don't ask for so much height
@@ -640,8 +674,8 @@ class DiversionDialog:
self.discard_btn.set_always_show_image(True)
self.discard_btn.set_sensitive(False)
self.discard_btn.set_valign(Gtk.Align.CENTER)
self.save_btn.connect("clicked", lambda *_args: self._save_yaml_file())
self.discard_btn.connect("clicked", lambda *_args: self._reload_yaml_file())
self.save_btn.connect(GtkSignal.CLICKED.value, lambda *_args: self._save_yaml_file())
self.discard_btn.connect(GtkSignal.CLICKED.value, lambda *_args: self._reload_yaml_file())
button_box.pack_start(self.save_btn, False, False, 0)
button_box.pack_start(self.discard_btn, False, False, 0)
button_box.set_halign(Gtk.Align.CENTER)
@@ -656,10 +690,10 @@ class DiversionDialog:
def _create_model(self):
model = Gtk.TreeStore(RuleComponentWrapper)
if len(_DIV.rules.components) == 1:
if len(diversion.rules.components) == 1:
# only built-in rules - add empty user rule list
_DIV.rules.components.insert(0, _DIV.Rule([], source=_DIV._file_path))
_populate_model(model, None, _DIV.rules.components)
diversion.rules.components.insert(0, diversion.Rule([], source=diversion._file_path))
_populate_model(model, None, diversion.rules.components)
return model
def _create_view_columns(self):
@@ -725,7 +759,7 @@ class DiversionDialog:
)
elif (
enabled_actions.insert_only_rule
and isinstance(_rule_component_clipboard, _DIV.Rule)
and isinstance(_rule_component_clipboard, diversion.Rule)
and e.keyval in [Gdk.KEY_v, Gdk.KEY_V]
):
self._action_menu.menu_do_paste(
@@ -733,7 +767,7 @@ class DiversionDialog:
)
elif (
enabled_actions.insert_root
and isinstance(_rule_component_clipboard, _DIV.Rule)
and isinstance(_rule_component_clipboard, diversion.Rule)
and e.keyval in [Gdk.KEY_v, Gdk.KEY_V]
):
self._action_menu.menu_do_paste(None, m, m.iter_nth_child(it, 0))
@@ -760,11 +794,11 @@ class DiversionDialog:
if e.keyval == Gdk.KEY_exclam:
self._action_menu.menu_do_negate(None, m, it)
elif e.keyval == Gdk.KEY_ampersand:
self._action_menu.menu_do_wrap(None, m, it, _DIV.And)
self._action_menu.menu_do_wrap(None, m, it, diversion.And)
elif e.keyval == Gdk.KEY_bar:
self._action_menu.menu_do_wrap(None, m, it, _DIV.Or)
self._action_menu.menu_do_wrap(None, m, it, diversion.Or)
elif e.keyval in [Gdk.KEY_r, Gdk.KEY_R] and (state & Gdk.ModifierType.SHIFT_MASK):
self._action_menu.menu_do_wrap(None, m, it, _DIV.Rule)
self._action_menu.menu_do_wrap(None, m, it, diversion.Rule)
if enabled_actions.flatten and e.keyval in [Gdk.KEY_asterisk, Gdk.KEY_KP_Multiply]:
self._action_menu.menu_do_flatten(None, m, it)
@@ -835,7 +869,7 @@ class SmartComboBox(Gtk.ComboBox):
self._all_values = []
self._blank = blank
self._model = None
self._commpletion = completion
self._completion = completion
self._case_insensitive = case_insensitive
self._norm = lambda s: None if s is None else s if not case_insensitive else str(s).upper()
self._replace_with_default_name = replace_with_default_name
@@ -847,7 +881,7 @@ class SmartComboBox(Gtk.ComboBox):
if name != self.get_child().get_text():
self.get_child().set_text(name)
self.connect("changed", lambda *a: replace_with(self.get_value(invalid_as_str=False)))
self.connect(GtkSignal.CHANGED.value, lambda *a: replace_with(self.get_value(invalid_as_str=False)))
self.set_id_column(0)
if self.get_has_entry():
@@ -887,7 +921,7 @@ class SmartComboBox(Gtk.ComboBox):
if visible:
to_complete += names if names else [str(value).strip()]
self.set_model(filtered_model)
if self.get_has_entry() and self._commpletion:
if self.get_has_entry() and self._completion:
CompletionEntry.add_completion_to_entry(self.get_child(), to_complete)
if self._find_idx(old_value) is not None:
self.set_value(old_value)
@@ -1067,16 +1101,19 @@ class UnsupportedRuleComponentUI(RuleComponentUI):
def create_widgets(self):
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label.set_text(_("This editor does not support the selected rule component yet."))
self.label.set_text(_("This editor does not support the selected rule component yet.") if self.component else "")
self.widgets[self.label] = (0, 0, 1, 1)
def collect_value(self):
return self.component.components[:] # not editable on the bottom panel
@classmethod
def right_label(cls, component):
return str(component)
class RuleUI(RuleComponentUI):
CLASS = _DIV.Rule
CLASS = diversion.Rule
def create_widgets(self):
self.widgets = {}
@@ -1094,7 +1131,7 @@ class RuleUI(RuleComponentUI):
class AndUI(RuleComponentUI):
CLASS = _DIV.And
CLASS = diversion.And
def create_widgets(self):
self.widgets = {}
@@ -1108,7 +1145,7 @@ class AndUI(RuleComponentUI):
class OrUI(RuleComponentUI):
CLASS = _DIV.Or
CLASS = diversion.Or
def create_widgets(self):
self.widgets = {}
@@ -1122,7 +1159,7 @@ class OrUI(RuleComponentUI):
class LaterUI(RuleComponentUI):
CLASS = _DIV.Later
CLASS = diversion.Later
MIN_VALUE = 0.01
MAX_VALUE = 100
@@ -1136,7 +1173,7 @@ class LaterUI(RuleComponentUI):
self.field.set_halign(Gtk.Align.CENTER)
self.field.set_valign(Gtk.Align.CENTER)
self.field.set_hexpand(True)
self.field.connect("value-changed", self._on_update)
self.field.connect(GtkSignal.VALUE_CHANGED.value, self._on_update)
self.widgets[self.field] = (0, 1, 1, 1)
def show(self, component, editable):
@@ -1157,7 +1194,7 @@ class LaterUI(RuleComponentUI):
class NotUI(RuleComponentUI):
CLASS = _DIV.Not
CLASS = diversion.Not
def create_widgets(self):
self.widgets = {}
@@ -1171,7 +1208,7 @@ class NotUI(RuleComponentUI):
class ActionUI(RuleComponentUI):
CLASS = _DIV.Action
CLASS = diversion.Action
@classmethod
def icon_name(cls):
@@ -1204,15 +1241,15 @@ class SetValueControl(Gtk.HBox):
],
case_insensitive=True,
)
self.toggle_widget.connect("changed", self._changed)
self.toggle_widget.connect(GtkSignal.CHANGED.value, self._changed)
self.range_widget = Gtk.SpinButton.new_with_range(0, 0xFFFF, 1)
self.range_widget.connect("value-changed", self._changed)
self.range_widget.connect(GtkSignal.VALUE_CHANGED.value, self._changed)
self.choice_widget = SmartComboBox(
[], completion=True, has_entry=True, case_insensitive=True, replace_with_default_name=True
)
self.choice_widget.connect("changed", self._changed)
self.choice_widget.connect(GtkSignal.CHANGED.value, self._changed)
self.sub_key_widget = SmartComboBox([])
self.sub_key_widget.connect("changed", self._changed)
self.sub_key_widget.connect(GtkSignal.CHANGED.value, self._changed)
self.unsupported_label = Gtk.Label(label=_("Unsupported setting"))
self.pack_start(self.sub_key_widget, False, False, 0)
self.sub_key_widget.set_hexpand(False)
@@ -1335,25 +1372,6 @@ class SetValueControl(Gtk.HBox):
self.unsupported_label.show()
def _all_settings():
settings = {}
for s in sorted(SETTINGS, key=lambda setting: setting.label):
if s.name not in settings:
settings[s.name] = [s]
else:
prev_setting = settings[s.name][0]
prev_kind = prev_setting.validator_class.kind
if prev_kind != s.validator_class.kind:
logger.warning(
"ignoring setting {} - same name of {}, but different kind ({} != {})".format(
s.__name__, prev_setting.__name__, prev_kind, s.validator_class.kind
)
)
continue
settings[s.name].append(s)
return settings
class _DeviceUI:
label_text = ""
@@ -1382,7 +1400,7 @@ class _DeviceUI:
self.device_field.set_value("")
self.device_field.set_valign(Gtk.Align.CENTER)
self.device_field.set_size_request(400, 0)
self.device_field.connect("changed", self._on_update)
self.device_field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.device_field] = (1, 1, 1, 1)
def update_devices(self):
@@ -1406,7 +1424,7 @@ class _DeviceUI:
class ActiveUI(_DeviceUI, ConditionUI):
CLASS = _DIV.Active
CLASS = diversion.Active
label_text = _("Device is active and its settings can be changed.")
@classmethod
@@ -1415,7 +1433,7 @@ class ActiveUI(_DeviceUI, ConditionUI):
class DeviceUI(_DeviceUI, ConditionUI):
CLASS = _DIV.Device
CLASS = diversion.Device
label_text = _("Device that originated the current notification.")
@classmethod
@@ -1424,7 +1442,7 @@ class DeviceUI(_DeviceUI, ConditionUI):
class HostUI(ConditionUI):
CLASS = _DIV.Host
CLASS = diversion.Host
def create_widgets(self):
self.widgets = {}
@@ -1433,7 +1451,7 @@ class HostUI(ConditionUI):
self.widgets[self.label] = (0, 0, 1, 1)
self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
self.field.set_size_request(600, 0)
self.field.connect("changed", self._on_update)
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.field] = (0, 1, 1, 1)
def show(self, component, editable):
@@ -1454,8 +1472,7 @@ class HostUI(ConditionUI):
class _SettingWithValueUI:
ALL_SETTINGS = _all_settings()
MULTIPLE = [_SKIND.multiple_toggle, _SKIND.map_choice, _SKIND.multiple_range]
MULTIPLE = [Kind.MULTIPLE_TOGGLE, Kind.MAP_CHOICE, Kind.MULTIPLE_RANGE]
ACCEPT_TOGGLE = True
label_text = ""
@@ -1481,8 +1498,8 @@ class _SettingWithValueUI:
self.device_field.set_valign(Gtk.Align.CENTER)
self.device_field.set_size_request(400, 0)
self.device_field.set_margin_top(m)
self.device_field.connect("changed", self._changed_device)
self.device_field.connect("changed", self._on_update)
self.device_field.connect(GtkSignal.CHANGED.value, self._changed_device)
self.device_field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.device_field] = (1, 1, 1, 1)
lbl = Gtk.Label(
@@ -1493,10 +1510,10 @@ class _SettingWithValueUI:
vexpand=False,
)
self.widgets[lbl] = (0, 2, 1, 1)
self.setting_field = SmartComboBox([(s[0].name, s[0].label) for s in self.ALL_SETTINGS.values()])
self.setting_field = SmartComboBox([(s[0].name, s[0].label) for s in ALL_SETTINGS.values()])
self.setting_field.set_valign(Gtk.Align.CENTER)
self.setting_field.connect("changed", self._changed_setting)
self.setting_field.connect("changed", self._on_update)
self.setting_field.connect(GtkSignal.CHANGED.value, self._changed_setting)
self.setting_field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.setting_field] = (1, 2, 1, 1)
self.value_lbl = Gtk.Label(
@@ -1519,8 +1536,8 @@ class _SettingWithValueUI:
self.key_field.set_margin_top(m)
self.key_field.hide()
self.key_field.set_valign(Gtk.Align.CENTER)
self.key_field.connect("changed", self._changed_key)
self.key_field.connect("changed", self._on_update)
self.key_field.connect(GtkSignal.CHANGED.value, self._changed_key)
self.key_field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.key_field] = (3, 1, 1, 1)
@classmethod
@@ -1546,7 +1563,7 @@ class _SettingWithValueUI:
if extra is not None:
choices |= NamedInts(**{str(extra): int(extra)})
return choices, extra
settings = cls.ALL_SETTINGS.get(setting, [])
settings = ALL_SETTINGS.get(setting, [])
choices = UnsortedNamedInts()
extra = None
for s in settings:
@@ -1562,14 +1579,14 @@ class _SettingWithValueUI:
setting = device.settings.get(setting_name, None)
settings = [type(setting)] if setting else None
else:
settings = cls.ALL_SETTINGS.get(setting_name, [None])
settings = ALL_SETTINGS.get(setting_name, [None])
setting = settings[0] # if settings have the same name, use the first one to get the basic data
val_class = setting.validator_class if setting else None
kind = val_class.kind if val_class else None
if kind in cls.MULTIPLE:
keys = UnsortedNamedInts()
for s in settings:
universe = getattr(s, "keys_universe" if kind == _SKIND.map_choice else "choices_universe", None)
universe = getattr(s, "keys_universe" if kind == Kind.MAP_CHOICE else "choices_universe", None)
if universe:
keys |= universe
# only one key per number is used
@@ -1641,12 +1658,12 @@ class _SettingWithValueUI:
supported_keys = None
if device_setting:
val = device_setting._validator
if device_setting.kind == _SKIND.multiple_toggle:
if device_setting.kind == Kind.MULTIPLE_TOGGLE:
supported_keys = val.get_options() or None
elif device_setting.kind == _SKIND.map_choice:
elif device_setting.kind == Kind.MAP_CHOICE:
choices = val.choices or None
supported_keys = choices.keys() if choices else None
elif device_setting.kind == _SKIND.multiple_range:
elif device_setting.kind == Kind.MULTIPLE_RANGE:
supported_keys = val.keys
self.key_field.show_only(supported_keys, include_new=True)
self._update_validation()
@@ -1655,24 +1672,24 @@ class _SettingWithValueUI:
setting, val_class, kind, keys = self._setting_attributes(setting_name, device)
ds = device.settings if device else {}
device_setting = ds.get(setting_name, None)
if kind in (_SKIND.toggle, _SKIND.multiple_toggle):
if kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE):
self.value_field.make_toggle()
elif kind in (_SKIND.choice, _SKIND.map_choice):
elif kind in (Kind.CHOICE, Kind.MAP_CHOICE):
all_values, extra = self._all_choices(device_setting or setting_name)
self.value_field.make_choice(all_values, extra)
supported_values = None
if device_setting:
val = device_setting._validator
choices = getattr(val, "choices", None) or None
if kind == _SKIND.choice:
if kind == Kind.CHOICE:
supported_values = choices
elif kind == _SKIND.map_choice and isinstance(choices, dict):
elif kind == Kind.MAP_CHOICE and isinstance(choices, dict):
supported_values = choices.get(key, None) or None
self.value_field.choice_widget.show_only(supported_values, include_new=True)
self._update_validation()
elif kind == _SKIND.range:
elif kind == Kind.RANGE:
self.value_field.make_range(val_class.min_value, val_class.max_value)
elif kind == _SKIND.multiple_range:
elif kind == Kind.MULTIPLE_RANGE:
self.value_field.make_range_with_key(
getattr(setting, "sub_items_universe", {}).get(key, {}) if setting else {},
getattr(setting, "_labels_sub", None) if setting else None,
@@ -1703,7 +1720,7 @@ class _SettingWithValueUI:
key = self.key_field.get_value(invalid_as_str=False, accept_hidden=False)
icon = "dialog-warning" if key is None else ""
self.key_field.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
if kind in (_SKIND.choice, _SKIND.map_choice):
if kind in (Kind.CHOICE, Kind.MAP_CHOICE):
value = self.value_field.choice_widget.get_value(invalid_as_str=False, accept_hidden=False)
icon = "dialog-warning" if value is None else ""
self.value_field.choice_widget.get_child().set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon)
@@ -1758,26 +1775,26 @@ class _SettingWithValueUI:
key_label = getattr(setting, "_labels", {}).get(key, [None])[0] if setting else None
disp.append(key_label or key)
value = next(a, None)
if setting and (kind in (_SKIND.choice, _SKIND.map_choice)):
if setting and (kind in (Kind.CHOICE, Kind.MAP_CHOICE)):
all_values = cls._all_choices(setting or setting_name)[0]
supported_values = None
if device_setting:
val = device_setting._validator
choices = getattr(val, "choices", None) or None
if kind == _SKIND.choice:
if kind == Kind.CHOICE:
supported_values = choices
elif kind == _SKIND.map_choice and isinstance(choices, dict):
elif kind == Kind.MAP_CHOICE and isinstance(choices, dict):
supported_values = choices.get(key, None) or None
if supported_values and isinstance(supported_values, NamedInts):
value = supported_values[value]
if not supported_values and all_values and isinstance(all_values, NamedInts):
value = all_values[value]
disp.append(value)
elif kind == _SKIND.multiple_range and isinstance(value, dict) and len(value) == 1:
elif kind == Kind.MULTIPLE_RANGE and isinstance(value, dict) and len(value) == 1:
k, v = next(iter(value.items()))
k = (getattr(setting, "_labels_sub", {}).get(k, (None,))[0] if setting else None) or k
disp.append(f"{k}={v}")
elif kind in (_SKIND.toggle, _SKIND.multiple_toggle):
elif kind in (Kind.TOGGLE, Kind.MULTIPLE_TOGGLE):
disp.append(_(str(value)))
else:
disp.append(value)
@@ -1785,7 +1802,7 @@ class _SettingWithValueUI:
class SetUI(_SettingWithValueUI, ActionUI):
CLASS = _DIV.Set
CLASS = diversion.Set
ACCEPT_TOGGLE = True
label_text = _("Change setting on device")
@@ -1801,7 +1818,7 @@ class SetUI(_SettingWithValueUI, ActionUI):
class SettingUI(_SettingWithValueUI, ConditionUI):
CLASS = _DIV.Setting
CLASS = diversion.Setting
ACCEPT_TOGGLE = False
label_text = _("Setting on device")
@@ -1816,32 +1833,32 @@ class SettingUI(_SettingWithValueUI, ConditionUI):
_SettingWithValueUI._on_update(self, *_args)
COMPONENT_UI = {
_DIV.Rule: RuleUI,
_DIV.Not: NotUI,
_DIV.Or: OrUI,
_DIV.And: AndUI,
_DIV.Later: LaterUI,
_DIV.Process: rule_conditions.ProcessUI,
_DIV.MouseProcess: rule_conditions.MouseProcessUI,
_DIV.Active: ActiveUI,
_DIV.Device: DeviceUI,
_DIV.Host: HostUI,
_DIV.Feature: rule_conditions.FeatureUI,
_DIV.Report: rule_conditions.ReportUI,
_DIV.Modifiers: rule_conditions.ModifiersUI,
_DIV.Key: rule_conditions.KeyUI,
_DIV.KeyIsDown: rule_conditions.KeyIsDownUI,
_DIV.Test: rule_conditions.TestUI,
_DIV.TestBytes: rule_conditions.TestBytesUI,
_DIV.Setting: SettingUI,
_DIV.MouseGesture: rule_conditions.MouseGestureUI,
_DIV.KeyPress: rule_actions.KeyPressUI,
_DIV.MouseScroll: rule_actions.MouseScrollUI,
_DIV.MouseClick: rule_actions.MouseClickUI,
_DIV.Execute: rule_actions.ExecuteUI,
_DIV.Set: SetUI,
type(None): RuleComponentUI, # placeholders for empty rule/And/Or
COMPONENT_UI: dict[Any, RuleComponentUI] = {
diversion.Rule: RuleUI,
diversion.Not: NotUI,
diversion.Or: OrUI,
diversion.And: AndUI,
diversion.Later: LaterUI,
diversion.Process: rule_conditions.ProcessUI,
diversion.MouseProcess: rule_conditions.MouseProcessUI,
diversion.Active: ActiveUI,
diversion.Device: DeviceUI,
diversion.Host: HostUI,
diversion.Feature: rule_conditions.FeatureUI,
diversion.Report: rule_conditions.ReportUI,
diversion.Modifiers: rule_conditions.ModifiersUI,
diversion.Key: rule_conditions.KeyUI,
diversion.KeyIsDown: rule_conditions.KeyIsDownUI,
diversion.Test: rule_conditions.TestUI,
diversion.TestBytes: rule_conditions.TestBytesUI,
diversion.Setting: SettingUI,
diversion.MouseGesture: rule_conditions.MouseGestureUI,
diversion.KeyPress: rule_actions.KeyPressUI,
diversion.MouseScroll: rule_actions.MouseScrollUI,
diversion.MouseClick: rule_actions.MouseClickUI,
diversion.Execute: rule_actions.ExecuteUI,
diversion.Set: SetUI,
# type(None): RuleComponentUI, # placeholders for empty rule/And/Or
}
_all_devices = AllDevicesInfo()

View File

@@ -37,8 +37,7 @@ def _init_icon_paths():
return
_default_theme = Gtk.IconTheme.get_default()
if logger.isEnabledFor(logging.DEBUG):
logger.debug("icon theme paths: %s", _default_theme.get_search_path())
logger.debug("icon theme paths: %s", _default_theme.get_search_path())
if gtk.battery_icons_style == "symbolic":
global TRAY_OKAY
@@ -57,8 +56,7 @@ def battery(level=None, charging=False):
if not _default_theme.has_icon(icon_name):
logger.warning("icon %s not found in current theme", icon_name)
return TRAY_OKAY # use Solaar icon if battery icon not available
elif logger.isEnabledFor(logging.DEBUG):
logger.debug("battery icon for %s:%s = %s", level, charging, icon_name)
logger.debug("battery icon for %s:%s = %s", level, charging, icon_name)
return icon_name
@@ -105,7 +103,7 @@ def device_icon_set(name="_", kind=None):
icon_set += ("input-mouse",)
elif str(kind) == "headset":
icon_set += ("audio-headphones", "audio-headset")
icon_set += ("input-" + str(kind),)
icon_set += (f"input-{str(kind)}",)
# icon_set += (name.replace(' ', '-'),)
_ICON_SETS[name] = icon_set
return icon_set

View File

@@ -17,6 +17,8 @@
import logging
from enum import Enum
from gi.repository import GLib
from gi.repository import Gtk
from logitech_receiver import hidpp10_constants
@@ -32,6 +34,11 @@ _PAIRING_TIMEOUT = 30 # seconds
_STATUS_CHECK = 500 # milliseconds
class GtkSignal(Enum):
CANCEL = "cancel"
CLOSE = "close"
def create(receiver):
receiver.reset_pairing() # clear out any information on previous pairing
title = _("%(receiver_name)s: pair new device") % {"receiver_name": receiver.name}
@@ -92,8 +99,7 @@ def prepare(receiver):
def check_lock_state(assistant, receiver, count=2):
if not assistant.is_drawable():
if logger.isEnabledFor(logging.DEBUG):
logger.debug("assistant %s destroyed, bailing out", assistant)
logger.debug("assistant %s destroyed, bailing out", assistant)
return False
return _check_lock_state(assistant, receiver, count)
@@ -129,21 +135,18 @@ def _check_lock_state(assistant, receiver, count):
def _pairing_failed(assistant, receiver, error):
assistant.remove_page(0) # needed to reset the window size
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s fail: %s", receiver, error)
logger.debug("%s fail: %s", receiver, error)
_create_failure_page(assistant, error)
def _pairing_succeeded(assistant, receiver, device):
assistant.remove_page(0) # needed to reset the window size
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s success: %s", receiver, device)
logger.debug("%s success: %s", receiver, device)
_create_success_page(assistant, device)
def _finish(assistant, receiver):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("finish %s", assistant)
logger.debug("finish %s", assistant)
assistant.destroy()
receiver.pairing.new_device = None
if receiver.pairing.lock_open:
@@ -158,8 +161,7 @@ def _finish(assistant, receiver):
def _show_passcode(assistant, receiver, passkey):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s show passkey: %s", receiver, passkey)
logger.debug("%s show passkey: %s", receiver, passkey)
name = receiver.pairing.device_name
authentication = receiver.pairing.device_authentication
intro_text = _("%(receiver_name)s: pair new device") % {"receiver_name": receiver.name}
@@ -207,8 +209,8 @@ def _create_assistant(receiver, ok, finish, title, text):
assistant.set_page_complete(page_intro, True)
else:
page_intro = _create_failure_page(assistant, receiver.pairing.error)
assistant.connect("cancel", finish, receiver)
assistant.connect("close", finish, receiver)
assistant.connect(GtkSignal.CANCEL.value, finish, receiver)
assistant.connect(GtkSignal.CLOSE.value, finish, receiver)
return assistant

View File

@@ -13,7 +13,7 @@
## 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 enum import Enum
from shlex import quote as shlex_quote
from gi.repository import Gtk
@@ -29,6 +29,12 @@ from solaar.ui.rule_base import CompletionEntry
from solaar.ui.rule_base import RuleComponentUI
class GtkSignal(Enum):
CHANGED = "changed"
CLICKED = "clicked"
TOGGLED = "toggled"
class ActionUI(RuleComponentUI):
CLASS = diversion.Action
@@ -47,25 +53,27 @@ class KeyPressUI(ActionUI):
self.label = Gtk.Label(
label=_("Simulate a chorded key click or depress or release.\nOn Wayland requires write access to /dev/uinput."),
halign=Gtk.Align.CENTER,
justify=Gtk.Justification.CENTER,
)
self.widgets[self.label] = (0, 0, 5, 1)
self.del_btns = []
self.add_btn = Gtk.Button(label=_("Add key"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
self.add_btn.connect("clicked", self._clicked_add)
self.add_btn.connect(GtkSignal.CLICKED.value, self._clicked_add)
self.widgets[self.add_btn] = (1, 1, 1, 1)
self.action_clicked_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Click"))
self.action_clicked_radio.connect("toggled", self._on_update, CLICK)
self.action_clicked_radio.connect(GtkSignal.TOGGLED.value, self._on_update, CLICK)
self.widgets[self.action_clicked_radio] = (0, 3, 1, 1)
self.action_pressed_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_clicked_radio, _("Depress"))
self.action_pressed_radio.connect("toggled", self._on_update, DEPRESS)
self.action_pressed_radio.connect(GtkSignal.TOGGLED.value, self._on_update, DEPRESS)
self.widgets[self.action_pressed_radio] = (1, 3, 1, 1)
self.action_released_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_pressed_radio, _("Release"))
self.action_released_radio.connect("toggled", self._on_update, RELEASE)
self.action_released_radio.connect(GtkSignal.TOGGLED.value, self._on_update, RELEASE)
self.widgets[self.action_released_radio] = (2, 3, 1, 1)
def _create_field(self):
field_entry = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
field_entry.connect("changed", self._on_update)
field_entry.connect(GtkSignal.CHANGED.value, self._on_update)
field_entry.set_size_request(250, -1)
self.fields.append(field_entry)
self.widgets[field_entry] = (len(self.fields) - 1, 1, 1, 1)
return field_entry
@@ -74,7 +82,7 @@ class KeyPressUI(ActionUI):
btn = Gtk.Button(label=_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True)
self.del_btns.append(btn)
self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1)
btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1)
btn.connect(GtkSignal.CLICKED.value, self._clicked_del, len(self.del_btns) - 1)
return btn
def _clicked_add(self, _btn):
@@ -113,7 +121,6 @@ class KeyPressUI(ActionUI):
field_entry = self.fields[i]
with self.ignore_changes():
field_entry.set_text(component.key_names[i])
field_entry.set_size_request(int(0.3 * self.panel.get_toplevel().get_size()[0]), 0)
field_entry.show_all()
self.del_btns[i].show()
for i in range(n, len(self.fields)):
@@ -132,7 +139,7 @@ class KeyPressUI(ActionUI):
@classmethod
def right_label(cls, component):
return " + ".join(component.key_names) + (" (" + component.action + ")" if component.action != CLICK else "")
return " + ".join(component.key_names) + (f" ({component.action})" if component.action != CLICK else "")
class MouseScrollUI(ActionUI):
@@ -143,7 +150,9 @@ class MouseScrollUI(ActionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(
label=_("Simulate a mouse scroll.\nOn Wayland requires write access to /dev/uinput."), halign=Gtk.Align.CENTER
label=_("Simulate a mouse scroll.\nOn Wayland requires write access to /dev/uinput."),
halign=Gtk.Align.CENTER,
justify=Gtk.Justification.CENTER,
)
self.widgets[self.label] = (0, 0, 4, 1)
self.label_x = Gtk.Label(label="x", halign=Gtk.Align.END, valign=Gtk.Align.END, hexpand=True)
@@ -153,8 +162,8 @@ class MouseScrollUI(ActionUI):
for f in [self.field_x, self.field_y]:
f.set_halign(Gtk.Align.CENTER)
f.set_valign(Gtk.Align.START)
self.field_x.connect("changed", self._on_update)
self.field_y.connect("changed", self._on_update)
self.field_x.connect(GtkSignal.CHANGED.value, self._on_update)
self.field_y.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.label_x] = (0, 1, 1, 1)
self.widgets[self.field_x] = (1, 1, 1, 1)
self.widgets[self.label_y] = (2, 1, 1, 1)
@@ -199,25 +208,29 @@ class MouseClickUI(ActionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(
label=_("Simulate a mouse click.\nOn Wayland requires write access to /dev/uinput."), halign=Gtk.Align.CENTER
label=_("Simulate a mouse click.\nOn Wayland requires write access to /dev/uinput."),
halign=Gtk.Align.CENTER,
justify=Gtk.Justification.CENTER,
)
self.widgets[self.label] = (0, 0, 4, 1)
self.label_b = Gtk.Label(label=_("Button"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True)
self.label_c = Gtk.Label(label=_("Count and Action"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True)
self.label_c = Gtk.Label(
label=_("Action (and Count, if click)"), halign=Gtk.Align.END, valign=Gtk.Align.CENTER, hexpand=True
)
self.field_b = CompletionEntry(self.BUTTONS)
self.field_c = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1)
self.field_d = CompletionEntry(self.ACTIONS)
for f in [self.field_b, self.field_c]:
f.set_halign(Gtk.Align.CENTER)
f.set_valign(Gtk.Align.START)
self.field_b.connect("changed", self._on_update)
self.field_c.connect("changed", self._on_update)
self.field_d.connect("changed", self._on_update)
self.field_b.connect(GtkSignal.CHANGED.value, self._on_update)
self.field_c.connect(GtkSignal.CHANGED.value, self._on_update)
self.field_d.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.label_b] = (0, 1, 1, 1)
self.widgets[self.field_b] = (1, 1, 1, 1)
self.widgets[self.label_c] = (2, 1, 1, 1)
self.widgets[self.field_c] = (3, 1, 1, 1)
self.widgets[self.field_d] = (4, 1, 1, 1)
self.widgets[self.field_c] = (4, 1, 1, 1)
self.widgets[self.field_d] = (3, 1, 1, 1)
def show(self, component, editable=True):
super().show(component, editable)
@@ -252,18 +265,20 @@ class ExecuteUI(ActionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(label=_("Execute a command with arguments."), halign=Gtk.Align.CENTER)
self.label = Gtk.Label(
label=_("Execute a command with arguments."), halign=Gtk.Align.CENTER, justify=Gtk.Justification.CENTER
)
self.widgets[self.label] = (0, 0, 5, 1)
self.fields = []
self.add_btn = Gtk.Button(label=_("Add argument"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
self.del_btns = []
self.add_btn.connect("clicked", self._clicked_add)
self.add_btn.connect(GtkSignal.CLICKED.value, self._clicked_add)
self.widgets[self.add_btn] = (1, 1, 1, 1)
def _create_field(self):
field_entry = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
field_entry.set_size_request(150, 0)
field_entry.connect("changed", self._on_update)
field_entry.connect(GtkSignal.CHANGED.value, self._on_update)
self.fields.append(field_entry)
self.widgets[field_entry] = (len(self.fields) - 1, 1, 1, 1)
return field_entry
@@ -273,7 +288,7 @@ class ExecuteUI(ActionUI):
btn.set_size_request(150, 0)
self.del_btns.append(btn)
self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1)
btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1)
btn.connect(GtkSignal.CLICKED.value, self._clicked_del, len(self.del_btns) - 1)
return btn
def _clicked_add(self, *_args):

View File

@@ -13,8 +13,10 @@
## 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.
import abc
from contextlib import contextmanager as contextlib_contextmanager
from typing import Any
from typing import Callable
from gi.repository import Gtk
@@ -47,7 +49,7 @@ class CompletionEntry(Gtk.Entry):
liststore.append((v,))
class RuleComponentUI:
class RuleComponentUI(abc.ABC):
CLASS = diversion.RuleComponent
def __init__(self, panel, on_update: Callable = None):
@@ -58,15 +60,17 @@ class RuleComponentUI:
self._on_update_callback = (lambda: None) if on_update is None else on_update
self.create_widgets()
def create_widgets(self):
@abc.abstractmethod
def create_widgets(self) -> dict:
pass
def show(self, component, editable=True):
self._show_widgets(editable)
self.component = component
def collect_value(self):
return None
@abc.abstractmethod
def collect_value(self) -> Any:
pass
@contextlib_contextmanager
def ignore_changes(self):
@@ -105,5 +109,5 @@ class RuleComponentUI:
for c in self.panel.get_children():
self.panel.remove(c)
def update_devices(self):
def update_devices(self): # noqa: B027
pass

View File

@@ -14,6 +14,7 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from dataclasses import dataclass
from enum import Enum
from gi.repository import Gtk
from logitech_receiver import diversion
@@ -26,6 +27,14 @@ from solaar.ui.rule_base import CompletionEntry
from solaar.ui.rule_base import RuleComponentUI
class GtkSignal(Enum):
CHANGED = "changed"
CLICKED = "clicked"
NOTIFY_ACTIVE = "notify::active"
TOGGLED = "toggled"
VALUE_CHANGED = "value-changed"
class ConditionUI(RuleComponentUI):
CLASS = diversion.Condition
@@ -39,12 +48,12 @@ class ProcessUI(ConditionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
self.label.set_text(_("X11 active process. For use in X11 only."))
self.widgets[self.label] = (0, 0, 1, 1)
self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
self.field.set_size_request(600, 0)
self.field.connect("changed", self._on_update)
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.field] = (0, 1, 1, 1)
def show(self, component, editable=True):
@@ -69,12 +78,12 @@ class MouseProcessUI(ConditionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
self.label.set_text(_("X11 mouse process. For use in X11 only."))
self.widgets[self.label] = (0, 0, 1, 1)
self.field = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
self.field.set_size_request(600, 0)
self.field.connect("changed", self._on_update)
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.field] = (0, 1, 1, 1)
def show(self, component, editable=True):
@@ -110,7 +119,7 @@ class FeatureUI(ConditionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
self.label.set_text(_("Feature name of notification triggering rule processing."))
self.widgets[self.label] = (0, 0, 1, 1)
self.field = Gtk.ComboBoxText.new_with_entry()
@@ -119,7 +128,7 @@ class FeatureUI(ConditionUI):
self.field.append(feature, feature)
self.field.set_valign(Gtk.Align.CENTER)
self.field.set_size_request(600, 0)
self.field.connect("changed", self._on_update)
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
all_features = [str(f) for f in SupportedFeature]
CompletionEntry.add_completion_to_entry(self.field.get_child(), all_features)
self.widgets[self.field] = (0, 1, 1, 1)
@@ -156,14 +165,14 @@ class ReportUI(ConditionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
self.label.set_text(_("Report number of notification triggering rule processing."))
self.widgets[self.label] = (0, 0, 1, 1)
self.field = Gtk.SpinButton.new_with_range(self.MIN_VALUE, self.MAX_VALUE, 1)
self.field.set_halign(Gtk.Align.CENTER)
self.field.set_valign(Gtk.Align.CENTER)
self.field.set_hexpand(True)
self.field.connect("changed", self._on_update)
self.field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.field] = (0, 1, 1, 1)
def show(self, component, editable=True):
@@ -188,7 +197,7 @@ class ModifiersUI(ConditionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
self.label.set_text(_("Active keyboard modifiers. Not always available in Wayland."))
self.widgets[self.label] = (0, 0, 5, 1)
self.labels = {}
@@ -200,7 +209,7 @@ class ModifiersUI(ConditionUI):
self.widgets[switch] = (i, 2, 1, 1)
self.labels[m] = label
self.switches[m] = switch
switch.connect("notify::active", self._on_update)
switch.connect(GtkSignal.NOTIFY_ACTIVE.value, self._on_update)
def show(self, component, editable=True):
super().show(component, editable)
@@ -226,7 +235,7 @@ class KeyUI(ConditionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
self.label.set_text(
_(
"Diverted key or button depressed or released.\n"
@@ -236,13 +245,13 @@ class KeyUI(ConditionUI):
self.widgets[self.label] = (0, 0, 5, 1)
self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
self.key_field.set_size_request(600, 0)
self.key_field.connect("changed", self._on_update)
self.key_field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.key_field] = (0, 1, 2, 1)
self.action_pressed_radio = Gtk.RadioButton.new_with_label_from_widget(None, _("Key down"))
self.action_pressed_radio.connect("toggled", self._on_update, Key.DOWN)
self.action_pressed_radio.connect(GtkSignal.TOGGLED.value, self._on_update, Key.DOWN)
self.widgets[self.action_pressed_radio] = (2, 1, 1, 1)
self.action_released_radio = Gtk.RadioButton.new_with_label_from_widget(self.action_pressed_radio, _("Key up"))
self.action_released_radio.connect("toggled", self._on_update, Key.UP)
self.action_released_radio.connect(GtkSignal.TOGGLED.value, self._on_update, Key.UP)
self.widgets[self.action_released_radio] = (3, 1, 1, 1)
def show(self, component, editable=True):
@@ -278,7 +287,7 @@ class KeyIsDownUI(ConditionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
self.label.set_text(
_(
"Diverted key or button is currently down.\n"
@@ -288,7 +297,7 @@ class KeyIsDownUI(ConditionUI):
self.widgets[self.label] = (0, 0, 5, 1)
self.key_field = CompletionEntry(self.KEY_NAMES, halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, hexpand=True)
self.key_field.set_size_request(600, 0)
self.key_field.connect("changed", self._on_update)
self.key_field.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.key_field] = (0, 1, 1, 1)
def show(self, component, editable=True):
@@ -318,7 +327,7 @@ class TestUI(ConditionUI):
def create_widgets(self):
self.widgets = {}
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
self.label.set_text(_("Test condition on notification triggering rule processing."))
self.widgets[self.label] = (0, 0, 4, 1)
lbl = Gtk.Label(label=_("Test"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=False, vexpand=False)
@@ -335,12 +344,12 @@ class TestUI(ConditionUI):
self.test.set_hexpand(False)
self.test.set_size_request(300, 0)
CompletionEntry.add_completion_to_entry(self.test.get_child(), diversion.TESTS)
self.test.connect("changed", self._on_update)
self.test.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.test] = (1, 1, 1, 1)
self.parameter = Gtk.Entry(halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
self.parameter.set_size_request(150, 0)
self.parameter.connect("changed", self._on_update)
self.parameter.connect(GtkSignal.CHANGED.value, self._on_update)
self.widgets[self.parameter] = (3, 1, 1, 1)
def show(self, component, editable=True):
@@ -374,7 +383,7 @@ class TestUI(ConditionUI):
@classmethod
def right_label(cls, component):
return component.test + (" " + repr(component.parameter) if component.parameter is not None else "")
return component.test + (f" {repr(component.parameter)}" if component.parameter is not None else "")
@dataclass
@@ -424,7 +433,7 @@ class TestBytesUI(ConditionUI):
self.fields = {}
self.field_labels = {}
self.widgets = {}
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True)
self.label = Gtk.Label(valign=Gtk.Align.CENTER, hexpand=True, justify=Gtk.Justification.CENTER)
self.label.set_text(_("Bit or range test on bytes in notification message triggering rule processing."))
self.widgets[self.label] = (0, 0, 5, 1)
col = 0
@@ -444,14 +453,14 @@ class TestBytesUI(ConditionUI):
field = Gtk.SpinButton.new_with_range(element.min, element.max, 1)
field.set_value(0)
field.set_size_request(150, 0)
field.connect("value-changed", self._on_update)
field.connect(GtkSignal.VALUE_CHANGED.value, self._on_update)
label = Gtk.Label(label=element.label, margin_top=20)
self.fields[element.id] = field
self.field_labels[element.id] = label
self.widgets[label] = (col, 1, 1, 1)
self.widgets[field] = (col, 2, 1, 1)
col += 1 if col != mode_col - 1 else 2
self.mode_field.connect("changed", lambda cb: (self._on_update(), self._only_mode(cb.get_active_id())))
self.mode_field.connect(GtkSignal.CHANGED.value, lambda cb: (self._on_update(), self._only_mode(cb.get_active_id())))
self.mode_field.set_active_id("range")
def show(self, component, editable=True):
@@ -525,11 +534,12 @@ class MouseGestureUI(ConditionUI):
self.label = Gtk.Label(
label=_("Mouse gesture with optional initiating button followed by zero or more mouse movements."),
halign=Gtk.Align.CENTER,
justify=Gtk.Justification.CENTER,
)
self.widgets[self.label] = (0, 0, 5, 1)
self.del_btns = []
self.add_btn = Gtk.Button(label=_("Add movement"), halign=Gtk.Align.CENTER, valign=Gtk.Align.END, hexpand=True)
self.add_btn.connect("clicked", self._clicked_add)
self.add_btn.connect(GtkSignal.CLICKED.value, self._clicked_add)
self.widgets[self.add_btn] = (1, 1, 1, 1)
def _create_field(self):
@@ -537,7 +547,7 @@ class MouseGestureUI(ConditionUI):
for g in self.MOUSE_GESTURE_NAMES:
field.append(g, g)
CompletionEntry.add_completion_to_entry(field.get_child(), self.MOVE_NAMES)
field.connect("changed", self._on_update)
field.connect(GtkSignal.CHANGED.value, self._on_update)
self.fields.append(field)
self.widgets[field] = (len(self.fields) - 1, 1, 1, 1)
return field
@@ -546,7 +556,7 @@ class MouseGestureUI(ConditionUI):
btn = Gtk.Button(label=_("Delete"), halign=Gtk.Align.CENTER, valign=Gtk.Align.START, hexpand=True)
self.del_btns.append(btn)
self.widgets[btn] = (len(self.del_btns) - 1, 2, 1, 1)
btn.connect("clicked", self._clicked_del, len(self.del_btns) - 1)
btn.connect(GtkSignal.CLICKED.value, self._clicked_del, len(self.del_btns) - 1)
return btn
def _clicked_add(self, _btn):
@@ -583,7 +593,6 @@ class MouseGestureUI(ConditionUI):
field = self.fields[i]
with self.ignore_changes():
field.get_child().set_text(component.movements[i])
field.set_size_request(int(0.3 * self.panel.get_toplevel().get_size()[0]), 0)
field.show_all()
self.del_btns[i].show()
for i in range(n, len(self.fields)):

View File

@@ -18,6 +18,7 @@
import logging
import os
from enum import Enum
from time import time
import gi
@@ -42,6 +43,11 @@ _TRAY_ICON_SIZE = 48
_MENU_ICON_SIZE = Gtk.IconSize.LARGE_TOOLBAR
class GtkSignal(Enum):
ACTIVATE = "activate"
SCROLL_EVENT = "scroll-event"
def _create_menu(quit_handler):
# per-device menu entries will be generated as-needed
menu = Gtk.Menu()
@@ -126,8 +132,7 @@ def _scroll(tray_icon, event, direction=None):
_picked_device = None
_picked_device = candidate or _picked_device
if logger.isEnabledFor(logging.DEBUG):
logger.debug("scroll: picked %s", _picked_device)
logger.debug("scroll: picked %s", _picked_device)
_update_tray_icon()
@@ -147,8 +152,7 @@ try:
# treat unavailable versions the same as unavailable packages
raise ImportError from exc
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f"using {'Ayatana ' if ayatana_appindicator_found else ''}AppIndicator3")
logger.debug(f"using {'Ayatana ' if ayatana_appindicator_found else ''}AppIndicator3")
# Defense against AppIndicator3 bug that treats files in current directory as icon files
# https://bugs.launchpad.net/ubuntu/+source/libappindicator/+bug/1363277
@@ -172,7 +176,7 @@ try:
# ind.set_label(NAME.lower(), NAME.lower())
ind.set_menu(menu)
ind.connect("scroll-event", _scroll)
ind.connect(GtkSignal.SCROLL_EVENT.value, _scroll)
return ind
@@ -206,16 +210,15 @@ try:
GLib.timeout_add(10 * 1000, _icon.set_status, AppIndicator3.IndicatorStatus.ACTIVE)
except ImportError:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("using StatusIcon")
logger.debug("using StatusIcon")
def _create(menu):
icon = Gtk.StatusIcon.new_from_icon_name(icons.TRAY_INIT)
icon.set_name(NAME.lower())
icon.set_title(NAME)
icon.set_tooltip_text(NAME)
icon.connect("activate", window.toggle)
icon.connect("scroll-event", _scroll)
icon.connect(GtkSignal.ACTIVATE.value, window.toggle)
icon.connect(GtkSignal.SCROLL_EVENT.value, _scroll)
icon.connect(
"popup-menu",
lambda icon, button, time: menu.popup(None, None, icon.position_menu, icon, button, time),
@@ -311,8 +314,7 @@ def _pick_device_with_lowest_battery():
picked = info
picked_level = level or 0
if logger.isEnabledFor(logging.DEBUG):
logger.debug("picked device with lowest battery: %s", picked)
logger.debug("picked device with lowest battery: %s", picked)
return picked

View File

@@ -17,13 +17,15 @@
import logging
from enum import Enum
from enum import IntEnum
import gi
from gi.repository.GObject import TYPE_PYOBJECT
from logitech_receiver import hidpp10_constants
from logitech_receiver.common import LOGITECH_VENDOR_ID
from logitech_receiver.common import NamedInt
from logitech_receiver.common import NamedInts
from solaar import NAME
from solaar.i18n import _
@@ -54,12 +56,30 @@ try:
except (ValueError, AttributeError):
_CAN_SET_ROW_NONE = ""
# tree model columns
_COLUMN = NamedInts(PATH=0, NUMBER=1, ACTIVE=2, NAME=3, ICON=4, STATUS_TEXT=5, STATUS_ICON=6, DEVICE=7)
class Column(IntEnum):
"""Columns of tree model."""
PATH = 0
NUMBER = 1
ACTIVE = 2
NAME = 3
ICON = 4
STATUS_TEXT = 5
STATUS_ICON = 6
DEVICE = 7
_COLUMN_TYPES = (str, int, bool, str, str, str, str, TYPE_PYOBJECT)
_TREE_SEPATATOR = (None, 0, False, None, None, None, None, None)
assert len(_TREE_SEPATATOR) == len(_COLUMN_TYPES)
assert len(_COLUMN_TYPES) == len(_COLUMN)
assert len(_COLUMN_TYPES) == len(Column)
class GtkSignal(Enum):
CHANGED = "changed"
CLICKED = "clicked"
DELETE_EVENT = "delete-event"
def _new_button(label, icon_name=None, icon_size=_NORMAL_BUTTON_ICON_SIZE, tooltip=None, toggle=False, clicked=None):
@@ -71,7 +91,7 @@ def _new_button(label, icon_name=None, icon_size=_NORMAL_BUTTON_ICON_SIZE, toolt
c.pack_start(Gtk.Label(label=label), True, True, 0)
b.add(c)
if clicked is not None:
b.connect("clicked", clicked)
b.connect(GtkSignal.CLICKED.value, clicked)
if tooltip:
b.set_tooltip_text(tooltip)
if not label and icon_size < _NORMAL_BUTTON_ICON_SIZE:
@@ -238,21 +258,21 @@ def _create_tree(model):
tree.set_model(model)
def _is_separator(model, item, _ignore=None):
return model.get_value(item, _COLUMN.PATH) is None
return model.get_value(item, Column.PATH) is None
tree.set_row_separator_func(_is_separator, None)
icon_cell_renderer = Gtk.CellRendererPixbuf()
icon_cell_renderer.set_property("stock-size", _TREE_ICON_SIZE)
icon_column = Gtk.TreeViewColumn("Icon", icon_cell_renderer)
icon_column.add_attribute(icon_cell_renderer, "sensitive", _COLUMN.ACTIVE)
icon_column.add_attribute(icon_cell_renderer, "icon-name", _COLUMN.ICON)
icon_column.add_attribute(icon_cell_renderer, "sensitive", Column.ACTIVE)
icon_column.add_attribute(icon_cell_renderer, "icon-name", Column.ICON)
tree.append_column(icon_column)
name_cell_renderer = Gtk.CellRendererText()
name_column = Gtk.TreeViewColumn("device name", name_cell_renderer)
name_column.add_attribute(name_cell_renderer, "sensitive", _COLUMN.ACTIVE)
name_column.add_attribute(name_cell_renderer, "text", _COLUMN.NAME)
name_column.add_attribute(name_cell_renderer, "sensitive", Column.ACTIVE)
name_column.add_attribute(name_cell_renderer, "text", Column.NAME)
name_column.set_expand(True)
tree.append_column(name_column)
tree.set_expander_column(name_column)
@@ -261,16 +281,16 @@ def _create_tree(model):
status_cell_renderer.set_property("scale", 0.85)
status_cell_renderer.set_property("xalign", 1)
status_column = Gtk.TreeViewColumn("status text", status_cell_renderer)
status_column.add_attribute(status_cell_renderer, "sensitive", _COLUMN.ACTIVE)
status_column.add_attribute(status_cell_renderer, "text", _COLUMN.STATUS_TEXT)
status_column.add_attribute(status_cell_renderer, "sensitive", Column.ACTIVE)
status_column.add_attribute(status_cell_renderer, "text", Column.STATUS_TEXT)
status_column.set_expand(True)
tree.append_column(status_column)
battery_cell_renderer = Gtk.CellRendererPixbuf()
battery_cell_renderer.set_property("stock-size", _TREE_ICON_SIZE)
battery_column = Gtk.TreeViewColumn("status icon", battery_cell_renderer)
battery_column.add_attribute(battery_cell_renderer, "sensitive", _COLUMN.ACTIVE)
battery_column.add_attribute(battery_cell_renderer, "icon-name", _COLUMN.STATUS_ICON)
battery_column.add_attribute(battery_cell_renderer, "sensitive", Column.ACTIVE)
battery_column.add_attribute(battery_cell_renderer, "icon-name", Column.STATUS_ICON)
tree.append_column(battery_column)
return tree
@@ -283,7 +303,7 @@ def _create_window_layout():
assert _empty is not None
assert _tree.get_selection().get_mode() == Gtk.SelectionMode.SINGLE
_tree.get_selection().connect("changed", _device_selected)
_tree.get_selection().connect(GtkSignal.CHANGED.value, _device_selected)
tree_scroll = Gtk.ScrolledWindow()
tree_scroll.add(_tree)
@@ -328,7 +348,7 @@ def _create(delete_action):
window = Gtk.Window()
window.set_title(NAME)
window.set_role("status-window")
window.connect("delete-event", delete_action)
window.connect(GtkSignal.DELETE_EVENT.value, delete_action)
vbox = _create_window_layout()
window.add(vbox)
@@ -348,20 +368,20 @@ def _create(delete_action):
def _find_selected_device():
selection = _tree.get_selection()
model, item = selection.get_selected()
return model.get_value(item, _COLUMN.DEVICE) if item else None
return model.get_value(item, Column.DEVICE) if item else None
def _find_selected_device_id():
selection = _tree.get_selection()
model, item = selection.get_selected()
if item:
return _model.get_value(item, _COLUMN.PATH), _model.get_value(item, _COLUMN.NUMBER)
return _model.get_value(item, Column.PATH), _model.get_value(item, Column.NUMBER)
# triggered by changing selection in the tree
def _device_selected(selection):
model, item = selection.get_selected()
device = model.get_value(item, _COLUMN.DEVICE) if item else None
device = model.get_value(item, Column.DEVICE) if item else None
if device:
_update_info_panel(device, full=True)
else:
@@ -381,7 +401,7 @@ def _receiver_row(receiver_path, receiver=None):
item = _model.get_iter_first()
while item:
# first row matching the path must be the receiver one
if _model.get_value(item, _COLUMN.PATH) == receiver_path:
if _model.get_value(item, Column.PATH) == receiver_path:
return item
item = _model.iter_next(item)
@@ -391,8 +411,7 @@ def _receiver_row(receiver_path, receiver=None):
status_icon = None
row_data = (receiver_path, 0, True, receiver.name, icon_name, status_text, status_icon, receiver)
assert len(row_data) == len(_TREE_SEPATATOR)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("new receiver row %s", row_data)
logger.debug("new receiver row %s", row_data)
item = _model.append(None, row_data)
if _TREE_SEPATATOR:
_model.append(None, _TREE_SEPATATOR)
@@ -415,13 +434,13 @@ def _device_row(receiver_path, device_number, device=None):
item = _model.iter_children(receiver_row)
new_child_index = 0
while item:
if _model.get_value(item, _COLUMN.PATH) != receiver_path:
if _model.get_value(item, Column.PATH) != receiver_path:
logger.warning(
"path for device row %s different from path for receiver %s",
_model.get_value(item, _COLUMN.PATH),
_model.get_value(item, Column.PATH),
receiver_path,
)
item_number = _model.get_value(item, _COLUMN.NUMBER)
item_number = _model.get_value(item, Column.NUMBER)
if item_number == device_number:
return item
if item_number > device_number:
@@ -445,8 +464,7 @@ def _device_row(receiver_path, device_number, device=None):
device,
)
assert len(row_data) == len(_TREE_SEPATATOR)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("new device row %s at index %d", row_data, new_child_index)
logger.debug("new device row %s at index %d", row_data, new_child_index)
item = _model.insert(receiver_row, new_child_index, row_data)
return item or None
@@ -538,17 +556,15 @@ def _update_details(button):
flag_bits = device.notification_flags
if flag_bits is not None:
flag_names = (
(f"({_('none')})",) if flag_bits == 0 else hidpp10_constants.NOTIFICATION_FLAG.flag_names(flag_bits)
)
yield _("Notifications"), f"\n{' ':15}".join(flag_names)
flag_names = hidpp10_constants.flags_to_str(flag_bits, fallback=f"({_('none')})")
yield _("Notifications"), flag_names
def _set_details(text):
_details._text.set_markup(text)
def _make_text(items):
text = "\n".join("%-13s: %s" % (name, value) for name, value in items)
return "<small><tt>" + text + "</tt></small>"
return f"<small><tt>{text}</tt></small>"
def _displayable_items(items):
for name, value in items:
@@ -833,9 +849,9 @@ def update(device, need_popup=False, refresh=False):
item = _receiver_row(device.path, device if is_alive else None)
if is_alive and item:
was_pairing = bool(_model.get_value(item, _COLUMN.STATUS_ICON))
was_pairing = bool(_model.get_value(item, Column.STATUS_ICON))
is_pairing = (not device.isDevice) and bool(device.pairing.lock_open)
_model.set_value(item, _COLUMN.STATUS_ICON, "network-wireless" if is_pairing else _CAN_SET_ROW_NONE)
_model.set_value(item, Column.STATUS_ICON, "network-wireless" if is_pairing else _CAN_SET_ROW_NONE)
if selected_device_id == (device.path, 0):
full_update = need_popup or was_pairing != is_pairing
@@ -850,7 +866,7 @@ def update(device, need_popup=False, refresh=False):
else:
path = device.receiver.path if device.receiver is not None else device.path
assert device.number is not None and device.number >= 0, "invalid device number" + str(device.number)
assert device.number is not None and device.number >= 0, f"invalid device number{str(device.number)}"
item = _device_row(path, device.number, device if bool(device) else None)
if bool(device) and item:
@@ -864,15 +880,15 @@ def update(device, need_popup=False, refresh=False):
def update_device(device, item, selected_device_id, need_popup, full=False):
was_online = _model.get_value(item, _COLUMN.ACTIVE)
was_online = _model.get_value(item, Column.ACTIVE)
is_online = bool(device.online)
_model.set_value(item, _COLUMN.ACTIVE, is_online)
_model.set_value(item, Column.ACTIVE, is_online)
battery_level = device.battery_info.level if device.battery_info is not None else None
battery_voltage = device.battery_info.voltage if device.battery_info is not None else None
if battery_level is None:
_model.set_value(item, _COLUMN.STATUS_TEXT, _CAN_SET_ROW_NONE)
_model.set_value(item, _COLUMN.STATUS_ICON, _CAN_SET_ROW_NONE)
_model.set_value(item, Column.STATUS_TEXT, _CAN_SET_ROW_NONE)
_model.set_value(item, Column.STATUS_ICON, _CAN_SET_ROW_NONE)
else:
if battery_voltage is not None and False: # Use levels instead of voltage here
status_text = f"{int(battery_voltage)}mV"
@@ -880,13 +896,13 @@ def update_device(device, item, selected_device_id, need_popup, full=False):
status_text = _(str(battery_level))
else:
status_text = f"{int(battery_level)}%"
_model.set_value(item, _COLUMN.STATUS_TEXT, status_text)
_model.set_value(item, Column.STATUS_TEXT, status_text)
charging = device.battery_info.charging() if device.battery_info is not None else None
icon_name = icons.battery(battery_level, charging)
_model.set_value(item, _COLUMN.STATUS_ICON, icon_name)
_model.set_value(item, Column.STATUS_ICON, icon_name)
_model.set_value(item, _COLUMN.NAME, device.codename)
_model.set_value(item, Column.NAME, device.codename)
if selected_device_id is None or need_popup:
select(device.receiver.path if device.receiver else device.path, device.number)
@@ -901,7 +917,7 @@ def find_device(serial):
def check(_store, _treepath, row):
nonlocal result
device = _model.get_value(row, _COLUMN.DEVICE)
device = _model.get_value(row, Column.DEVICE)
if device and device.kind and (device.unitId == serial or device.serial == serial):
result = device
return True

View File

@@ -1 +1 @@
1.1.14
1.1.19

View File

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

2162
po/ka.po Normal file

File diff suppressed because it is too large Load Diff

984
po/pl.po

File diff suppressed because it is too large Load Diff

1085
po/ru.po

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1890
po/sv.po

File diff suppressed because it is too large Load Diff

1660
po/uk.po Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
repo=pwr-Solaar/Solaar

View File

@@ -5,7 +5,7 @@
# because they could perform firmware updates.
KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"
ACTION != "add", GOTO="solaar_end"
ACTION == "remove", GOTO="solaar_end"
SUBSYSTEM != "hidraw", GOTO="solaar_end"
# USB-connected Logitech receivers and devices

View File

@@ -4,7 +4,7 @@
# Allowing users to write to the device is potentially dangerous
# because they could perform firmware updates.
ACTION != "add", GOTO="solaar_end"
ACTION == "remove", GOTO="solaar_end"
SUBSYSTEM != "hidraw", GOTO="solaar_end"
# USB-connected Logitech receivers and devices

View File

@@ -76,12 +76,13 @@ setup(
"psutil (>= 5.4.3)",
'dbus-python ; platform_system=="Linux"',
"PyGObject",
"typing_extensions",
],
extras_require={
"report-descriptor": ["hid-parser"],
"desktop-notifications": ["Notify (>= 0.7)"],
"git-commit": ["python-git-info"],
"test": ["pytest", "pytest-mock", "pytest-cov", "typing_extensions"],
"test": ["pytest", "pytest-mock", "pytest-cov"],
"dev": ["ruff"],
},
package_dir={"": "lib"},

View File

@@ -7,7 +7,7 @@ Comment[ru]=Управление приёмником Logitech Unifying Receiver
Comment[de]=Logitech Unifying Empfänger Geräteverwaltung
Comment[es]=Administrador de periféricos de Logitech Receptor Unifying
Comment[pl]=Menedżer urządzeń peryferyjnych odbiornika Logitech Unifying
Comment[sv]=Kringutrustningshanteraring för Logitech Unifying-mottagare
Comment[sv]=Kringutrustningshanterare för Logitech Unifying-mottagare
Comment[zh_CN]=罗技优联设备管理器
Comment[zh_TW]=羅技Unifying 裝置管理器
Comment[zh_HK]=羅技Unifying 裝置管理器

View File

@@ -0,0 +1,190 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="100"
height="100"
id="svg5"
sodipodi:docname="solaar-attention.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview5"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showguides="true"
inkscape:zoom="6.355"
inkscape:cx="-0.70810385"
inkscape:cy="42.800944"
inkscape:current-layer="g3-7" /><title
id="title1">Solaar attention</title><defs
id="defs4"><linearGradient
id="gradient_blue"><stop
style="stop-color:#009099;stop-opacity:1"
offset="0"
id="stop1" /><stop
style="stop-color:#00a899;stop-opacity:0.9"
offset="1"
id="stop2" /></linearGradient><linearGradient
id="gradient_yellow"><stop
style="stop-color:#f0ff18;stop-opacity:1"
offset="0"
id="stop3" /><stop
style="stop-color:#f3ff2b;stop-opacity:0.94901961;"
offset="0.49391085"
id="stop6" /><stop
style="stop-color:#f8ff40;stop-opacity:0.9"
offset="1"
id="stop4" /></linearGradient><linearGradient
x1="5"
y1="50"
x2="95"
y2="50"
id="gradient_rect"
xlink:href="#gradient_yellow"
gradientUnits="userSpaceOnUse" /><linearGradient
x1="37"
y1="50"
x2="63"
y2="50"
id="gradient_dot"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse" /><linearGradient
id="gradient_black"><stop
style="stop-color:#000000;stop-opacity:1"
offset="0"
id="stop1-47" /><stop
style="stop-color:#000000;stop-opacity:0.9"
offset="1"
id="stop2-6" /></linearGradient><linearGradient
x1="5"
y1="50"
x2="95"
y2="50"
id="gradient_rect-3"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><linearGradient
x1="37"
y1="50"
x2="63"
y2="50"
id="gradient_dot-1"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-5"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-7"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect18"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
x1="50"
y1="5.5"
x2="50"
y2="94.5"
id="gradient_rect-8"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-110.09871,-4.4507114)" /><linearGradient
x1="50.01833"
y1="5"
x2="50.01833"
y2="95"
id="gradient_dot-5"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse" /></defs><metadata
id="metadata4"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title>Solaar attention</dc:title><dc:creator><cc:Agent><dc:title>Daniel Pavel</dc:title></cc:Agent></dc:creator><cc:license
rdf:resource="http://creativecommons.org/licenses/by-sa/3.0/" /><dc:date>2013-06-25</dc:date><dc:identifier>solaar-attention</dc:identifier></cc:Work><cc:License
rdf:about="http://creativecommons.org/licenses/by-sa/3.0/"><cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" /><cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" /><cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /><cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" /></cc:License></rdf:RDF></metadata><g
id="g3-7"><rect
style="fill:#f3ff2b;fill-opacity:0.94901961;stroke:none;stroke-width:3;stroke-linejoin:round;stroke-opacity:0.996078;paint-order:fill markers stroke"
id="rect17"
width="90"
height="90"
x="5"
y="5"
rx="10"
ry="10" /><path
id="path11-5"
style="display:inline;fill:#131313;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:0.905882;paint-order:markers fill stroke"
d="m 36.326172,18.050781 c -0.653587,3.07e-4 -1.295564,0.172759 -1.861328,0.5 l -3.260348,1.886206 c -0.369666,0.213862 -0.976947,0.555623 -1.294346,0.840227 -1.095169,0.982014 -1.50919,2.530927 -1.050063,3.928399 0.133086,0.405083 0.488934,1.004291 0.702637,1.37405 l 5.656751,9.787566 c 0.62092,1.074344 0.455955,2.70357 -0.225142,3.739812 -0.385108,0.585915 -0.736584,1.1959 -1.052473,1.826946 C 33.386764,43.042894 32.056577,44 30.815708,44 H 19.405457 c -0.377101,0 -0.99151,-0.0065 -1.361861,0.06167 C 16.311702,44.380584 14.999644,45.89844 15,47.722656 v 4.554688 C 14.999599,54.333476 16.666524,56.000401 18.722656,56 h 12.093052 c 1.240869,0 2.571057,0.957108 3.126154,2.066017 0.315889,0.631045 0.667365,1.24103 1.052473,1.826945 0.681096,1.03624 0.84606,2.665465 0.22514,3.739809 l -6.043694,10.457073 c -1.028907,1.779844 -0.420325,4.056781 1.359375,5.085937 l 3.929688,2.273438 c 1.779707,1.029944 4.057417,0.421155 5.085937,-1.359375 l 6.043694,-10.457073 c 0.62092,-1.074344 2.114654,-1.744293 3.352326,-1.666259 C 49.29502,67.988467 49.64608,68 50,68 c 0.353931,0 0.705,-0.01153 1.053229,-0.03349 1.237655,-0.07804 2.731376,0.591916 3.352296,1.66626 l 6.043694,10.457073 c 1.02852,1.78053 3.30623,2.389319 5.085937,1.359375 l 3.929688,-2.273438 c 1.7797,-1.029156 2.388282,-3.306093 1.359375,-5.085937 L 64.780525,63.632771 c -0.62092,-1.074344 -0.455955,-2.70357 0.225142,-3.739812 0.385108,-0.585915 0.736584,-1.1959 1.052473,-1.826946 C 66.613236,56.957106 67.943423,56 69.184291,56 H 81.277344 C 83.333476,56.000401 85.000401,54.333476 85,52.277344 V 47.722656 C 85.000401,45.666524 83.333476,43.999599 81.277344,44 H 69.184291 c -1.240868,0 -2.571056,-0.957108 -3.126153,-2.066017 -0.315889,-0.631045 -0.667365,-1.24103 -1.052473,-1.826945 -0.681096,-1.03624 -0.84606,-2.665465 -0.22514,-3.739809 l 6.043694,-10.457073 c 1.028907,-1.779844 0.420325,-4.056781 -1.359375,-5.085937 l -3.929688,-2.273438 c -1.779707,-1.029944 -4.057417,-0.421155 -5.085937,1.359375 l -6.043694,10.457073 c -0.62092,1.074344 -2.114654,1.744293 -3.352326,1.666259 C 50.70498,32.011533 50.35392,32 50,32 c -0.353931,0 -0.705,0.01153 -1.053229,0.03349 -1.237655,0.07804 -2.731376,-0.591916 -3.352296,-1.66626 L 39.550781,19.910156 C 38.885414,18.758657 37.656082,18.0498 36.326172,18.050781 Z M 50,38.746094 C 56.215539,38.745663 61.254337,43.784461 61.253906,50 61.254337,56.215539 56.215539,61.254337 50,61.253906 43.784461,61.254337 38.745663,56.215539 38.746094,50 38.745663,43.784461 43.784461,38.745663 50,38.746094 Z"
inkscape:path-effect="#path-effect20-7"
inkscape:original-d="m 36.326172,18.050781 a 3.721056,3.721056 0 0 0 -1.861328,0.5 l -3.929688,2.273438 a 3.7228036,3.7228036 0 0 0 -1.359375,5.085937 L 36.34375,38.3125 C 34.921877,39.972287 33.808215,41.898777 33.0625,44 H 18.722656 A 3.7219297,3.7219297 0 0 0 15,47.722656 v 4.554688 A 3.7219297,3.7219297 0 0 0 18.722656,56 H 33.0625 c 0.745715,2.101223 1.859377,4.027713 3.28125,5.6875 l -7.167969,12.402344 a 3.7228036,3.7228036 0 0 0 1.359375,5.085937 l 3.929688,2.273438 a 3.721056,3.721056 0 0 0 5.085937,-1.359375 L 46.71875,67.6875 C 47.784179,67.884035 48.877705,68 50,68 c 1.122295,0 2.215821,-0.115965 3.28125,-0.3125 l 7.167969,12.402344 a 3.721056,3.721056 0 0 0 5.085937,1.359375 l 3.929688,-2.273438 a 3.7228036,3.7228036 0 0 0 1.359375,-5.085937 L 63.65625,61.6875 C 65.078123,60.027713 66.191785,58.101223 66.9375,56 H 81.277344 A 3.7219297,3.7219297 0 0 0 85,52.277344 V 47.722656 A 3.7219297,3.7219297 0 0 0 81.277344,44 H 66.9375 c -0.745715,-2.101223 -1.859377,-4.027713 -3.28125,-5.6875 l 7.167969,-12.402344 a 3.7228036,3.7228036 0 0 0 -1.359375,-5.085937 l -3.929688,-2.273438 a 3.721056,3.721056 0 0 0 -5.085937,1.359375 L 53.28125,32.3125 C 52.215821,32.115965 51.122295,32 50,32 c -1.122295,0 -2.215821,0.115965 -3.28125,0.3125 L 39.550781,19.910156 A 3.721056,3.721056 0 0 0 36.326172,18.050781 Z M 50,38.746094 A 11.253125,11.253125 0 0 1 61.253906,50 11.253125,11.253125 0 0 1 50,61.253906 11.253125,11.253125 0 0 1 38.746094,50 11.253125,11.253125 0 0 1 50,38.746094 Z" /></g></svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="100"
height="100"
id="svg3"
sodipodi:docname="solaar.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview3"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showguides="true"
inkscape:zoom="8"
inkscape:cx="0.5625"
inkscape:cy="79.25"
inkscape:current-layer="svg3" /><title
id="title1">Solaar</title><defs
id="defs2"><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect18"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="linearGradient17"
inkscape:collect="always"><stop
style="stop-color:#009099;stop-opacity:1;"
offset="0"
id="stop17" /><stop
style="stop-color:#00a899;stop-opacity:1;"
offset="1"
id="stop18" /></linearGradient><linearGradient
id="gradient_blue"><stop
style="stop-color:#009099;stop-opacity:1"
offset="0"
id="stop1" /><stop
style="stop-color:#00a899;stop-opacity:0.9"
offset="1"
id="stop2" /></linearGradient><linearGradient
x1="50"
y1="5.5"
x2="50"
y2="94.5"
id="gradient_rect"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-110.09871,-4.4507114)" /><linearGradient
x1="50.01833"
y1="5"
x2="50.01833"
y2="95"
id="gradient_dot"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient17"
id="linearGradient18"
x1="50"
y1="5"
x2="50"
y2="95"
gradientUnits="userSpaceOnUse" /></defs><metadata
id="metadata2"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title>Solaar</dc:title><dc:creator><cc:Agent><dc:title>Daniel Pavel</dc:title></cc:Agent></dc:creator><cc:license
rdf:resource="http://creativecommons.org/licenses/by-sa/3.0/" /><dc:date>2013-06-25</dc:date><dc:identifier>solaar</dc:identifier></cc:Work><cc:License
rdf:about="http://creativecommons.org/licenses/by-sa/3.0/"><cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" /><cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" /><cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /><cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" /></cc:License></rdf:RDF></metadata><g
id="g3"><rect
style="fill:url(#linearGradient18);fill-opacity:1;stroke:none;stroke-width:3;stroke-linejoin:round;stroke-opacity:0.996078;paint-order:fill markers stroke"
id="rect17"
width="90"
height="90"
x="5"
y="5"
rx="10"
ry="10" /><path
id="path11"
style="display:inline;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:0.905882;paint-order:markers fill stroke"
d="m 36.326172,18.050781 c -0.653587,3.07e-4 -1.295564,0.172759 -1.861328,0.5 l -3.260348,1.886206 c -0.369666,0.213862 -0.976947,0.555623 -1.294346,0.840227 -1.095169,0.982014 -1.50919,2.530927 -1.050063,3.928399 0.133086,0.405083 0.488934,1.004291 0.702637,1.37405 l 5.656751,9.787566 c 0.62092,1.074344 0.455955,2.70357 -0.225142,3.739812 -0.385108,0.585915 -0.736584,1.1959 -1.052473,1.826946 C 33.386764,43.042894 32.056577,44 30.815708,44 H 19.405457 c -0.377101,0 -0.99151,-0.0065 -1.361861,0.06167 C 16.311702,44.380584 14.999644,45.89844 15,47.722656 v 4.554688 C 14.999599,54.333476 16.666524,56.000401 18.722656,56 h 12.093052 c 1.240869,0 2.571057,0.957108 3.126154,2.066017 0.315889,0.631045 0.667365,1.24103 1.052473,1.826945 0.681096,1.03624 0.84606,2.665465 0.22514,3.739809 l -6.043694,10.457073 c -1.028907,1.779844 -0.420325,4.056781 1.359375,5.085937 l 3.929688,2.273438 c 1.779707,1.029944 4.057417,0.421155 5.085937,-1.359375 l 6.043694,-10.457073 c 0.62092,-1.074344 2.114654,-1.744293 3.352326,-1.666259 C 49.29502,67.988467 49.64608,68 50,68 c 0.353931,0 0.705,-0.01153 1.053229,-0.03349 1.237655,-0.07804 2.731376,0.591916 3.352296,1.66626 l 6.043694,10.457073 c 1.02852,1.78053 3.30623,2.389319 5.085937,1.359375 l 3.929688,-2.273438 c 1.7797,-1.029156 2.388282,-3.306093 1.359375,-5.085937 L 64.780525,63.632771 c -0.62092,-1.074344 -0.455955,-2.70357 0.225142,-3.739812 0.385108,-0.585915 0.736584,-1.1959 1.052473,-1.826946 C 66.613236,56.957106 67.943423,56 69.184291,56 H 81.277344 C 83.333476,56.000401 85.000401,54.333476 85,52.277344 V 47.722656 C 85.000401,45.666524 83.333476,43.999599 81.277344,44 H 69.184291 c -1.240868,0 -2.571056,-0.957108 -3.126153,-2.066017 -0.315889,-0.631045 -0.667365,-1.24103 -1.052473,-1.826945 -0.681096,-1.03624 -0.84606,-2.665465 -0.22514,-3.739809 l 6.043694,-10.457073 c 1.028907,-1.779844 0.420325,-4.056781 -1.359375,-5.085937 l -3.929688,-2.273438 c -1.779707,-1.029944 -4.057417,-0.421155 -5.085937,1.359375 l -6.043694,10.457073 c -0.62092,1.074344 -2.114654,1.744293 -3.352326,1.666259 C 50.70498,32.011533 50.35392,32 50,32 c -0.353931,0 -0.705,0.01153 -1.053229,0.03349 -1.237655,0.07804 -2.731376,-0.591916 -3.352296,-1.66626 L 39.550781,19.910156 C 38.885414,18.758657 37.656082,18.0498 36.326172,18.050781 Z M 50,38.746094 C 56.215539,38.745663 61.254337,43.784461 61.253906,50 61.254337,56.215539 56.215539,61.254337 50,61.253906 43.784461,61.254337 38.745663,56.215539 38.746094,50 38.745663,43.784461 43.784461,38.745663 50,38.746094 Z"
inkscape:path-effect="#path-effect20"
inkscape:original-d="m 36.326172,18.050781 a 3.721056,3.721056 0 0 0 -1.861328,0.5 l -3.929688,2.273438 a 3.7228036,3.7228036 0 0 0 -1.359375,5.085937 L 36.34375,38.3125 C 34.921877,39.972287 33.808215,41.898777 33.0625,44 H 18.722656 A 3.7219297,3.7219297 0 0 0 15,47.722656 v 4.554688 A 3.7219297,3.7219297 0 0 0 18.722656,56 H 33.0625 c 0.745715,2.101223 1.859377,4.027713 3.28125,5.6875 l -7.167969,12.402344 a 3.7228036,3.7228036 0 0 0 1.359375,5.085937 l 3.929688,2.273438 a 3.721056,3.721056 0 0 0 5.085937,-1.359375 L 46.71875,67.6875 C 47.784179,67.884035 48.877705,68 50,68 c 1.122295,0 2.215821,-0.115965 3.28125,-0.3125 l 7.167969,12.402344 a 3.721056,3.721056 0 0 0 5.085937,1.359375 l 3.929688,-2.273438 a 3.7228036,3.7228036 0 0 0 1.359375,-5.085937 L 63.65625,61.6875 C 65.078123,60.027713 66.191785,58.101223 66.9375,56 H 81.277344 A 3.7219297,3.7219297 0 0 0 85,52.277344 V 47.722656 A 3.7219297,3.7219297 0 0 0 81.277344,44 H 66.9375 c -0.745715,-2.101223 -1.859377,-4.027713 -3.28125,-5.6875 l 7.167969,-12.402344 a 3.7228036,3.7228036 0 0 0 -1.359375,-5.085937 l -3.929688,-2.273438 a 3.721056,3.721056 0 0 0 -5.085937,1.359375 L 53.28125,32.3125 C 52.215821,32.115965 51.122295,32 50,32 c -1.122295,0 -2.215821,0.115965 -3.28125,0.3125 L 39.550781,19.910156 A 3.721056,3.721056 0 0 0 36.326172,18.050781 Z M 50,38.746094 A 11.253125,11.253125 0 0 1 61.253906,50 11.253125,11.253125 0 0 1 50,61.253906 11.253125,11.253125 0 0 1 38.746094,50 11.253125,11.253125 0 0 1 50,38.746094 Z" /></g></svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,234 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="100"
height="100"
id="svg3"
sodipodi:docname="solaar-init.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview3"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showguides="true"
inkscape:zoom="7.297342"
inkscape:cx="15.005464"
inkscape:cy="46.86638"
inkscape:current-layer="svg3"><sodipodi:guide
position="33.74894,95"
orientation="0,-1"
id="guide3"
inkscape:locked="false" /><sodipodi:guide
position="5,53.923662"
orientation="1,0"
id="guide4"
inkscape:locked="false" /><sodipodi:guide
position="67.152677,5"
orientation="0,-1"
id="guide5"
inkscape:locked="false" /><sodipodi:guide
position="95,74.370155"
orientation="1,0"
id="guide6"
inkscape:locked="false" /></sodipodi:namedview><title
id="title1">Solaar init</title><defs
id="defs2"><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect7"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="gradient_gray"><stop
style="stop-color:#848484;stop-opacity:1"
offset="0"
id="stop1" /><stop
style="stop-color:#9c9c9c;stop-opacity:0.9"
offset="1"
id="stop2" /></linearGradient><linearGradient
x1="5"
y1="50"
x2="95"
y2="50"
id="gradient_rect"
xlink:href="#gradient_gray"
gradientUnits="userSpaceOnUse" /><linearGradient
x1="37"
y1="50"
x2="63"
y2="50"
id="gradient_dot"
xlink:href="#gradient_gray"
gradientUnits="userSpaceOnUse" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-7"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="gradient_black"><stop
style="stop-color:#000000;stop-opacity:1"
offset="0"
id="stop1-3" /><stop
style="stop-color:#000000;stop-opacity:0.9"
offset="1"
id="stop2-3" /></linearGradient><linearGradient
x1="5"
y1="50"
x2="95"
y2="50"
id="gradient_rect-3"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><linearGradient
x1="37"
y1="50"
x2="63"
y2="50"
id="gradient_dot-8"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-5"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-7-6"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect18"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="gradient_blue"><stop
style="stop-color:#009099;stop-opacity:1"
offset="0"
id="stop1-4" /><stop
style="stop-color:#00a899;stop-opacity:0.9"
offset="1"
id="stop2-1" /></linearGradient><linearGradient
x1="50"
y1="5.5"
x2="50"
y2="94.5"
id="gradient_rect-8"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-110.09871,-4.4507114)" /><linearGradient
x1="50.01833"
y1="5"
x2="50.01833"
y2="95"
id="gradient_dot-5"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse" /></defs><metadata
id="metadata2"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title>Solaar init</dc:title><dc:creator><cc:Agent><dc:title>Daniel Pavel</dc:title></cc:Agent></dc:creator><cc:license
rdf:resource="http://creativecommons.org/licenses/by-sa/3.0/" /><dc:date>2013-06-25</dc:date><dc:identifier>solaar-init</dc:identifier></cc:Work><cc:License
rdf:about="http://creativecommons.org/licenses/by-sa/3.0/"><cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" /><cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" /><cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /><cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" /></cc:License></rdf:RDF></metadata><rect
style="fill:#808080;fill-opacity:1;stroke:none;stroke-width:3;stroke-linejoin:round;stroke-opacity:0.996078;paint-order:fill markers stroke"
id="rect17"
width="90"
height="90"
x="5"
y="5"
rx="10"
ry="10" /><path
id="path11-5"
style="display:inline;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:0.905882;paint-order:markers fill stroke"
d="m 36.326172,18.050781 c -0.653587,3.07e-4 -1.295564,0.172759 -1.861328,0.5 l -3.260348,1.886206 c -0.369666,0.213862 -0.976947,0.555623 -1.294346,0.840227 -1.095169,0.982014 -1.50919,2.530927 -1.050063,3.928399 0.133086,0.405083 0.488934,1.004291 0.702637,1.37405 l 5.656751,9.787566 c 0.62092,1.074344 0.455955,2.70357 -0.225142,3.739812 -0.385108,0.585915 -0.736584,1.1959 -1.052473,1.826946 C 33.386764,43.042894 32.056577,44 30.815708,44 H 19.405457 c -0.377101,0 -0.99151,-0.0065 -1.361861,0.06167 C 16.311702,44.380584 14.999644,45.89844 15,47.722656 v 4.554688 C 14.999599,54.333476 16.666524,56.000401 18.722656,56 h 12.093052 c 1.240869,0 2.571057,0.957108 3.126154,2.066017 0.315889,0.631045 0.667365,1.24103 1.052473,1.826945 0.681096,1.03624 0.84606,2.665465 0.22514,3.739809 l -6.043694,10.457073 c -1.028907,1.779844 -0.420325,4.056781 1.359375,5.085937 l 3.929688,2.273438 c 1.779707,1.029944 4.057417,0.421155 5.085937,-1.359375 l 6.043694,-10.457073 c 0.62092,-1.074344 2.114654,-1.744293 3.352326,-1.666259 C 49.29502,67.988467 49.64608,68 50,68 c 0.353931,0 0.705,-0.01153 1.053229,-0.03349 1.237655,-0.07804 2.731376,0.591916 3.352296,1.66626 l 6.043694,10.457073 c 1.02852,1.78053 3.30623,2.389319 5.085937,1.359375 l 3.929688,-2.273438 c 1.7797,-1.029156 2.388282,-3.306093 1.359375,-5.085937 L 64.780525,63.632771 c -0.62092,-1.074344 -0.455955,-2.70357 0.225142,-3.739812 0.385108,-0.585915 0.736584,-1.1959 1.052473,-1.826946 C 66.613236,56.957106 67.943423,56 69.184291,56 H 81.277344 C 83.333476,56.000401 85.000401,54.333476 85,52.277344 V 47.722656 C 85.000401,45.666524 83.333476,43.999599 81.277344,44 H 69.184291 c -1.240868,0 -2.571056,-0.957108 -3.126153,-2.066017 -0.315889,-0.631045 -0.667365,-1.24103 -1.052473,-1.826945 -0.681096,-1.03624 -0.84606,-2.665465 -0.22514,-3.739809 l 6.043694,-10.457073 c 1.028907,-1.779844 0.420325,-4.056781 -1.359375,-5.085937 l -3.929688,-2.273438 c -1.779707,-1.029944 -4.057417,-0.421155 -5.085937,1.359375 l -6.043694,10.457073 c -0.62092,1.074344 -2.114654,1.744293 -3.352326,1.666259 C 50.70498,32.011533 50.35392,32 50,32 c -0.353931,0 -0.705,0.01153 -1.053229,0.03349 -1.237655,0.07804 -2.731376,-0.591916 -3.352296,-1.66626 L 39.550781,19.910156 C 38.885414,18.758657 37.656082,18.0498 36.326172,18.050781 Z M 50,38.746094 C 56.215539,38.745663 61.254337,43.784461 61.253906,50 61.254337,56.215539 56.215539,61.254337 50,61.253906 43.784461,61.254337 38.745663,56.215539 38.746094,50 38.745663,43.784461 43.784461,38.745663 50,38.746094 Z"
inkscape:path-effect="#path-effect20-7"
inkscape:original-d="m 36.326172,18.050781 a 3.721056,3.721056 0 0 0 -1.861328,0.5 l -3.929688,2.273438 a 3.7228036,3.7228036 0 0 0 -1.359375,5.085937 L 36.34375,38.3125 C 34.921877,39.972287 33.808215,41.898777 33.0625,44 H 18.722656 A 3.7219297,3.7219297 0 0 0 15,47.722656 v 4.554688 A 3.7219297,3.7219297 0 0 0 18.722656,56 H 33.0625 c 0.745715,2.101223 1.859377,4.027713 3.28125,5.6875 l -7.167969,12.402344 a 3.7228036,3.7228036 0 0 0 1.359375,5.085937 l 3.929688,2.273438 a 3.721056,3.721056 0 0 0 5.085937,-1.359375 L 46.71875,67.6875 C 47.784179,67.884035 48.877705,68 50,68 c 1.122295,0 2.215821,-0.115965 3.28125,-0.3125 l 7.167969,12.402344 a 3.721056,3.721056 0 0 0 5.085937,1.359375 l 3.929688,-2.273438 a 3.7228036,3.7228036 0 0 0 1.359375,-5.085937 L 63.65625,61.6875 C 65.078123,60.027713 66.191785,58.101223 66.9375,56 H 81.277344 A 3.7219297,3.7219297 0 0 0 85,52.277344 V 47.722656 A 3.7219297,3.7219297 0 0 0 81.277344,44 H 66.9375 c -0.745715,-2.101223 -1.859377,-4.027713 -3.28125,-5.6875 l 7.167969,-12.402344 a 3.7228036,3.7228036 0 0 0 -1.359375,-5.085937 l -3.929688,-2.273438 a 3.721056,3.721056 0 0 0 -5.085937,1.359375 L 53.28125,32.3125 C 52.215821,32.115965 51.122295,32 50,32 c -1.122295,0 -2.215821,0.115965 -3.28125,0.3125 L 39.550781,19.910156 A 3.721056,3.721056 0 0 0 36.326172,18.050781 Z M 50,38.746094 A 11.253125,11.253125 0 0 1 61.253906,50 11.253125,11.253125 0 0 1 50,61.253906 11.253125,11.253125 0 0 1 38.746094,50 11.253125,11.253125 0 0 1 50,38.746094 Z" /></svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,241 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="100"
height="100"
id="svg3"
sodipodi:docname="solaar-init.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview3"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showguides="true"
inkscape:zoom="7.297342"
inkscape:cx="15.005464"
inkscape:cy="46.86638"
inkscape:current-layer="svg3"><sodipodi:guide
position="33.74894,95"
orientation="0,-1"
id="guide3"
inkscape:locked="false" /><sodipodi:guide
position="5,53.923662"
orientation="1,0"
id="guide4"
inkscape:locked="false" /><sodipodi:guide
position="67.152677,5"
orientation="0,-1"
id="guide5"
inkscape:locked="false" /><sodipodi:guide
position="95,74.370155"
orientation="1,0"
id="guide6"
inkscape:locked="false" /></sodipodi:namedview><title
id="title1">Solaar init</title><defs
id="defs2"><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect7"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="gradient_gray"><stop
style="stop-color:#848484;stop-opacity:1"
offset="0"
id="stop1" /><stop
style="stop-color:#9c9c9c;stop-opacity:0.9"
offset="1"
id="stop2" /></linearGradient><linearGradient
x1="5"
y1="50"
x2="95"
y2="50"
id="gradient_rect"
xlink:href="#gradient_gray"
gradientUnits="userSpaceOnUse" /><linearGradient
x1="37"
y1="50"
x2="63"
y2="50"
id="gradient_dot"
xlink:href="#gradient_gray"
gradientUnits="userSpaceOnUse" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-7"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="gradient_black"><stop
style="stop-color:#000000;stop-opacity:1"
offset="0"
id="stop1-3" /><stop
style="stop-color:#000000;stop-opacity:0.9"
offset="1"
id="stop2-3" /></linearGradient><linearGradient
x1="5"
y1="50"
x2="95"
y2="50"
id="gradient_rect-3"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><linearGradient
x1="37"
y1="50"
x2="63"
y2="50"
id="gradient_dot-8"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-5"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-7-6"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect18"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="gradient_blue"><stop
style="stop-color:#009099;stop-opacity:1"
offset="0"
id="stop1-4" /><stop
style="stop-color:#00a899;stop-opacity:0.9"
offset="1"
id="stop2-1" /></linearGradient><linearGradient
x1="50"
y1="5.5"
x2="50"
y2="94.5"
id="gradient_rect-8"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-110.09871,-4.4507114)" /><linearGradient
x1="50.01833"
y1="5"
x2="50.01833"
y2="95"
id="gradient_dot-5"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse" /></defs><metadata
id="metadata2"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title>Solaar init</dc:title><dc:creator><cc:Agent><dc:title>Daniel Pavel</dc:title></cc:Agent></dc:creator><cc:license
rdf:resource="http://creativecommons.org/licenses/by-sa/3.0/" /><dc:date>2013-06-25</dc:date><dc:identifier>solaar-init</dc:identifier></cc:Work><cc:License
rdf:about="http://creativecommons.org/licenses/by-sa/3.0/"><cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" /><cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" /><cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /><cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" /></cc:License></rdf:RDF></metadata><rect
style="fill:#808080;fill-opacity:1;stroke:none;stroke-width:3;stroke-linejoin:round;stroke-opacity:0.996078;paint-order:fill markers stroke"
id="rect17"
width="90"
height="90"
x="5"
y="5"
rx="10"
ry="10" /><path
id="path6"
style="display:inline;fill:#131313;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.880197;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:0.905882;paint-order:markers fill stroke"
d="m 36.326172,18.050781 c -0.653587,3.07e-4 -1.295564,0.172759 -1.861328,0.5 l -3.260348,1.886206 c -0.369666,0.213862 -0.976947,0.555623 -1.294346,0.840227 -1.095169,0.982014 -1.50919,2.530927 -1.050063,3.928399 0.133086,0.405083 0.488934,1.004291 0.702637,1.37405 l 5.656751,9.787566 c 0.62092,1.074344 0.455955,2.70357 -0.225142,3.739812 -0.385108,0.585915 -0.736584,1.1959 -1.052473,1.826946 C 33.386764,43.042894 32.056577,44 30.815708,44 H 19.405457 c -0.377101,0 -0.99151,-0.0065 -1.361861,0.06167 C 16.311703,44.380584 14.999644,45.89844 15,47.722656 v 4.554688 C 14.999599,54.333476 16.666524,56.000401 18.722656,56 h 12.093052 c 1.240869,0 2.571057,0.957108 3.126154,2.066017 0.315889,0.631045 0.667365,1.24103 1.052473,1.826945 0.681096,1.03624 0.84606,2.665465 0.22514,3.739809 l -6.043694,10.457073 c -1.028907,1.779844 -0.420325,4.056781 1.359375,5.085937 l 3.929688,2.273438 c 1.779707,1.029944 4.057417,0.421155 5.085937,-1.359375 l 6.043694,-10.457073 c 0.62092,-1.074344 2.114654,-1.744293 3.352326,-1.666259 C 49.29502,67.988467 49.64608,68 50,68 c 0.353931,0 0.705,-0.01153 1.053229,-0.03349 1.237655,-0.07804 2.731376,0.591916 3.352296,1.66626 l 6.043694,10.457073 c 1.02852,1.78053 3.30623,2.389319 5.085937,1.359375 l 3.929688,-2.273438 c 1.7797,-1.029156 2.388282,-3.306093 1.359375,-5.085937 L 64.780525,63.632771 c -0.62092,-1.074344 -0.455955,-2.70357 0.225142,-3.739812 0.385108,-0.585915 0.736584,-1.1959 1.052473,-1.826946 C 66.613236,56.957106 67.943423,56 69.184291,56 H 81.277344 C 83.333476,56.000401 85.000401,54.333476 85,52.277344 V 47.722656 C 85.000401,45.666524 83.333476,43.999599 81.277344,44 H 69.184291 c -1.240868,0 -2.571056,-0.957108 -3.126153,-2.066017 -0.315889,-0.631045 -0.667365,-1.24103 -1.052473,-1.826945 -0.681096,-1.03624 -0.84606,-2.665465 -0.22514,-3.739809 l 6.043694,-10.457073 c 1.028907,-1.779844 0.420325,-4.056781 -1.359375,-5.085937 l -3.929688,-2.273438 c -1.779707,-1.029944 -4.057417,-0.421155 -5.085937,1.359375 l -6.043694,10.457073 c -0.62092,1.074344 -2.114654,1.744293 -3.352326,1.666259 C 50.70498,32.011533 50.35392,32 50,32 c -0.353931,0 -0.705,0.01153 -1.053229,0.03349 -1.237655,0.07804 -2.731376,-0.591916 -3.352296,-1.66626 L 39.550781,19.910156 C 38.885414,18.758657 37.656082,18.0498 36.326172,18.050781 Z m 13.347319,17.858481 c 6.215539,-4.32e-4 13.90713,7.251873 13.906698,13.467412 4.32e-4,6.21554 -7.19351,14.539222 -13.409049,14.538791 -6.215539,4.31e-4 -14.18,-6.257225 -14.179568,-12.472764 -4.32e-4,-6.215539 7.46638,-15.533871 13.681919,-15.533439 z"
inkscape:path-effect="#path-effect7"
inkscape:original-d="m 36.326172,18.050781 c -0.653587,3.07e-4 -1.295564,0.172759 -1.861328,0.5 l -3.929688,2.273438 c -1.7797,1.029156 -2.388282,3.306093 -1.359375,5.085937 L 36.34375,38.3125 C 34.921877,39.972287 33.808215,41.898777 33.0625,44 H 18.722656 C 16.666524,43.999599 14.999599,45.666524 15,47.722656 v 4.554688 C 14.999599,54.333476 16.666524,56.000401 18.722656,56 H 33.0625 c 0.745715,2.101223 1.859377,4.027713 3.28125,5.6875 l -7.167969,12.402344 c -1.028907,1.779844 -0.420325,4.056781 1.359375,5.085937 l 3.929688,2.273438 c 1.779707,1.029944 4.057417,0.421155 5.085937,-1.359375 L 46.71875,67.6875 C 47.784179,67.884035 48.877705,68 50,68 c 1.122295,0 2.215821,-0.115965 3.28125,-0.3125 l 7.167969,12.402344 c 1.02852,1.78053 3.30623,2.389319 5.085937,1.359375 l 3.929688,-2.273438 c 1.7797,-1.029156 2.388282,-3.306093 1.359375,-5.085937 L 63.65625,61.6875 C 65.078123,60.027713 66.191785,58.101223 66.9375,56 H 81.277344 C 83.333476,56.000401 85.000401,54.333476 85,52.277344 V 47.722656 C 85.000401,45.666524 83.333476,43.999599 81.277344,44 H 66.9375 c -0.745715,-2.101223 -1.859377,-4.027713 -3.28125,-5.6875 l 7.167969,-12.402344 c 1.028907,-1.779844 0.420325,-4.056781 -1.359375,-5.085937 l -3.929688,-2.273438 c -1.779707,-1.029944 -4.057417,-0.421155 -5.085937,1.359375 L 53.28125,32.3125 C 52.215821,32.115965 51.122295,32 50,32 c -1.122295,0 -2.215821,0.115965 -3.28125,0.3125 L 39.550781,19.910156 C 38.885414,18.758657 37.656082,18.0498 36.326172,18.050781 Z m 13.347319,17.858481 c 6.215539,-4.32e-4 13.90713,7.251873 13.906698,13.467412 4.32e-4,6.21554 -7.19351,14.539222 -13.409049,14.538791 -6.215539,4.31e-4 -14.18,-6.257225 -14.179568,-12.472764 -4.32e-4,-6.215539 7.46638,-15.533871 13.681919,-15.533439 z"
transform="matrix(1.0973922,0.29404534,-0.29404534,1.0973922,9.8327959,-19.571815)"
sodipodi:nodetypes="cccccccccccccccccsccccccccccccccccccscccccccc" /><path
id="path11-5"
style="display:inline;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:0.905882;paint-order:markers fill stroke"
d="m 36.326172,18.050781 c -0.653587,3.07e-4 -1.295564,0.172759 -1.861328,0.5 l -3.260348,1.886206 c -0.369666,0.213862 -0.976947,0.555623 -1.294346,0.840227 -1.095169,0.982014 -1.50919,2.530927 -1.050063,3.928399 0.133086,0.405083 0.488934,1.004291 0.702637,1.37405 l 5.656751,9.787566 c 0.62092,1.074344 0.455955,2.70357 -0.225142,3.739812 -0.385108,0.585915 -0.736584,1.1959 -1.052473,1.826946 C 33.386764,43.042894 32.056577,44 30.815708,44 H 19.405457 c -0.377101,0 -0.99151,-0.0065 -1.361861,0.06167 C 16.311702,44.380584 14.999644,45.89844 15,47.722656 v 4.554688 C 14.999599,54.333476 16.666524,56.000401 18.722656,56 h 12.093052 c 1.240869,0 2.571057,0.957108 3.126154,2.066017 0.315889,0.631045 0.667365,1.24103 1.052473,1.826945 0.681096,1.03624 0.84606,2.665465 0.22514,3.739809 l -6.043694,10.457073 c -1.028907,1.779844 -0.420325,4.056781 1.359375,5.085937 l 3.929688,2.273438 c 1.779707,1.029944 4.057417,0.421155 5.085937,-1.359375 l 6.043694,-10.457073 c 0.62092,-1.074344 2.114654,-1.744293 3.352326,-1.666259 C 49.29502,67.988467 49.64608,68 50,68 c 0.353931,0 0.705,-0.01153 1.053229,-0.03349 1.237655,-0.07804 2.731376,0.591916 3.352296,1.66626 l 6.043694,10.457073 c 1.02852,1.78053 3.30623,2.389319 5.085937,1.359375 l 3.929688,-2.273438 c 1.7797,-1.029156 2.388282,-3.306093 1.359375,-5.085937 L 64.780525,63.632771 c -0.62092,-1.074344 -0.455955,-2.70357 0.225142,-3.739812 0.385108,-0.585915 0.736584,-1.1959 1.052473,-1.826946 C 66.613236,56.957106 67.943423,56 69.184291,56 H 81.277344 C 83.333476,56.000401 85.000401,54.333476 85,52.277344 V 47.722656 C 85.000401,45.666524 83.333476,43.999599 81.277344,44 H 69.184291 c -1.240868,0 -2.571056,-0.957108 -3.126153,-2.066017 -0.315889,-0.631045 -0.667365,-1.24103 -1.052473,-1.826945 -0.681096,-1.03624 -0.84606,-2.665465 -0.22514,-3.739809 l 6.043694,-10.457073 c 1.028907,-1.779844 0.420325,-4.056781 -1.359375,-5.085937 l -3.929688,-2.273438 c -1.779707,-1.029944 -4.057417,-0.421155 -5.085937,1.359375 l -6.043694,10.457073 c -0.62092,1.074344 -2.114654,1.744293 -3.352326,1.666259 C 50.70498,32.011533 50.35392,32 50,32 c -0.353931,0 -0.705,0.01153 -1.053229,0.03349 -1.237655,0.07804 -2.731376,-0.591916 -3.352296,-1.66626 L 39.550781,19.910156 C 38.885414,18.758657 37.656082,18.0498 36.326172,18.050781 Z M 50,38.746094 C 56.215539,38.745663 61.254337,43.784461 61.253906,50 61.254337,56.215539 56.215539,61.254337 50,61.253906 43.784461,61.254337 38.745663,56.215539 38.746094,50 38.745663,43.784461 43.784461,38.745663 50,38.746094 Z"
inkscape:path-effect="#path-effect20-7"
inkscape:original-d="m 36.326172,18.050781 a 3.721056,3.721056 0 0 0 -1.861328,0.5 l -3.929688,2.273438 a 3.7228036,3.7228036 0 0 0 -1.359375,5.085937 L 36.34375,38.3125 C 34.921877,39.972287 33.808215,41.898777 33.0625,44 H 18.722656 A 3.7219297,3.7219297 0 0 0 15,47.722656 v 4.554688 A 3.7219297,3.7219297 0 0 0 18.722656,56 H 33.0625 c 0.745715,2.101223 1.859377,4.027713 3.28125,5.6875 l -7.167969,12.402344 a 3.7228036,3.7228036 0 0 0 1.359375,5.085937 l 3.929688,2.273438 a 3.721056,3.721056 0 0 0 5.085937,-1.359375 L 46.71875,67.6875 C 47.784179,67.884035 48.877705,68 50,68 c 1.122295,0 2.215821,-0.115965 3.28125,-0.3125 l 7.167969,12.402344 a 3.721056,3.721056 0 0 0 5.085937,1.359375 l 3.929688,-2.273438 a 3.7228036,3.7228036 0 0 0 1.359375,-5.085937 L 63.65625,61.6875 C 65.078123,60.027713 66.191785,58.101223 66.9375,56 H 81.277344 A 3.7219297,3.7219297 0 0 0 85,52.277344 V 47.722656 A 3.7219297,3.7219297 0 0 0 81.277344,44 H 66.9375 c -0.745715,-2.101223 -1.859377,-4.027713 -3.28125,-5.6875 l 7.167969,-12.402344 a 3.7228036,3.7228036 0 0 0 -1.359375,-5.085937 l -3.929688,-2.273438 a 3.721056,3.721056 0 0 0 -5.085937,1.359375 L 53.28125,32.3125 C 52.215821,32.115965 51.122295,32 50,32 c -1.122295,0 -2.215821,0.115965 -3.28125,0.3125 L 39.550781,19.910156 A 3.721056,3.721056 0 0 0 36.326172,18.050781 Z M 50,38.746094 A 11.253125,11.253125 0 0 1 61.253906,50 11.253125,11.253125 0 0 1 50,61.253906 11.253125,11.253125 0 0 1 38.746094,50 11.253125,11.253125 0 0 1 50,38.746094 Z" /></svg>

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="100"
height="100"
id="svg3"
sodipodi:docname="solaar-symbolic_dark.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview3"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showguides="true"
inkscape:zoom="4.4936636"
inkscape:cx="76.440969"
inkscape:cy="79.445199"
inkscape:current-layer="svg3"><sodipodi:guide
position="40.341527,95"
orientation="0,-1"
id="guide3"
inkscape:locked="false" /><sodipodi:guide
position="5,55.07737"
orientation="1,0"
id="guide4"
inkscape:locked="false" /><sodipodi:guide
position="95,52.215267"
orientation="1,0"
id="guide5"
inkscape:locked="false" /><sodipodi:guide
position="61.950078,5"
orientation="0,-1"
id="guide6"
inkscape:locked="false" /></sodipodi:namedview><title
id="title1">Solaar</title><defs
id="defs2"><linearGradient
id="gradient_black"><stop
style="stop-color:#000000;stop-opacity:1"
offset="0"
id="stop1" /><stop
style="stop-color:#000000;stop-opacity:0.9"
offset="1"
id="stop2" /></linearGradient><linearGradient
x1="5"
y1="50"
x2="95"
y2="50"
id="gradient_rect"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><linearGradient
x1="37"
y1="50"
x2="63"
y2="50"
id="gradient_dot"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-5"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-7"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect18"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="gradient_blue"><stop
style="stop-color:#009099;stop-opacity:1"
offset="0"
id="stop1-4" /><stop
style="stop-color:#00a899;stop-opacity:0.9"
offset="1"
id="stop2-1" /></linearGradient><linearGradient
x1="50"
y1="5.5"
x2="50"
y2="94.5"
id="gradient_rect-8"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-110.09871,-4.4507114)" /><linearGradient
x1="50.01833"
y1="5"
x2="50.01833"
y2="95"
id="gradient_dot-5"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse" /></defs><metadata
id="metadata2"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title>Solaar</dc:title><dc:creator><cc:Agent><dc:title>Daniel Pavel</dc:title></cc:Agent></dc:creator><cc:license
rdf:resource="http://creativecommons.org/licenses/by-sa/3.0/" /><dc:date>2013-06-25</dc:date><dc:identifier>solaar</dc:identifier></cc:Work><cc:License
rdf:about="http://creativecommons.org/licenses/by-sa/3.0/"><cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" /><cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" /><cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /><cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" /></cc:License></rdf:RDF></metadata><g
id="g3-7"><rect
style="fill:#f2f2f2;fill-opacity:1;stroke:none;stroke-width:3;stroke-linejoin:round;stroke-opacity:0.996078;paint-order:fill markers stroke"
id="rect17"
width="90"
height="90"
x="5"
y="5"
rx="10"
ry="10" /><path
id="path11-5"
style="display:inline;fill:#131313;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:0.905882;paint-order:markers fill stroke"
d="m 36.326172,18.050781 c -0.653587,3.07e-4 -1.295564,0.172759 -1.861328,0.5 l -3.260348,1.886206 c -0.369666,0.213862 -0.976947,0.555623 -1.294346,0.840227 -1.095169,0.982014 -1.50919,2.530927 -1.050063,3.928399 0.133086,0.405083 0.488934,1.004291 0.702637,1.37405 l 5.656751,9.787566 c 0.62092,1.074344 0.455955,2.70357 -0.225142,3.739812 -0.385108,0.585915 -0.736584,1.1959 -1.052473,1.826946 C 33.386764,43.042894 32.056577,44 30.815708,44 H 19.405457 c -0.377101,0 -0.99151,-0.0065 -1.361861,0.06167 C 16.311702,44.380584 14.999644,45.89844 15,47.722656 v 4.554688 C 14.999599,54.333476 16.666524,56.000401 18.722656,56 h 12.093052 c 1.240869,0 2.571057,0.957108 3.126154,2.066017 0.315889,0.631045 0.667365,1.24103 1.052473,1.826945 0.681096,1.03624 0.84606,2.665465 0.22514,3.739809 l -6.043694,10.457073 c -1.028907,1.779844 -0.420325,4.056781 1.359375,5.085937 l 3.929688,2.273438 c 1.779707,1.029944 4.057417,0.421155 5.085937,-1.359375 l 6.043694,-10.457073 c 0.62092,-1.074344 2.114654,-1.744293 3.352326,-1.666259 C 49.29502,67.988467 49.64608,68 50,68 c 0.353931,0 0.705,-0.01153 1.053229,-0.03349 1.237655,-0.07804 2.731376,0.591916 3.352296,1.66626 l 6.043694,10.457073 c 1.02852,1.78053 3.30623,2.389319 5.085937,1.359375 l 3.929688,-2.273438 c 1.7797,-1.029156 2.388282,-3.306093 1.359375,-5.085937 L 64.780525,63.632771 c -0.62092,-1.074344 -0.455955,-2.70357 0.225142,-3.739812 0.385108,-0.585915 0.736584,-1.1959 1.052473,-1.826946 C 66.613236,56.957106 67.943423,56 69.184291,56 H 81.277344 C 83.333476,56.000401 85.000401,54.333476 85,52.277344 V 47.722656 C 85.000401,45.666524 83.333476,43.999599 81.277344,44 H 69.184291 c -1.240868,0 -2.571056,-0.957108 -3.126153,-2.066017 -0.315889,-0.631045 -0.667365,-1.24103 -1.052473,-1.826945 -0.681096,-1.03624 -0.84606,-2.665465 -0.22514,-3.739809 l 6.043694,-10.457073 c 1.028907,-1.779844 0.420325,-4.056781 -1.359375,-5.085937 l -3.929688,-2.273438 c -1.779707,-1.029944 -4.057417,-0.421155 -5.085937,1.359375 l -6.043694,10.457073 c -0.62092,1.074344 -2.114654,1.744293 -3.352326,1.666259 C 50.70498,32.011533 50.35392,32 50,32 c -0.353931,0 -0.705,0.01153 -1.053229,0.03349 -1.237655,0.07804 -2.731376,-0.591916 -3.352296,-1.66626 L 39.550781,19.910156 C 38.885414,18.758657 37.656082,18.0498 36.326172,18.050781 Z M 50,38.746094 C 56.215539,38.745663 61.254337,43.784461 61.253906,50 61.254337,56.215539 56.215539,61.254337 50,61.253906 43.784461,61.254337 38.745663,56.215539 38.746094,50 38.745663,43.784461 43.784461,38.745663 50,38.746094 Z"
inkscape:path-effect="#path-effect20-7"
inkscape:original-d="m 36.326172,18.050781 a 3.721056,3.721056 0 0 0 -1.861328,0.5 l -3.929688,2.273438 a 3.7228036,3.7228036 0 0 0 -1.359375,5.085937 L 36.34375,38.3125 C 34.921877,39.972287 33.808215,41.898777 33.0625,44 H 18.722656 A 3.7219297,3.7219297 0 0 0 15,47.722656 v 4.554688 A 3.7219297,3.7219297 0 0 0 18.722656,56 H 33.0625 c 0.745715,2.101223 1.859377,4.027713 3.28125,5.6875 l -7.167969,12.402344 a 3.7228036,3.7228036 0 0 0 1.359375,5.085937 l 3.929688,2.273438 a 3.721056,3.721056 0 0 0 5.085937,-1.359375 L 46.71875,67.6875 C 47.784179,67.884035 48.877705,68 50,68 c 1.122295,0 2.215821,-0.115965 3.28125,-0.3125 l 7.167969,12.402344 a 3.721056,3.721056 0 0 0 5.085937,1.359375 l 3.929688,-2.273438 a 3.7228036,3.7228036 0 0 0 1.359375,-5.085937 L 63.65625,61.6875 C 65.078123,60.027713 66.191785,58.101223 66.9375,56 H 81.277344 A 3.7219297,3.7219297 0 0 0 85,52.277344 V 47.722656 A 3.7219297,3.7219297 0 0 0 81.277344,44 H 66.9375 c -0.745715,-2.101223 -1.859377,-4.027713 -3.28125,-5.6875 l 7.167969,-12.402344 a 3.7228036,3.7228036 0 0 0 -1.359375,-5.085937 l -3.929688,-2.273438 a 3.721056,3.721056 0 0 0 -5.085937,1.359375 L 53.28125,32.3125 C 52.215821,32.115965 51.122295,32 50,32 c -1.122295,0 -2.215821,0.115965 -3.28125,0.3125 L 39.550781,19.910156 A 3.721056,3.721056 0 0 0 36.326172,18.050781 Z M 50,38.746094 A 11.253125,11.253125 0 0 1 61.253906,50 11.253125,11.253125 0 0 1 50,61.253906 11.253125,11.253125 0 0 1 38.746094,50 11.253125,11.253125 0 0 1 50,38.746094 Z" /></g></svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
width="100"
height="100"
id="svg3"
sodipodi:docname="solaar-symbolic.svg"
xml:space="preserve"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><sodipodi:namedview
id="namedview3"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
showguides="true"
inkscape:zoom="6.355"
inkscape:cx="8.5759245"
inkscape:cy="34.618411"
inkscape:current-layer="g3-7"><sodipodi:guide
position="40.341527,95"
orientation="0,-1"
id="guide3"
inkscape:locked="false" /><sodipodi:guide
position="5,55.07737"
orientation="1,0"
id="guide4"
inkscape:locked="false" /><sodipodi:guide
position="95,52.215267"
orientation="1,0"
id="guide5"
inkscape:locked="false" /><sodipodi:guide
position="61.950078,5"
orientation="0,-1"
id="guide6"
inkscape:locked="false" /></sodipodi:namedview><title
id="title1">Solaar</title><defs
id="defs2"><linearGradient
id="gradient_black"><stop
style="stop-color:#000000;stop-opacity:1"
offset="0"
id="stop1" /><stop
style="stop-color:#000000;stop-opacity:0.9"
offset="1"
id="stop2" /></linearGradient><linearGradient
x1="5"
y1="50"
x2="95"
y2="50"
id="gradient_rect"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><linearGradient
x1="37"
y1="50"
x2="63"
y2="50"
id="gradient_dot"
xlink:href="#gradient_black"
gradientUnits="userSpaceOnUse" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-5"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect20-7"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,0,1,0,0.77328184,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0.68280054,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,2.2467915,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect18"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,3.7219297,0,1 @ F,0,1,1,0,3.7219297,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
id="gradient_blue"><stop
style="stop-color:#009099;stop-opacity:1"
offset="0"
id="stop1-4" /><stop
style="stop-color:#00a899;stop-opacity:0.9"
offset="1"
id="stop2-1" /></linearGradient><linearGradient
x1="50"
y1="5.5"
x2="50"
y2="94.5"
id="gradient_rect-8"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-110.09871,-4.4507114)" /><linearGradient
x1="50.01833"
y1="5"
x2="50.01833"
y2="95"
id="gradient_dot-5"
xlink:href="#gradient_blue"
gradientUnits="userSpaceOnUse" /></defs><metadata
id="metadata2"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title>Solaar</dc:title><dc:creator><cc:Agent><dc:title>Daniel Pavel</dc:title></cc:Agent></dc:creator><cc:license
rdf:resource="http://creativecommons.org/licenses/by-sa/3.0/" /><dc:date>2013-06-25</dc:date><dc:identifier>solaar</dc:identifier></cc:Work><cc:License
rdf:about="http://creativecommons.org/licenses/by-sa/3.0/"><cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" /><cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" /><cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" /><cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" /><cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" /></cc:License></rdf:RDF></metadata><g
id="g3-7"><rect
style="fill:#131313;fill-opacity:1;stroke:none;stroke-width:3;stroke-linejoin:round;stroke-opacity:0.996078;paint-order:fill markers stroke"
id="rect17"
width="90"
height="90"
x="5"
y="5"
rx="10"
ry="10" /><path
id="path11-5"
style="display:inline;fill:#f2f2f2;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:0.905882;paint-order:markers fill stroke"
d="m 36.326172,18.050781 c -0.653587,3.07e-4 -1.295564,0.172759 -1.861328,0.5 l -3.260348,1.886206 c -0.369666,0.213862 -0.976947,0.555623 -1.294346,0.840227 -1.095169,0.982014 -1.50919,2.530927 -1.050063,3.928399 0.133086,0.405083 0.488934,1.004291 0.702637,1.37405 l 5.656751,9.787566 c 0.62092,1.074344 0.455955,2.70357 -0.225142,3.739812 -0.385108,0.585915 -0.736584,1.1959 -1.052473,1.826946 C 33.386764,43.042894 32.056577,44 30.815708,44 H 19.405457 c -0.377101,0 -0.99151,-0.0065 -1.361861,0.06167 C 16.311702,44.380584 14.999644,45.89844 15,47.722656 v 4.554688 C 14.999599,54.333476 16.666524,56.000401 18.722656,56 h 12.093052 c 1.240869,0 2.571057,0.957108 3.126154,2.066017 0.315889,0.631045 0.667365,1.24103 1.052473,1.826945 0.681096,1.03624 0.84606,2.665465 0.22514,3.739809 l -6.043694,10.457073 c -1.028907,1.779844 -0.420325,4.056781 1.359375,5.085937 l 3.929688,2.273438 c 1.779707,1.029944 4.057417,0.421155 5.085937,-1.359375 l 6.043694,-10.457073 c 0.62092,-1.074344 2.114654,-1.744293 3.352326,-1.666259 C 49.29502,67.988467 49.64608,68 50,68 c 0.353931,0 0.705,-0.01153 1.053229,-0.03349 1.237655,-0.07804 2.731376,0.591916 3.352296,1.66626 l 6.043694,10.457073 c 1.02852,1.78053 3.30623,2.389319 5.085937,1.359375 l 3.929688,-2.273438 c 1.7797,-1.029156 2.388282,-3.306093 1.359375,-5.085937 L 64.780525,63.632771 c -0.62092,-1.074344 -0.455955,-2.70357 0.225142,-3.739812 0.385108,-0.585915 0.736584,-1.1959 1.052473,-1.826946 C 66.613236,56.957106 67.943423,56 69.184291,56 H 81.277344 C 83.333476,56.000401 85.000401,54.333476 85,52.277344 V 47.722656 C 85.000401,45.666524 83.333476,43.999599 81.277344,44 H 69.184291 c -1.240868,0 -2.571056,-0.957108 -3.126153,-2.066017 -0.315889,-0.631045 -0.667365,-1.24103 -1.052473,-1.826945 -0.681096,-1.03624 -0.84606,-2.665465 -0.22514,-3.739809 l 6.043694,-10.457073 c 1.028907,-1.779844 0.420325,-4.056781 -1.359375,-5.085937 l -3.929688,-2.273438 c -1.779707,-1.029944 -4.057417,-0.421155 -5.085937,1.359375 l -6.043694,10.457073 c -0.62092,1.074344 -2.114654,1.744293 -3.352326,1.666259 C 50.70498,32.011533 50.35392,32 50,32 c -0.353931,0 -0.705,0.01153 -1.053229,0.03349 -1.237655,0.07804 -2.731376,-0.591916 -3.352296,-1.66626 L 39.550781,19.910156 C 38.885414,18.758657 37.656082,18.0498 36.326172,18.050781 Z M 50,38.746094 C 56.215539,38.745663 61.254337,43.784461 61.253906,50 61.254337,56.215539 56.215539,61.254337 50,61.253906 43.784461,61.254337 38.745663,56.215539 38.746094,50 38.745663,43.784461 43.784461,38.745663 50,38.746094 Z"
inkscape:path-effect="#path-effect20-7"
inkscape:original-d="m 36.326172,18.050781 a 3.721056,3.721056 0 0 0 -1.861328,0.5 l -3.929688,2.273438 a 3.7228036,3.7228036 0 0 0 -1.359375,5.085937 L 36.34375,38.3125 C 34.921877,39.972287 33.808215,41.898777 33.0625,44 H 18.722656 A 3.7219297,3.7219297 0 0 0 15,47.722656 v 4.554688 A 3.7219297,3.7219297 0 0 0 18.722656,56 H 33.0625 c 0.745715,2.101223 1.859377,4.027713 3.28125,5.6875 l -7.167969,12.402344 a 3.7228036,3.7228036 0 0 0 1.359375,5.085937 l 3.929688,2.273438 a 3.721056,3.721056 0 0 0 5.085937,-1.359375 L 46.71875,67.6875 C 47.784179,67.884035 48.877705,68 50,68 c 1.122295,0 2.215821,-0.115965 3.28125,-0.3125 l 7.167969,12.402344 a 3.721056,3.721056 0 0 0 5.085937,1.359375 l 3.929688,-2.273438 a 3.7228036,3.7228036 0 0 0 1.359375,-5.085937 L 63.65625,61.6875 C 65.078123,60.027713 66.191785,58.101223 66.9375,56 H 81.277344 A 3.7219297,3.7219297 0 0 0 85,52.277344 V 47.722656 A 3.7219297,3.7219297 0 0 0 81.277344,44 H 66.9375 c -0.745715,-2.101223 -1.859377,-4.027713 -3.28125,-5.6875 l 7.167969,-12.402344 a 3.7228036,3.7228036 0 0 0 -1.359375,-5.085937 l -3.929688,-2.273438 a 3.721056,3.721056 0 0 0 -5.085937,1.359375 L 53.28125,32.3125 C 52.215821,32.115965 51.122295,32 50,32 c -1.122295,0 -2.215821,0.115965 -3.28125,0.3125 L 39.550781,19.910156 A 3.721056,3.721056 0 0 0 36.326172,18.050781 Z M 50,38.746094 A 11.253125,11.253125 0 0 1 61.253906,50 11.253125,11.253125 0 0 1 50,61.253906 11.253125,11.253125 0 0 1 38.746094,50 11.253125,11.253125 0 0 1 50,38.746094 Z" /></g></svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -8,7 +8,8 @@
<developer id="pwr-solaar.github.io">
<name>pwr-Solaar</name>
</developer>
<url type="homepage">https://github.com/pwr-Solaar/Solaar</url>
<url type="homepage">https://pwr-solaar.github.io/Solaar/</url>
<url type="vcs-browser">https://github.com/pwr-Solaar/Solaar</url>
<content_rating type="oars-1.0" />
<update_contact>pfpschneider_AT_gmail.com</update_contact>
@@ -47,6 +48,9 @@
</screenshots>
<releases>
<release version="1.1.19" date="2026-01-08"/>
<release version="1.1.18" date="2025-12-11"/>
<release version="1.1.16" date="2025-10-23"/>
<release version="1.1.14" date="2025-01-01"/>
<release version="1.1.13" date="2024-05-11"/>
<release version="1.1.12" date="2024-04-27"/>

View File

@@ -388,6 +388,8 @@ class Device:
setting_callback: Any = None
sliding = profiles = _backlight = _keys = _remap_keys = _led_effects = _gestures = None
_gestures_lock = threading.Lock()
number = "d1"
present = True
read_register = device.Device.read_register
write_register = device.Device.write_register
@@ -405,6 +407,7 @@ class Device:
self.persister = configuration._DeviceEntry()
self.features = hidpp20.FeaturesArray(self)
self.settings = []
self.receiver = []
if self.feature is not None:
self.features = hidpp20.FeaturesArray(self)
self.responses = [
@@ -435,6 +438,18 @@ class Device:
print("PING", self._protocol)
return self._protocol
def handle_notification(self, handle):
pass
def changed(self, *args, **kwargs):
pass
def set_battery_info(self, *args, **kwargs):
pass
def status_string(self):
pass
def match_requests(number, responses, call_args_list):
for i in range(0 - number, 0):

View File

@@ -9,7 +9,8 @@ import pytest
from logitech_receiver import base
from logitech_receiver import exceptions
from logitech_receiver.base import HIDPP_SHORT_MESSAGE_ID
from logitech_receiver.base import request
from logitech_receiver.common import LOGITECH_VENDOR_ID
from logitech_receiver.common import BusID
from logitech_receiver.hidpp10_constants import ErrorCode as Hidpp10Error
from logitech_receiver.hidpp20_constants import ErrorCode as Hidpp20Error
@@ -37,10 +38,9 @@ def test_product_information(usb_id, expected_name, expected_receiver_kind):
def test_filter_receivers_known():
bus_id = 2
vendor_id = 0x046D
product_id = 0xC548
receiver_info = base._filter_receivers(bus_id, vendor_id, product_id)
receiver_info = base.get_known_receiver_info(bus_id, LOGITECH_VENDOR_ID, product_id)
assert receiver_info["name"] == "Bolt Receiver"
assert receiver_info["receiver_kind"] == "bolt"
@@ -48,43 +48,51 @@ def test_filter_receivers_known():
def test_filter_receivers_unknown():
bus_id = 1
vendor_id = 0x046D
product_id = 0xC500
receiver_info = base._filter_receivers(bus_id, vendor_id, product_id)
receiver_info = base.get_known_receiver_info(bus_id, LOGITECH_VENDOR_ID, product_id)
assert receiver_info["bus_id"] == bus_id
assert receiver_info["product_id"] == product_id
@pytest.mark.parametrize(
"product_id, hidpp_short, hidpp_long",
"product_id, bus, hidpp_short, hidpp_long, expected",
[
(0xC548, True, False),
(0xC07E, True, False),
(0xC07E, False, True),
(0xA07E, False, True),
(0xA07E, None, None),
(0xA07C, False, False),
(0xC548, BusID.USB, True, False, {"name": "Bolt Receiver", "usb_interface": 2}),
(0xC07D, BusID.USB, True, False, {"usb_interface": 1}),
(0xC07E, BusID.USB, False, True, {"usb_interface": 1}),
(0xC07E, BusID.BLUETOOTH, False, True, {"bus_id": 5}),
(0xA07E, BusID.USB, False, True, {"product_id": 0xA07E}),
(0xA07C, BusID.USB, False, False, None),
(0xC07F, BusID.USB, None, None, {"usb_interface": 2}),
(0xC07F, BusID.BLUETOOTH, None, None, None),
(0xB013, BusID.BLUETOOTH, None, None, {"product_id": 0xB013}),
],
)
def test_filter_products_of_interest(product_id, hidpp_short, hidpp_long):
bus_id = 3
vendor_id = 0x046D
receiver_info = base._filter_products_of_interest(
bus_id,
vendor_id,
def test_filter_products_of_interest(product_id, bus, hidpp_short, hidpp_long, expected):
receiver_info = base.filter_products_of_interest(
bus,
LOGITECH_VENDOR_ID,
product_id,
hidpp_short=hidpp_short,
hidpp_long=hidpp_long,
)
if not hidpp_short and not hidpp_long:
assert receiver_info is None
if expected is None:
assert receiver_info == expected
else:
assert isinstance(receiver_info["vendor_id"], int)
assert receiver_info["product_id"] == product_id
assert all([receiver_info[key] == expected_value for key, expected_value in expected.items()])
assert receiver_info["vendor_id"] == LOGITECH_VENDOR_ID
assert receiver_info["product_id"]
def test_match():
record = {"vendor_id": LOGITECH_VENDOR_ID}
res = base._match_device(record, 0, LOGITECH_VENDOR_ID, 0)
assert res is True
@pytest.mark.parametrize(
@@ -144,19 +152,19 @@ def test_request_errors(
with mock.patch(
"logitech_receiver.base._read",
return_value=(HIDPP_SHORT_MESSAGE_ID, device_number, prefix + reply_data_sw_id + struct.pack("B", error_code)),
), mock.patch("logitech_receiver.base._skip_incoming", return_value=None), mock.patch(
), mock.patch("logitech_receiver.base._read_input_buffer"), mock.patch(
"logitech_receiver.base.write", return_value=None
), mock.patch("logitech_receiver.base._get_next_sw_id", return_value=next_sw_id):
if raise_exception:
with pytest.raises(exceptions.FeatureCallError) as context:
request(handle, device_number, next_sw_id, return_error=return_error)
base.request(handle, device_number, next_sw_id, return_error=return_error)
assert context.value.number == device_number
assert context.value.request == next_sw_id
assert context.value.error == error_code
assert context.value.params == b""
else:
result = request(handle, device_number, next_sw_id, return_error=return_error)
result = base.request(handle, device_number, next_sw_id, return_error=return_error)
assert result == (error_code if return_error else None)

View File

@@ -0,0 +1,30 @@
import pytest
from logitech_receiver import base_usb
from logitech_receiver.common import LOGITECH_VENDOR_ID
def test_ensure_known_receivers_mappings_are_valid():
for key, receiver in base_usb.KNOWN_RECEIVERS.items():
assert key == receiver["product_id"]
def test_get_receiver_info():
expected = {
"vendor_id": LOGITECH_VENDOR_ID,
"product_id": 0xC548,
"usb_interface": 2,
"name": "Bolt Receiver",
"receiver_kind": "bolt",
"max_devices": 6,
"may_unpair": True,
}
res = base_usb.get_receiver_info(0xC548)
assert res == expected
def test_get_receiver_info_unknown_device_fails():
with pytest.raises(ValueError):
base_usb.get_receiver_info(0xC500)

View File

@@ -2,22 +2,28 @@ from unittest import mock
from logitech_receiver import desktop_notifications
def test_notifications_available():
result = desktop_notifications.notifications_available()
assert not result
# depends on external environment, so make some tests dependent on availability
def test_init():
assert not desktop_notifications.init()
result = desktop_notifications.init()
assert result == desktop_notifications.available
def test_uninit():
assert desktop_notifications.uninit() is None
class MockDevice(mock.Mock):
name = "MockDevice"
def close():
return True
def test_show():
dev = mock.MagicMock()
dev = MockDevice()
reason = "unknown"
assert desktop_notifications.show(dev, reason) is None
result = desktop_notifications.show(dev, reason)
assert result is not None if desktop_notifications.available else result is None

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