Compare commits

...

259 Commits

Author SHA1 Message Date
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
Peter F. Patel-Schneider 7e9babdc79 release 1.1.14 2025-01-01 09:42:39 -05:00
Nick 01d76bb0ed i18n: Swedish translations in .desktop files 2025-01-01 09:37:14 -05:00
Peter F. Patel-Schneider 3768354230 release 1.1.14rc4 2024-12-24 10:36:55 -05:00
Peter F. Patel-Schneider 87afc3659e cli: handle fake feature enums in show 2024-12-24 10:29:55 -05:00
Matthaiks 2e9aa64a2e Update Polish translation 2024-12-24 10:28:17 -05:00
Peter F. Patel-Schneider e945f797a2 release 1.1.14rc3 2024-12-23 10:57:24 -05:00
MattHag 73c88210f7 Fix battery entry in device
Enforce use of enum value.

Fixes #2700
Related #2273
2024-12-23 10:50:43 -05:00
Peter F. Patel-Schneider 510753ea67 release 1.1.14rc2 2024-12-23 10:40:49 -05:00
Peter F. Patel-Schneider c2a3bc7e55 release 1.1.14rc1 2024-12-23 10:40:49 -05:00
Nick b6f5f86c36 i18n: Swedish translations in .desktop files 2024-12-09 08:49:20 -05:00
Nick ba4fda00df Update Swedish translation 2024-12-09 06:39:04 -05:00
Nick 1fcedeee70 i18n: Swedish translations in .desktop files 2024-12-09 06:37:28 -05:00
Nick 2157fdb59c
po: Add translator to list (#2687)
* Add translator to list

* Update i18n.md

---------

Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
2024-11-29 16:42:07 -05:00
Osman Karagöz c5f74953ce
po: Update tr.po (#2683) 2024-11-17 08:48:14 -05:00
Max Ammann ff6f7a8e22
settings: Add ratchet setting for smart shift enhanced devices (#2681)
* Add ratchet setting for smart shift enhanced devices

* Update lib/logitech_receiver/settings_templates.py

---------

Co-authored-by: Peter F. Patel-Schneider <pfpschneider@gmail.com>
2024-11-16 16:06:02 -05:00
Pierre Carru 8b0904ead0
receiver: fix BoltReceiver, Ex100Receiver __init__ (#2661) 2024-11-10 17:44:40 -05:00
Nick 9d5568f6e5
po: Update Swedish translation (#2671)
* Update Swedish translation

Small fixes

* Update sv.po

* Update sv.po

* Update sv.po
2024-11-09 12:17:06 -05:00
Nick ba4bbd0118
po: Update Swedish translation (#2670) 2024-11-08 15:48:35 -05:00
MattHag 862cef1f77 hidpp20_constants: Refactor Gesture into enum
Replace Gesture NamedInts with enum.

Related #2273
2024-11-03 14:41:07 -05:00
Romain Loutrel a19461b29d
refactor: replace ERROR NamedInts by IntEnum (#2645)
* refactoring(logitech_receiver/notifications): change to enums PairingError and BoltPairingError

* refactoring(logitech_receiver/notifications): change to enums PairingError and BoltPairingError (Fix pre-commit checks)

* refactor(logitech_receiver/base.py): create unit tests for ping function before replacing ERRORNamedInts by IntEnum

* refactor(logitech_receiver/base.py): create unit tests for request function before replacing ERROR NamedInts by IntEnum

* refactor(logitech_receiver/base.py): create unit tests for ping function before replacing ERRORNamedInts by IntEnum (add exclusion for macOS)

* refactor(logitech_receiver/base.py): create unit tests for ping function before replacing ERRORNamedInts by IntEnum (fix for python < 3.10)

* refactor(solaar/cli./probe.py): create unit tests for run function before replacing ERROR NamedInts by IntEnum (focusing on the call order when receiving errors)

* refactor(solaar/cli./probe.py): refactor register processing to handle short and long registers in a single loop structure for improved readability and reduced code duplication.

* refactor(logitech_receiver/hidpp10_constants.py): replace ERROR NamedInt by IntEnum.

* refactor(logitech_receiver/hidpp10_constants.py): distinguish hidpp10 and hidpp20 errors in the code for readibility.

* refactor(logitech_receiver/hidpp20_constants.py): replace ERROR NamedInt by IntEnum.

* refactor(logitech_receiver/hidpp20_constants.py): replace ERROR NamedInt by IntEnum. (fix problem with | operator when typing with python 3.8)

* feature(hide on startup option): Visual test (not binded yet) DRAFT

* refactor(solaar/cli./probe.py): create unit tests for run function before replacing ERROR NamedInts by IntEnum (focusing on the call order when receiving errors)

* refactor(solaar/cli./probe.py): refactor register processing to handle short and long registers in a single loop structure for improved readability and reduced code duplication.

* refactor(logitech_receiver/hidpp10_constants.py): replace ERROR NamedInt by IntEnum.

* refactor(logitech_receiver/hidpp20_constants.py): replace ERROR NamedInt by IntEnum.

* refactor(logitech_receiver/hidpp20_constants.py): replace ERROR NamedInt by IntEnum. (fix problem with | operator when typing with python 3.8)

* feature(hide on startup option): Visual test (not binded yet) DRAFT

* Merge: Refactor: hidpp20 to use enum

* Merge: Refactor: hidpp20 to use enum (fix test)

---------

Co-authored-by: some_developer <some.developper.44@gmail.com>
2024-11-02 10:17:50 -04:00
MattHag c90146df31
Refactor: hidpp20 to use enum (#2647)
* Remove duplicated Param definition

Use constants from hidpp20 constants

Related #2273

* hidpp20/Param: Refactor to use IntEnum

Related #2273

* hidpp20_constants: Refactor to use IntEnum

Related #2273
2024-11-02 08:33:58 -04:00
John Erling Blad 8518604155
i18n: Updated Norwegian Nynorsk (nn) (#2655)
Co-authored-by: John Erling Blad <jeblad@google.com>
2024-10-31 05:27:09 -04:00
John Erling Blad de033267fa i18n: Updated Norwegian Bokmål (nb) 2024-10-31 04:19:57 -04:00
John erling Blad 0d4fd4c537 i18n: Updated Norwegian Bokmål (nb) 2024-10-28 08:11:53 -04:00
MattHag 1afcfe4b57
refactor: use IntEnum for firmware and cidgroup constances
* Refactor: test_named_ints_flag_names

Shorten test and clarify behavior using binary numbers.

* Introduce plain flag_names function

This replicates the NamedInts functionality as plain function.

* Refactor FeatureFlag to use IntFlag

Replace NamedInts implementation with IntFlag enum and plain flag_names
function.

Related #2273

* Refactor FirmwareKind to use IntEnum

- Move general FirmwareKind to common module.
- Replace NamedInts implementation with IntEnum.
- Harden related HIDPP 1.0 get_firmware test.

Related #2273

* Refactor CID_GROUP, CID_GROUP_BIT to use IntEnum

Related #2273
2024-10-23 16:25:35 -04:00
Romain Loutrel 79ffbda903
change pairing error values to intenums
* refactoring(logitech_receiver/notifications): change to enums PairingError and BoltPairingError

* refactoring(logitech_receiver/notifications): change to enums PairingError and BoltPairingError (Fix pre-commit checks)

* refactor(logitech_receiver/base.py): create unit tests for ping function before replacing ERRORNamedInts by IntEnum

* refactor(logitech_receiver/base.py): create unit tests for request function before replacing ERROR NamedInts by IntEnum

* refactor(logitech_receiver/base.py): create unit tests for ping function before replacing ERRORNamedInts by IntEnum (add exclusion for macOS)

* refactor(logitech_receiver/base.py): create unit tests for ping function before replacing ERRORNamedInts by IntEnum (fix for python < 3.10)
2024-10-23 16:22:22 -04:00
Peter F. Patel-Schneider 2185a8390c ui: fix initialization bug for PackedRangeControl 2024-10-22 14:31:13 -04:00
rloutrel 0d12c6f229 notifications: Introduce unit tests 2024-10-20 12:57:00 -04:00
MattHag 0cd9c0c9b5 Refactor: Introduce Feature enum
Convert Feature NamedInts to SupportedFeature integer enum.

Related #2273
2024-10-14 07:28:09 -04:00
MattHag 11e7cbde69 Test Feature class
Related #2273
2024-10-14 07:28:09 -04:00
MattHag 06fd32b501 Test and refactor process_notification
Related #2273
2024-10-14 07:28:09 -04:00
MattHag badb76953d Test key_is_down
Related #2273
2024-10-14 07:28:09 -04:00
Peter F. Patel-Schneider a36973916c settings: check all bits for extended report rate 2024-10-13 20:46:21 -04:00
rloutrel 15659a1ee4 Fix copy-paste error while refactoring notifications.py 2024-10-11 13:23:55 -04:00
MattHag 194c385824 RuleComponentUI: Type hints methods 2024-10-11 07:42:38 -04:00
MattHag d1f9b9ca3d diversion_rules: Add type hints 2024-10-11 07:42:38 -04:00
MattHag 97d1e90ceb Fix signature of show
Fix diverged signature of RuleComponentUI subclasses.
2024-10-11 07:42:38 -04:00
MattHag 9f57955142 Action menu: Move context menu into own class
Reduce complexity of diversion dialog.
2024-10-11 07:42:38 -04:00
MattHag 0dec545bfd Fix rule saving command 2024-10-11 07:42:38 -04:00
MattHag 3277015ab6 diversion: Add type hints 2024-10-11 07:42:38 -04:00
MattHag 691e5878f9 Remove obsolete pytest fixture 2024-10-11 07:42:38 -04:00
MattHag bb559c0d7c base: Remove hard dependency on gi
Import gi solely for type checking.
2024-10-11 07:42:38 -04:00
MattHag 1f85ec01e7 base: Add more unit tests
Make internal functions private.
2024-10-11 07:42:38 -04:00
MattHag 58ddb0d6cd Introduce HIDAPI protocol
Improve type hints and names.
2024-10-11 07:42:38 -04:00
MattHag 46366b2430 Fix warnings from automatic code inspections
Warnings found by automatic code inspection and partially tackled
- Drop distuitls inf favour of setuptools
- Replace deprecated pyudev.Device.from_device_number
- Remove unnecessary brackets
- Avoid access to private variables etc.
- Shadows built-in name
- Line length >120 characters
- Not a module level variable
- Simplify clause
and more
2024-10-11 07:42:38 -04:00
MattHag 0f4d1aebcd ui/common: Introduce tests 2024-10-11 07:42:38 -04:00
MattHag 89233957dc settings: Add tests 2024-10-11 07:42:38 -04:00
MattHag c9e781e752 settings_template: Introduce State enum 2024-10-11 07:42:38 -04:00
MattHag 54aace050c Replace action strings with constants
Avoids spelling mistakes and helps readability.
2024-10-11 07:42:38 -04:00
MattHag cba3533869 Remove factory wrapper classes
A module level function is sufficient, no wrapper needed.
2024-10-11 07:42:38 -04:00
MattHag ef6b7dec2c receiver: Remove hard dependency on base
With this test all receiver tests are macOS compatible again. The low
level interface supports passing a fake API for unit tests.
2024-10-11 07:42:38 -04:00
MattHag 4e50e605a6 device: Remove hard dependency on base 2024-10-11 07:42:38 -04:00
MattHag 37e2ac80ba base: Add test for filter_products_of_interest 2024-10-11 07:42:38 -04:00
MattHag 614a5dc633 Add type hints and clean up 2024-10-11 07:42:38 -04:00
MattHag 1729189981 base: Add find_paired_node functions
Avoid direct access to hidapi and use the base module as low-level API
instead. This change replaces the remaining calls to find_paired_node
and find_paired_node_wpid by exposing them via base module.
2024-10-11 07:42:38 -04:00
MattHag 90c41dbe32 base: Add find_paired_node functions
Avoid the need for hidapi imports and add them to the base API module.
2024-10-11 07:42:38 -04:00
MattHag a7ad625023 Fix about dialog 2024-10-11 07:42:38 -04:00
MattHag 8d0672ac3c base_usb: Add external interface
Clean up, type hint and tests base_usb and related modules.
2024-10-11 07:42:38 -04:00
MattHag a75c4b9679 ui/about: Use Model-View-Presenter pattern for testability
Split model and view, and enable view mocks for unit tests without GDK.
2024-10-11 07:42:38 -04:00
MattHag 46fafa0e68 hidapi: Explicitly load hidapi/udev implementation
Linux uses udev, other platforms use the cross-platform hidapi
implementation. Remove implicit loading of hidapi in hidapi/__init__.py.
2024-10-11 07:42:38 -04:00
MattHag 99fc9c6fcb receiver: Remove hard dependency on hidapi 2024-10-11 07:42:38 -04:00
MattHag 615499dce2 device: Remove hard dependency on hidapi 2024-10-11 07:42:38 -04:00
MattHag 9907cb2875 Test coverage: Fix keysyms to be visible (#9)
Fix typo in package name.
2024-10-11 07:42:38 -04:00
MattHag 65d3b406c0 keysyms: Introduce tests for this package 2024-10-11 07:42:38 -04:00
MattHag b681aafaff keysymdef: Rename key symbols
Name the key symbol mapping different than the module itself.
2024-10-11 07:42:38 -04:00
MattHag 32fef49ff8 Upload test coverage reports solely after merging
Related #1097
2024-10-11 07:42:38 -04:00
Peter F. Patel-Schneider 4aada31b21 ui: augment pairing message for devices with multiple channels 2024-10-08 15:15:35 -04:00
MattHag 128ec43d70 solaar: Add type hints 2024-10-08 14:35:16 -04:00
MattHag 0481950324 base: Add type hints 2024-10-08 14:35:16 -04:00
MattHag aa354dc4c4 Replace global sw_id with function call
Add test for it.
2024-10-08 14:35:16 -04:00
MattHag 2442299539 base: Simplify receiver info retrieval
- Remove comments with unused receivers
- Simplify receiver hardcoded info
2024-10-08 14:35:16 -04:00
MattHag 2aa462532a settings_template: Prepare removal of desktop_notifications dependency
Related #2273
2024-10-08 14:35:16 -04:00
MattHag d5af19be8a Make ui/desktop_notifications testable
Introduce unit tests.

Related #2273
2024-10-08 14:35:16 -04:00
MattHag e8ef262433 Rename ui/notify module to desktop notifications
Related #2273
2024-10-08 14:35:16 -04:00
MattHag 912afff173 Make lr/desktop_notifications testable
Introduce unit tests.

Related #2273
2024-10-08 14:35:16 -04:00
MattHag 0f8ab61ddf Rename lr/notify module to desktop_notifications
Related #2273
2024-10-08 14:35:16 -04:00
MattHag c76b0ef36b Add code coverage badge
Related #1097
2024-10-08 14:35:16 -04:00
MattHag b1b9f01083 Setup reports and upload codecov
Create coverage.xml, upload it to GitHub CI and visualize with codecov.

Setup instruction:
- Install codecov for project
  https://github.com/settings/installations/55029514
- Add CODECOV_TOKEN in the GitHub CI project secrets

Related #1097
2024-10-08 14:35:16 -04:00
MattHag 454e1601bd Introduce test coverage threshold
Enforce a total coverage of 40% of the code.

Related #1097
2024-10-08 14:35:16 -04:00
MattHag c1bc39f99f Fix test coverage reporting
Related #1097
2024-10-08 14:35:16 -04:00
MattHag 26667afea4 Simplify setup with pathlib 2024-10-08 14:35:16 -04:00
MattHag 741c0861c2 Clarify that fake hidpp is used
This module shouldn't be necessary on the long run. Remove pieces from
it whenever possible.
2024-10-08 14:35:16 -04:00
MattHag 3c1aa35067 hidapi: Completely remove dependency on gi
Related #2480
2024-10-08 14:35:16 -04:00
MattHag 40033c0183 Introduce hid_parser tests
Add basic tests to cover the package.
2024-09-15 09:19:13 -04:00
MattHag 8fb087be14 logitech_receiver: Remove GDK dependency 2024-09-15 09:18:51 -04:00
MattHag ea0eb66f39 Refactor: Remove all GDK dependencies from hidapi package
The hidapi hardware layer must not know or depend on any UI libraries.
Removes all GDK dependencies from the hidapi packages, which makes
testing of these modules easier and removes unwanted cross-dependencies.

Related #2480
2024-09-15 09:18:51 -04:00
MattHag 70def31942 Refactor: Distinguish module from package
Adapt module names to easily distinguish them.

Related #2480
2024-09-15 09:18:51 -04:00
Peter F. Patel-Schneider fdd2c79950 settings: allow unkonwn keys in Key rule conditions 2024-08-28 10:40:23 -04:00
Peter F. Patel-Schneider ae39ac46ba docs: improve documentation for cli actions 2024-08-23 20:03:54 -04:00
Peter F. Patel-Schneider 4578f5f6f1 device: cycle sw_id to better guard against duplication of messages 2024-08-23 19:41:10 -04:00
Peter F. Patel-Schneider c07c30baef device: handle error return on root feature 2024-08-23 19:41:10 -04:00
MattHag af12f8df52 Remove incomplete developer docs
Auto generated code documentation is incomplete, remove it.

Related #2503
2024-08-23 18:44:52 -04:00
MattHag 48ff85ab94 Publish GitHub pages only on push to master
Avoid draft documentation from being published.
2024-08-23 18:44:14 -04:00
Peter F. Patel-Schneider 64a9aac0d5 docs: add information about Onboard Profiles overriding some settings 2024-08-10 10:42:28 -04:00
Peter F. Patel-Schneider ce197b7093 doc: add wording to README.md that Solaar is not a device driver 2024-07-24 07:19:24 -04:00
IskandarMa 7b797f40f7
i18n: Chinese translations in .desktop files (#2554)
* translation(v1.1.13): update solaar.pot; fix missing zh_CN translation; fix some mis-leading translation in zh_CN

* fix translation error

* i18n: zh_CN/zh_TW/zh_HK in .desktop files

---------

Co-authored-by: IskandarMa <zhenghe.mt@alibaba-inc.com>
2024-07-15 23:49:37 -04:00
MattHag 67829c5807
Clean up imports (#2537)
* Remove import as _ in solaar startup

Related #2273

* Remove import as _ in listener

Related #2273

* Remove import as _ in cli init

Related #2273

* Remove import as _ in gtk

Related #2273

* Remove import as _ in show

Related #2273

* Remove import as _ in tray

Related #2273

* Remove import as _ in profiles

Related #2273

* Remove import as _ in config

Related #2273

* Remove import as _ in config panel

Related #2273

* Remove import as _ in window

Related #2273

* Remove import as _ in pair

Related #2273

* Remove import as _ in pair window

Related #2273

* Remove import as _ in cli package

Related #2273

* Remove import as _ in ui package

Related #2273

* Remove commented out code

Related #2273

* Use constant for Logitech ID
2024-07-15 08:37:18 -04:00
Peter F. Patel-Schneider d9d67ed738 device: handle unknown device kinds 2024-07-02 10:59:16 -04:00
Peter F. Patel-Schneider 71d2a50cb4 docs: fix broken links to Solaar logo 2024-07-02 07:57:00 -04:00
IskandarMa 25b9ba70d2
po: Update zh_CN translation (#2541)
* translation(v1.1.13): update solaar.pot; fix missing zh_CN translation; fix some mis-leading translation in zh_CN

* fix translation error

---------

Co-authored-by: IskandarMa <zhenghe.mt@alibaba-inc.com>
2024-07-01 08:04:34 -04:00
MattHag 59b30706b8
docs: Use mkdocs for public documentation (#2527)
* Add mkdocs config

Build and debug docs locally:
mkdocs serve

* Add mkdocs config

* Introduce GitHub action for mkdocs

* Delete outdated doc files

* Generate Python documentation

* Clean up docs

- Remove ToDos from public docs
2024-07-01 08:03:50 -04:00
MattHag f40a5cc7a9
Clean up setup.py (#2536)
- Indent description
- Remove commented code

Related #2273
2024-06-29 15:23:38 -04:00
proletarius101 6d4cf80c89
docs: dead links in the AppStream file (#2539) 2024-06-29 15:22:25 -04:00
Anderson Silva 8ab8cb0225
docs: Update about.py (#2535)
Update copyright date in about page to reflect current year (2024)

Follow-up to #2074
2024-06-23 17:56:52 -04:00
Peter F. Patel-Schneider 3aa064b40f settings: finish change to new constants 2024-06-13 10:01:50 -04:00
Peter F. Patel-Schneider db93e9ab10 hidapi: remove check on driver 2024-06-13 07:44:47 -04:00
Peter F. Patel-Schneider a7784b40ab cli: finish change to show.py for new constants 2024-06-13 05:54:36 -04:00
MattHag 86b55b9c25 Introduce Enum BusID
Distinguishes Bluetooth and USB devices.
2024-06-03 08:37:02 -04:00
MattHag 7f5e156fa1 Introduce constant for Logitech vendor ID
The Vendor ID for Logitech is 0x46D = 1133.
2024-06-03 08:37:02 -04:00
MattHag d67466298b Introduce Enum for notification types 2024-06-03 08:37:02 -04:00
MattHag 9726b93a78 Improve base module
Use clearer names and type hints.
2024-06-03 08:37:02 -04:00
MattHag e316ed1bc2 Remove unnecessary receiver info 'hid_driver'
The same constant is used everywhere.
2024-06-03 08:37:02 -04:00
MattHag a5ded24057 Convert HIDPPNotification to dataclass
Replaces the very last namedtuple.
2024-06-03 08:37:02 -04:00
Peter F. Patel-Schneider 2113e63a75 device: be defensive when converting battery status to string 2024-06-03 08:33:11 -04:00
MattHag 104556e7a3 Automatically detect packages in /lib
Automate handling of internal packages.
2024-06-02 10:42:57 -04:00
MattHag be83dac209
hid: Convert definition of HID registers to enum
* Refactor HID Register definitions

Use enums for distinct type hints, easy discovery of registers.
Make constants uppercase and benefit from enum auto-completion.

Related #2273

* Improve type hints: Registers
2024-06-02 10:34:00 -04:00
MattHag c23ebcd267 Use double quotes for module level docstrings
Make module level docstrings distinguishable from license text.

Related #2273
2024-06-02 09:54:21 -04:00
MattHag 5a63e44d58 Remove empty comment lines
Remove hashtags solely used for structuring.

Related #2273
2024-06-02 09:54:21 -04:00
MattHag 244d0ee88a
solaar: clean up locale code
Usage example for German:
LC_ALL=de_DE.UTF-8 solaar

Related #2507
2024-06-01 12:09:55 -04:00
Peter F. Patel-Schneider 089b85676f docs: update built-in rules 2024-06-01 11:59:57 -04:00
MattHag cece723ea4
docs: Improve rules documentation
- Page heading
- Fix heading levels
- Improve some names
2024-05-27 12:50:42 -04:00
MattHag c29231bc6b
refactor: Creation of devices (#2493)
* Refine interfaces for testability

* Reenable fixed device tests
2024-05-27 11:58:16 -04:00
MattHag faf27ca323
docs: Add headings to structure rules.md
Allow users to find relevant information without reading a
long wall of text.
2024-05-27 09:55:41 -04:00
MattHag 815dce07be Unify imports in logitech package
Related #2273
2024-05-23 16:42:18 -04:00
Peter F. Patel-Schneider 90b0db6c3b device: don't ping device when getting name or codename 2024-05-22 21:22:08 -04:00
Matthias Hagmann c9dc232951 Refactor: Use dataclasses and enums
Replace unnecessary NamedInts in favour of default data types.
Simplify interfaces by reducing possible input from strings to members
of an enum.
2024-05-22 21:14:41 -04:00
Matthias Hagmann 469c04faaf Introduce Device protocol and type hints 2024-05-22 21:14:41 -04:00
Matthias Hagmann 675cd6ee34 Add typing_extensions dependency 2024-05-22 21:14:41 -04:00
Matthias Hagmann 193dbfda21 hidpp10: Move independent functions to module level 2024-05-22 21:14:41 -04:00
Matthias Hagmann 7d171b1d09 Refactor: Use dataclasses and enums
Replace NamedTuples with more flexible dataclass, which also support
type hints. Introduce enums to replace constant strings, which need to
be kept in sync. Also enhances interfaces by limiting it to the enum
values. Remove unused variables.
2024-05-22 21:14:41 -04:00
MattHag 500b9998c5
Fix macOS compatibility and reenable CI tests
* Fix CI for macOS

* Fix error message for missing hidapi

* Skip some device and receiver tests on macOS

Tests fail on macOS, enable them when unit tests are
refined to only test the module without dependencies.

* Safe guard dbus import
2024-05-22 18:45:40 -04:00
MattHag a9ce033cc8
docs: Update README.md
Related #2485
2024-05-16 17:48:33 -04:00
MattHag 9882d99125 docs: Add high-level graph of components
Gives an overview of the main components of Solaar and
their connections.
2024-05-16 15:59:41 -04:00
MattHag d0a3e474c7
hidapi: Unify imports in hidapi package (#2487)
Remove all 'import xyz as _xyz' and favor import of module name to
get more context in the code.

Related #2273
2024-05-16 15:58:22 -04:00
MattHag f15a50b4b2 docs: Move screenshots into dedicated folder
Clean up docs folder.
2024-05-16 15:55:44 -04:00
david_david 9d2cedbefe
po: Update French translation (for release 1.1.13)
- by David Geiger david.david@mageialinux-online.org
2024-05-12 09:58:51 -04:00
MattHag b321167304
Drop support for end-of-life Python 3.7
* Drop support for end-of-life Python 3.7

* Remove handling of code for Python 3.7 and older
2024-05-11 11:48:32 -04:00
149 changed files with 20281 additions and 15438 deletions

22
.coveragerc Normal file
View File

@ -0,0 +1,22 @@
[run]
branch = True
source =
hid_parser
hidapi
keysyms
logitech_receiver
solaar
omit =
*/tests/*
*/setup.py
*/__main__.py
[report]
exclude_lines =
pragma: no cover
if __name__ == '__main__':
if typing.TYPE_CHECKING
fail_under = 40

52
.github/workflows/gh-pages.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- master
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: 'pages'
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: Install dependencies
run: |
pip install mkdocs mkdocs-rtd-dropdown mkdocs-mermaid2-plugin mkdocstrings[python]
- name: Build and deploy
run: |
mkdocs build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: 'site'
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@ -8,7 +8,8 @@ jobs:
strategy:
matrix:
python-version: [3.7, 3.12]
python-version: [3.8, 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"]'
@ -31,30 +38,53 @@ jobs:
run: |
make test
# macos-tests:
# runs-on: macos-latest
#
# strategy:
# matrix:
# python-version: [3.7, 3.12]
#
# steps:
# - name: Checkout
# uses: actions/checkout@v4
#
# - name: Set up Python ${{ matrix.python-version }}
# uses: actions/setup-python@v5
# with:
# python-version: ${{ matrix.python-version }}
#
# - name: Set up macOS dependencies
# run: |
# make install_brew
#
# - name: Install Python dependencies
# run: |
# make install_pip PIP_ARGS='.["test"]'
#
# - name: Run tests on macOS
# run: |
# make test
- name: Upload coverage to Codecov
if: github.ref == 'refs/heads/master'
uses: codecov/codecov-action@v4.5.0
with:
directory: ./coverage/reports/
env_vars: OS, PYTHON
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
token: ${{ secrets.CODECOV_TOKEN }}
macos-tests:
runs-on: macos-latest
strategy:
matrix:
python-version: [3.8, 3.13]
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- 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: |
pytest --cov --cov-report=xml
- name: Upload coverage to Codecov
if: github.ref == 'refs/heads/master'
uses: codecov/codecov-action@v4.5.0
with:
directory: ./coverage/reports/
env_vars: OS, PYTHON
files: ./coverage.xml
flags: unittests
name: codecov-umbrella
token: ${{ secrets.CODECOV_TOKEN }}

6
.gitignore vendored
View File

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

View File

@ -1,3 +1,131 @@
# 1.1.15rc1
* 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
* Fix battery entries in config.yaml
* Add ratchet setting for smart shift enhanced devices
* Refactor Gesture into enum
* Replace ERROR NamedInts by IntEnum (#2645)
* Refactor hidpp20 to use enum
* Update Polish, Swedish, Norwegian Nynorsk (nn), and Norwegian Bokmål (nb) translations
* Use IntEnum for firmware and cidgroup constances
* Change pairing error values to intenums
* Fix initialization bug for PackedRangeControl
* Add tests for feature class, process_notification, and key_is_down
* Check all bits for extended report rate
* Add type hints
* Improve about dialog
* Reduce dependencies
* Refactor code
* Improve testing
* Allow unknown keys in Key rule conditions
* Improve documentation for cli actions
* Cycle sw_id to better guard against duplication of messages
* Handle error return on root feature
* Clean up documentation
* Improve github interactions
* Add information about Onboard Profiles overriding some settings
* Add wording to README.md that Solaar is not a device driver
* Clean up imports
* Handle unknown device kinds
* Fix broken links to Solaar logo
* Use mkdocs for public documentation
* Clean up setup.py
* Remove Dead links in the AppStream file
* Update about.py
* Remove check on driver
* Improve base module
* Remove unnecessary receiver info 'hid_driver'
* Convert HIDPPNotification to dataclass
* Be defensive when converting battery status to string
* Automatically detect packages in /lib
* Clean up locale code
* Improve rules documentation
* Refactor creation of devices
* Add headings to structure rules.md
* Unify imports in logitech package
* Don't ping device when getting name or codename
* Use dataclasses and enums where useful
* Introduce Device protocol and type hints
* Add typing_extensions dependency
* Move hidpp10 independent functions to module level
* Fix macOS compatibility and reenable CI tests
* Unify imports in hidapi package
* Move screenshots into dedicated folder and add high-level graph of components
* Update French and Chinese translations
* Drop support for end-of-life Python 3.7
# 1.1.13
* Update Polish and Russian translations.

View File

@ -19,6 +19,11 @@ 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
@ -66,4 +71,4 @@ lint:
test:
@echo "Running Solaar tests"
pytest --cov=lib/ tests/
pytest --cov --cov-report=xml

View File

@ -1,9 +1,10 @@
# <img src="https://pwr-solaar.github.io/Solaar/assets/solaar.svg" width="60px"/> Solaar
# <img src="https://pwr-solaar.github.io/Solaar/img/solaar.svg" width="60px"/> Solaar
Solaar is a Linux manager for many Logitech keyboards, mice, and other devices
that connect wirelessly to a Unifying, Bolt, Lightspeed or Nano receiver
as well as many Logitech devices that connect via a USB cable or Bluetooth.
Solaar is not a device driver and responds only to special messages from devices
that are otherwise ignored by the Linux input system.
<a href="https://pwr-solaar.github.io/Solaar/index">More Information</a> -
<a href="https://pwr-solaar.github.io/Solaar/usage">Usage</a> -
@ -12,18 +13,19 @@ as well as many Logitech devices that connect via a USB cable or Bluetooth.
<a href="https://pwr-solaar.github.io/Solaar/installation">Manual Installation</a>
[![codecov](https://codecov.io/gh/pwr-Solaar/Solaar/graph/badge.svg?token=D7YWFEWID6)](https://codecov.io/gh/pwr-Solaar/Solaar)
[![License: GPL v2](https://img.shields.io/badge/License-GPL%20v2+-blue.svg)](../LICENSE.txt)
<p align="center">
<img src="https://pwr-solaar.github.io/Solaar/Solaar-main-window-multiple.png" width="54%"/>
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-main-window-multiple.png" width="54%"/>
&#160;
<img src="https://pwr-solaar.github.io/Solaar/Solaar-main-window-receiver.png" width="43%"/>
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-main-window-receiver.png" width="43%"/>
</p>
<p align="center">
<img src="https://pwr-solaar.github.io/Solaar/Solaar-main-window-back-divert.png" width="49%"/>
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-main-window-back-divert.png" width="49%"/>
&#160;
<img src="https://pwr-solaar.github.io/Solaar/Solaar-rule-editor.png" width="48%"/>
<img src="https://pwr-solaar.github.io/Solaar/screenshots/Solaar-rule-editor.png" width="48%"/>
</p>
Solaar supports:

View File

@ -1,5 +1,10 @@
# Notes on Major Changes in Releases
## Version 1.1.15
* 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

@ -21,30 +21,16 @@
def init_paths():
"""Make the app work in the source tree."""
import os.path as _path
import os.path
import sys
# Python 2 need conversion from utf-8 filenames
# Python 3 might have problems converting back to UTF-8 in case of Unicode surrogates
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 because non UTF-8 characters in the pathname.\n"
)
sys.exit(1)
prefix = _path.normpath(_path.join(_path.realpath(decoded_path), ".."))
src_lib = _path.join(prefix, "lib")
share_lib = _path.join(prefix, "share", "solaar", "lib")
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")
for location in src_lib, share_lib:
init_py = _path.join(location, "solaar", "__init__.py")
# print ("sys.path[0]: checking", init_py)
if _path.exists(init_py):
# print ("sys.path[0]: found", location, "replacing", sys.path[0])
init_py = os.path.join(location, "solaar", "__init__.py")
if os.path.exists(init_py):
sys.path[0] = location
break

View File

@ -1,9 +0,0 @@
title: Solaar
description: Linux Device Manager for Logitech Unifying Receivers and Devices.
tagline: Linux Device Manager for Logitech Unifying Receivers and Devices.
owner: pwr-Solaar
owner_url: https://github.com/pwr-Solaar
repository: pwr-Solaar/Solaar
show_downloads: false
encoding: utf-8
theme: jekyll-theme-slate

View File

@ -1,53 +0,0 @@
<!DOCTYPE html>
<html lang="{{ site.lang | default: "en-US" }}">
<head>
<meta charset='utf-8'>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,maximum-scale=2">
<link rel="stylesheet" type="text/css" media="screen" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}">
<link rel="icon" type="image/png" href="{{ site.baseurl }}/assets/favicon.png" />
{% seo %}
</head>
<body>
<!-- HEADER -->
<div id="header_wrap" class="outer">
<header class="inner">
{% if site.github.is_project_page %}
<a id="forkme_banner" href="{{ site.github.repository_url }}">View on GitHub</a>
{% endif %}
<h1 id="project_title">
<img src="{{ site.baseurl }}/assets/solaar.svg" style="margin-bottom: -10px; width: 48px; height: 48px; border: 0; box-shadow: none;" />
{{ site.title | default: site.github.repository_name }}</h1>
<h2 id="project_tagline">{{ site.description | default: site.github.project_tagline }}</h2>
{% if site.show_downloads %}
<section id="downloads">
<a class="zip_download_link" href="{{ site.github.zip_url }}">Download this project as a .zip file</a>
<a class="tar_download_link" href="{{ site.github.tar_url }}">Download this project as a tar.gz file</a>
</section>
{% endif %}
</header>
</div>
<!-- MAIN CONTENT -->
<div id="main_content_wrap" class="outer">
<section id="main_content" class="inner">
{{ content }}
</section>
</div>
<!-- FOOTER -->
<div id="footer_wrap" class="outer">
<footer class="inner">
{% if site.github.is_project_page %}
<p class="copyright">{{ site.title | default: site.github.repository_name }} maintained by <a href="{{ site.github.owner_url }}">{{ site.github.owner_name }}</a></p>
{% endif %}
<p>Published with <a href="https://pages.github.com">GitHub Pages</a></p>
</footer>
</div>
</body>
</html>

View File

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html lang="{{ site.lang | default: "en-US" }}">
<head>
<meta charset='utf-8'>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,maximum-scale=2">
<link rel="stylesheet" type="text/css" media="screen" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}">
<link rel="icon" type="image/png" href="{{ site.baseurl }}/assets/favicon.png" />
{% seo %}
</head>
<body>
<!-- HEADER -->
<div id="header_wrap" class="outer">
<header class="inner">
{% if site.github.is_project_page %}
<a id="forkme_banner" href="{{ site.github.repository_url }}">View on GitHub</a>
{% endif %}
<h1 id="project_title">
<a href="{{ site.baseurl }}" style="color: #fff;">
<img src="{{ site.baseurl }}/assets/solaar.svg" style="margin-bottom: -10px; width: 48px; height: 48px; border: 0; box-shadow: none;" />
{{ site.title | default: site.github.repository_name }}</h1>
</a>
</header>
</div>
<!-- MAIN CONTENT -->
<div id="main_content_wrap" class="outer">
<section id="main_content" class="inner">
{{ content }}
</section>
</div>
<!-- FOOTER -->
<div id="footer_wrap" class="outer">
<footer class="inner">
{% if site.github.is_project_page %}
<p class="copyright">{{ site.title | default: site.github.repository_name }} maintained by <a href="{{ site.github.owner_url }}">{{ site.github.owner_name }}</a></p>
{% endif %}
<p>Published with <a href="https://pages.github.com">GitHub Pages</a></p>
</footer>
</div>
</body>
</html>

View File

@ -80,7 +80,7 @@ that they were bought with.
## Device Settings
Solaar can display quite a few changeable settings of receivers and devices.
For a list of HID++ features and their support see [the features page](features).
For a list of HID++ features and their support see [the features page](features.md).
Solaar does not do much beyond using the HID++ protocol to change the
behavior of receivers and devices via changing their settings.
@ -186,13 +186,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 +247,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.

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

@ -56,15 +56,16 @@ Some of the languages Solaar has been translated to are listed below. A full lis
- Italiano: [Michele Olivo][micheleolivo], Lorenzo
- Japanese: Ryunosuke Toda
- Norsk (Bokmål): [John Erling Blad][jeblad]
- Norsk (Nynorsk): [John Erling Blad][jeblad]
- Polski: [Adrian Piotrowicz][nexces], Matthaiks
- Portuguese: Américo Monteiro
- 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
- Svensk: [Daniel Zippert][zipperten], Emelie Snecker
- Swedish: John Erling Blad
- Swedish: John Erling Blad, [Daniel Zippert][zipperten], Emelie Snecker, Jonatan Nyberg
- Turkish: Osman Karagöz
[Rongronggg9]: https://github.com/Rongronggg9
@ -80,3 +81,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

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
docs/img/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -3,12 +3,40 @@ title: Solaar Implementation
layout: page
---
TODO: improve the callback mechanism(s) to support the explicit calls of the UI
# Solaar Implementation
Solaar has three main components: code mostly about receivers and devices, code for the command line interface, and code for the graphical user interface.
The following graph shows the main components of Solaar and how they interact.
```mermaid
graph TD
subgraph User interface
U[UI]
C[CLI]
end
subgraph Core
U --> S{Solaar}
C --> S
S --> L[Logitech receiver]
L --> R[Receiver]
L --> D[Device]
S --> B[dbus]
end
subgraph Hardware interface
R --> A
D --> A
A[hidapi]--> P[hid parser]
end
subgraph Peripherals
P <-.-> M[Logitech mouse]
P <-.-> K[Logitech keyboard]
end
```
## Receivers and Devices
The code in `logitech_receiver` is responsible for creating and maintaining receiver (`receiver/Receiver`) and device (`device/Device`) objects for each device on the computer that uses the Logitech HID++ protocol. These objects are discovered in Linux by interacting with the Linux `udev` system using code in `hidapi`.
@ -40,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
@ -58,8 +86,6 @@ The module `descriptors` sets up information on device models for which Solaar n
The module `base_usb` sets up information for most of the receiver models that Solaar supports, including USB id, USB interface used for HID++ messages, what kind of receiver model it is, and some capabilities of the receiver model. Solaar can now support other receivers as long as they are not too unusual. The module lso sets up lists of device models by USB ID and Bluetooth ID and provides a function to determine whether a USB ID or Bluetooth ID is an HID++ device model
TODO? Move some information down to descriptors?
The module `base` provides functions that call discovery to enumerate all current receivers and devices and to set up a callback for when new receivers or devices are discovered. It provides functions to open and close I/O channels to receivers and devices, write HID++ messages to receivers and devices, and read HID++ messages from receivers and devices. It provides a function to turn an HID++ message into a notification.
The module provides a function to send an HID++ message to a receiver or device, constructing the message from parameters to the function, and optionally waiting for and returning a response. The function checks messages from the receiver or device, only terminating at timeout or when a message that appears to be the response is seen. Other messages are turned into notifications if appropriate and ignoreed otherwise. A separate function sends a ping message and waits for a reply to the ping.
@ -79,8 +105,6 @@ After this processing HID++ 2.0 notifications are sent to the `diversion` module
The `status` module provides the `DeviceStatus` class to record the battery status of a device. It also provides an interface to signal changes to the connection status of the device that can invoke a callback. This callback is used to update the Solaar user interface when the status changes.
TODO: check why solaar/listener.py sets the callback multiple times
### Settings
@ -123,10 +147,6 @@ The Solaar GUI takes these settings and constructs an interface for displaying a
This setup allows for very quick implementation of simple settings but it bypasses the data stored in a device object.
TODO: Refactor settings so that they always use data stored in device objects. Set up code to create a device data as easily as simple settings are creates. Set up code to use this to create a setting object for the Solaar GUI. Use callbacks to control GUI redisplay whenever the device data changes.
### Solaar Rules
@ -143,8 +163,6 @@ The module `common.py` provides utility functions, structures, and classes.
`FirmwareInfo` provides information about device firmware.
`BATTERY_APPROX` provides named integers used for approximate battery levels of devices.
TODO: Move a couple of things from hidpp20 to here.
`i18n.py` provides a few strings that need translations and might not otherwise be visible to translation software.
`special_keys.py` provides named integers for various collections of key codes and colors.
@ -162,8 +180,6 @@ Device and receiver discovery is performed when Solaar starts. While the Solaar
This code is also responsible for actual writing data to devices and receivers and reading data from them.
TOD: Is this actually the case?
## Solaar

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
@ -95,7 +95,7 @@ which is done using the usual Bluetooth mechanisms.
For a partial list of supported devices
and their features, see [the devices page](https://pwr-solaar.github.io/Solaar/devices).
[logo]: https://pwr-solaar.github.io/Solaar/assets/solaar.svg
[logo]: https://pwr-solaar.github.io/Solaar/img/solaar.svg
## Prebuilt packages
@ -133,6 +133,8 @@ 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.

View File

@ -42,8 +42,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 +73,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 +92,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 +132,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.

View File

@ -3,11 +3,10 @@ title: Rule Processing of HID++ Notifications
layout: page
---
# Rule Processing of HID++ Notifications
Creating and editing most rules can be done in the Solaar GUI, by pressing the 'Rule Editor' button in the
Solaar main window.
Rule processing is an experimental feature. Significant changes might be made in response to problems.
Note that rule processing only fully works under X11.
When running under Wayland with X11 libraries loaded some features will not be available.
When running under Wayland without X11 libraries loaded even more features will not be available.
@ -24,8 +23,9 @@ You may have to reboot your system for the write permission to be set up.
Another way to get write access to /dev/uinput is to run `sudo setfacl -m u:${USER}:rw /dev/uinput`
but this needs to be done every time the system is rebooted.
Logitech devices that use HID++ version 2.0 or greater produce feature-based
notifications that Solaar can process using a simple rule language. For
## HID++ notifications and diversion
Logitech devices that use HID++ version 2.0 or greater, produce feature-based
notifications that Solaar can process using a simple rule language. For
example, using rules Solaar can emulate an `XF86_MonBrightnessDown` key tap
in response to the pressing of the `Brightness Down` key on Craft keyboards,
which normally does not produce any input at all when the keyboard is in
@ -34,7 +34,7 @@ Windows mode.
Solaar's rules only trigger on HID++ notifications so device actions that
normally produce HID output have to be first be set (diverted) to
produce HID++ notifications instead of their normal behavior.
Currently Solaar can divert some mouse scroll wheels, some
Currently, Solaar can divert some mouse scroll wheels, some
mouse thumb wheels, the crown of Craft keyboards, and some keys and buttons.
If the scroll wheel, thumb wheel, crown, key, or button is
not diverted by setting the appropriate setting then no HID++ notification is
@ -42,50 +42,60 @@ generated and rules will not be triggered by manipulating the wheel, crown, key,
Look for `HID++` or `Diversion` settings to see what
diversion can be done with your devices.
### Show notifications
Running Solaar with the `-ddd`
option will show information about notifications, including their feature
name, report number, and data.
In response to a feature-based HID++ notification Solaar runs a sequence of
rules. A `Rule` is a sequence of components, which are either sub-rules,
conditions, or actions. Conditions and actions are dictionaries with one
rules. A `Rule` is a sequence of components, which are either sub-rules,
conditions, or actions. Conditions and actions are dictionaries with one
entry whose key is the name of the condition or action and whose value is
the argument of the action.
If the last thing that a rule does is execute an action, no more rules are
processed for the notification.
Rules are evaluated by evaluating each of their components in order. The
Rules are evaluated by evaluating each of their components in order. The
evaluation of a rule is terminated early if a condition component evaluates
to false or the last evaluated sub-component of a component is an action. A
rule is false if its last evaluated component evaluates to a false value.
to false or the last evaluated subcomponent of a component is an action. A
rule is false if its last evaluated component evaluates to false.
## Conditions
### Not
`Not` conditions take a single component and are true if their component
evaluates to a false value.
### Or
`Or` conditions take a sequence of components and are evaluated by
evaluating each of their components in order.
An Or condition is terminated early if a component evaluates to true or the
last evaluated sub-component of a component is an action.
last evaluated subcomponent of a component is an action.
A Or condition is true if its last evaluated component evaluates to a true
value. `And` conditions take a sequence of components which are evaluated the same
value. `And` conditions take a sequence of components which are evaluated the same
as rules.
### Feature
`Feature` conditions are true if the name of the feature of the current
notification is their string argument.
`Report` conditions are true if the report number in the current
notification is their integer argument.
### Key
`Key` conditions are true if the Logitech name of the current **diverted** key or button being pressed is their
string argument. Alternatively, if the argument is a list `[name, action]` where `action`
string argument. Alternatively, if the argument is a list `[name, action]` where `action`
is either `'pressed'` or `'released'`, the key down or key up events of `name` argument are
matched, respectively. Logitech key and button names are shown in the `Key/Button Diversion`
setting. These names are also shown in the output of `solaar show` in the 'reprogrammable keys'
section. Only keys or buttons that have 'divertable' in their report can be diverted.
Some keyboards have Gn, Mn, or MR keys, which are diverted using the 'Divert G Keys' setting.
matched, respectively. Logitech key and button names are shown in the `Key/Button Diversion`
setting. These names are also shown in the output of `solaar show` in the 'Reprogrammable keys'
section. Only keys or buttons that have 'Divertable' in their report can be diverted.
Some keyboards have 'Gn', 'Mn', or 'MR' keys, which are diverted using the 'Divert G Keys' setting.
### Key is down
`KeyIsDown` conditions are true if the **diverted** key or button that is their string argument is currently down.
Note that this only works for **diverted** keys or buttons, including diverted Gn, Mn, and MR keys.
### Key and button diversion
Solaar can also create special notifications in response to mouse movements on some mice.
Setting `Key/Button Diversion` for a key or button to Mouse Gestures causes the key or button to create a `Mouse Gesture`
notification for the period that the key or button is depressed.
@ -95,6 +105,7 @@ Pressing a diverted key creates a key event.
When the key is released the sequence of events is sent as a synthetic notification
that can be matched with `Mouse Gesture` conditions.
### Mouse gestures
`Mouse Gesture` conditions are true if the actions (mouse movements and diverted key presses) taken while a mouse gestures button is held down match the arguments of the condition.
Mouse gestures buttons can be set using the 'Key/Button Diversion' setting, by changing the value to `Mouse Gestures`.
The arguments of a Mouse Gesture condition can be a direction, i.e., `Mouse Up`, `Mouse Down`, `Mouse Left`, `Mouse Right`, `Mouse Up-Left`, `Mouse Up-Right`, `Mouse Down-Left`, or `Mouse Down-Right`, or the Logitech name of a key.
@ -104,24 +115,32 @@ The condition `Smart Shift` -> `Mouse Down` -> `Back Button` would match pressin
Directions and buttons can be mixed and chained together however you like.
It's possible to create a `No-op` gesture by clicking 'Delete' on the initial Action when you first create the rule. This gesture will trigger when you simply click a Mouse Gestures button.
### Key modifiers
`Modifiers` conditions take either a string or a sequence of strings, which
can only be `Shift`, `Control`, `Alt`, and `Super`.
Modifiers conditions are true if their argument is the current keyboard
modifiers.
### Process focused
`Process` conditions are true if the process for the focused input window
or the window's Window manager class or instance name starts with their string argument.
### Window under cursor
`MouseProcess` conditions are true if the process for the window under the mouse
or the window's Window manager class or instance name starts with their string argument.
### Device notification and device active
`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' conditions are true if the computers hostname starts with the condition's argument.
### Host
`Host` conditions are true if the computers hostname starts with the condition's argument.
### Solaar device setting
`Setting` conditions check the value of a Solaar setting on a device.
`Setting` conditions take three or four arguments, depending on the setting:
the Serial number or Unit ID of a device, as shown in Solaar's detail pane,
@ -147,16 +166,17 @@ For settings that use gestures as an argument the internal name of the gesture i
which can be found in the GESTURE2_GESTURES_LABELS structure in lib/logitech_receiver/settings_templates.
For boolean settings '~' can be used to toggle the setting.
### Test and TestBytes
`Test` and `TestBytes` conditions are true if their test evaluates to true on the feature,
report, and data of the current notification.
`TestBytes` conditions can return a number instead of a boolean.
report and data of the current notification.
`TestBytes` conditions can return a number instead of a boolean.
`TestBytes` conditions consist of a sequence of three or four integers and use the first
two to select bytes of the notification data.
Writing this kind of test condition is not trivial.
Three-element `TestBytes` conditions are true if the selected bytes bit-wise anded
Three-element `TestBytes` conditions are true if the selected bytes bit-wise AND
with its third element is non-zero.
The value of these test conditions is the result of the and.
The value of these test conditions is the result of the AND.
Four-element `TestBytes` conditions are true if the selected bytes form a signed
integer between the third and fourth elements.
The value of these conditions is the signed value of the selected bytes
@ -184,12 +204,15 @@ This displacement is reset when the thumb wheel is inactive.
With a parameter the test is only true if the current thumb wheel displacement is greater than the parameter.
The displacement is then lessened by the amount of the parameter.
## Actions
### Key press
A `KeyPress` action takes either the name of an X11 key symbol, such as "a",
a list of X11 key symbols, such as "a" or "Control+a",
a list of X11 key symbols, such as "a" or "CTRL + A",
or a two-element list with the first element as above
and the second element one of 'click', 'depress', or 'release'
and the second element one of `'click'`, `'depress'`, or `'release'`
and executes key actions on a simulated keyboard to produce these symbols.
Use separate `KeyPress` actions for multiple characters,
Use separate `KeyPress` actions for multiple characters,
i.e., don't use a single `KeyPress` like 'a+b'.
The `KeyPress` action normally both depresses and releases (clicks) the keys,
but can also just depress the keys or just release the keys.
@ -215,38 +238,42 @@ simulate inputting a key symbol.
Unfortunately, this determination can go wrong in several ways and is more likely
to go wrong under Wayland than under X11.
### Mouse scroll
A `MouseScroll` action takes a sequence of two numbers and simulates a horizontal and vertical mouse scroll of these amounts.
If the previous condition in the parent rule returns a number the scroll amounts are multiplied by this number.
### 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.
An `Execute` action takes a program and arguments and executes it asynchronously.
### Set setting
A `Set` action changes a Solaar setting for a device, provided that the device is on-line.
`Set` actions take three or four arguments, depending on the setting.
The first two are the Serial number or Unit ID of a device, as shown in Solaar's detail pane,
or null for the device that initiated rule processing; and
the internal name of a setting (which can be found from solaar config \<device\>).
the internal name of a setting (which can be found from `solaar config <device>`).
Simple settings take one extra argument, the value to set the setting to.
For boolean settings '~' can be used to toggle the setting.
For boolean settings `~` can be used to toggle the setting.
Other simple settings take two extra arguments, a key indicating which sub-setting to set and the value to set it to.
For settings that use gestures as an argument the internal name of the gesture is used,
which can be found in the GESTURE2_GESTURES_LABELS structure in lib/logitech_receiver/settings_templates.
which can be found in the GESTURE2_GESTURES_LABELS structure in `lib/logitech_receiver/settings_templates`.
All settings are supported.
### Later
A `Later` action executes rule components later.
`Later` actions take an integer delay in seconds between 1 and 100 followed by zero or more rule components that will be executed later.
Processing of the rest of the rule continues immediately.
Solaar has several built-in rules, which are run after user-created rules and so can be overridden by user-created rules.
One rule turns
## Built-in Rules
Solaar has a built-in rule, which is run after user-created rules and so can be overridden by user-created rules.
This rule turns
`Brightness Down` key press notifications into `XF86_MonBrightnessDown` key taps
and `Brightness Up` key press notifications into `XF86_MonBrightnessUp` key taps.
Another rule makes Craft crown ratchet movements move between tabs when the crown is pressed
and up and down otherwise.
A third rule turns Craft crown ratchet movements into `XF86_AudioNext` or `XF86_AudioPrev` key taps when the crown is pressed and `XF86_AudioRaiseVolume` or `XF86_AudioLowerVolume` otherwise.
A fourth rule doubles the speed of `THUMB WHEEL` movements unless the `Control` modifier is on.
All of these rules are only active if the key or feature is diverted, of course.
## Example Solaar Rule File
Solaar reads rules from a YAML configuration file (normally `~/.config/solaar/rules.yaml`).
This file contains zero or more documents, each a rule.
@ -294,10 +321,11 @@ Here is a file with six rules:
...
```
## Button diversion example
Here is an example showing how to divert the Back Button on an MX Master 3 so that pressing
the button will initiate rule processing and a rule that triggers on this notification and
switches the mouse to host 3 after popping up a simple notification.
![Solaar-divert-back](Solaar-main-window-back-divert.png)
![Solaar-divert-back](screenshots/Solaar-main-window-back-divert.png)
![Solaar-rule-back-host](Solaar-rule-editor.png)
![Solaar-rule-back-host](screenshots/Solaar-rule-editor.png)

View File

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View File

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

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

@ -22,7 +22,7 @@ The following is an image of the Solaar menu and the icon (the battery
symbol is in the system tray at the left of the image). The icon can
also be other battery icons or versions of the Logitech Unifying icon.
![Solaar-menu](Solaar-menu.png)
![Solaar-menu](screenshots/Solaar-menu.png)
Clicking on “Quit” in the Solaar menu terminates the program.
Clicking on “About Solaar” pops up a window with further information about Solaar.
@ -64,7 +64,7 @@ To pair with a Bolt receiver you have to type a passcode followed by enter
or click the left and right buttons in the correct sequence followed by
clicking both buttons simultaneously.
![Solaar-main-window-receiver](Solaar-main-window-receiver.png)
![Solaar-main-window-receiver](screenshots/Solaar-main-window-receiver.png)
When a device is selected you can unpair the device if your receiver supports
unpairing. To unpair the device, just click on the “Unpair” button and
@ -93,26 +93,26 @@ You can also see and change the settings of devices.
Changing settings is performed by clicking on buttons,
moving sliders, or selecting from alternatives.
![Solaar-main-window-keyboard](Solaar-main-window-keyboard.png)
![Solaar-main-window-keyboard](screenshots/Solaar-main-window-keyboard.png)
![Solaar-main-window-mouse](Solaar-main-window-mouse.png)
![Solaar-main-window-mouse](screenshots/Solaar-main-window-mouse.png)
Device settings now have a clickable icon that determines whether the
setting can be changed and whether the setting is ignored.
![Solaar-divert-back](Solaar-main-window-back-divert.png)
![Solaar-divert-back](screenshots/Solaar-main-window-back-divert.png)
If the selected device that is paired with a receiver is powered down or
otherwise disconnected its settings cannot be changed
but it still can be unpaired if its receiver allows unpairing.
![Solaar-main-window-offline](Solaar-main-window-offline.png)
![Solaar-main-window-offline](screenshots/Solaar-main-window-offline.png)
If a device is paired with a receiver but directly connected via USB or Bluetooth
the receiver pairing will show up as well as the direct connection.
The device can only be manipulated using the direct connection.
![Solaar-main-window-multiple](Solaar-main-window-multiple.png)
![Solaar-main-window-multiple](screenshots/Solaar-main-window-multiple.png)
#### Remapping key and button actions
@ -127,7 +127,7 @@ action is always the one shown first in the list. As with all settings,
Solaar will remember past action settings and restore them on the device
from then on.
![Solaar-main-window-actions](Solaar-main-window-button-actions.png)
![Solaar-main-window-actions](screenshots/Solaar-main-window-button-actions.png)
The names of the keys, buttons, and actions are mostly taken from Logitech
documentation and may not be completely obvious.
@ -136,9 +136,9 @@ It is possible to end up with an unusable system, for example by having no
way to do a mouse left click, so exercise caution when remapping keys or
buttons that are needed to operate your system.
## Solaar command line interface
## Solaar command-line interface
Solaar also has a command line interface that can do most of what can be
Solaar also has a command-line interface that can do most of what can be
done using the main window. For more information on the
command line interface, run `solaar --help` to see the commands and
then `solaar <command> --help` to see the arguments to any of the commands.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@ -14,17 +14,13 @@ from typing import Dict
from typing import Iterable
from typing import Iterator
from typing import List
from typing import Literal
from typing import Optional
from typing import Sequence
from typing import TextIO
from typing import Tuple
from typing import Union
if sys.version_info >= (3, 8):
from typing import Literal
else: # pragma: no cover
from typing_extensions import Literal
import hid_parser.data
__version__ = "0.0.3"
@ -564,7 +560,8 @@ class ArrayItem(MainItem):
)
continue
if usage in self._usages and all(usage_type not in self._INCOMPATIBLE_TYPES for usage_type in usage.usage_types):
not_incompatible_type = all(usage_type not in self._INCOMPATIBLE_TYPES for usage_type in usage.usage_types)
if usage in self._usages and not_incompatible_type:
usage_values[usage] = UsageValue(self, True)
return usage_values
@ -824,14 +821,28 @@ class ReportDescriptor:
if data is None:
raise InvalidReportDescriptor("Invalid output item")
self._append_items(
offset_output, self._output, report_id, report_count, report_size, usages, data, {**glob, **local}
offset_output,
self._output,
report_id,
report_count,
report_size,
usages,
data,
{**glob, **local},
)
elif tag == TagMain.FEATURE:
if data is None:
raise InvalidReportDescriptor("Invalid feature item")
self._append_items(
offset_feature, self._feature, report_id, report_count, report_size, usages, data, {**glob, **local}
offset_feature,
self._feature,
report_id,
report_count,
report_size,
usages,
data,
{**glob, **local},
)
# clear local

View File

@ -1,47 +0,0 @@
## Copyright (C) 2012-2013 Daniel Pavel
##
## 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.
"""Generic Human Interface Device API."""
import platform as _platform
if _platform.system() in ("Darwin", "Windows"):
from hidapi.hidapi import close # noqa: F401
from hidapi.hidapi import enumerate # noqa: F401
from hidapi.hidapi import find_paired_node # noqa: F401
from hidapi.hidapi import find_paired_node_wpid # noqa: F401
from hidapi.hidapi import get_manufacturer # noqa: F401
from hidapi.hidapi import get_product # noqa: F401
from hidapi.hidapi import get_serial # noqa: F401
from hidapi.hidapi import monitor_glib # noqa: F401
from hidapi.hidapi import open # noqa: F401
from hidapi.hidapi import open_path # noqa: F401
from hidapi.hidapi import read # noqa: F401
from hidapi.hidapi import write # noqa: F401
else:
from hidapi.udev import close # noqa: F401
from hidapi.udev import enumerate # noqa: F401
from hidapi.udev import find_paired_node # noqa: F401
from hidapi.udev import find_paired_node_wpid # noqa: F401
from hidapi.udev import get_manufacturer # noqa: F401
from hidapi.udev import get_product # noqa: F401
from hidapi.udev import get_serial # noqa: F401
from hidapi.udev import monitor_glib # noqa: F401
from hidapi.udev import open # noqa: F401
from hidapi.udev import open_path # noqa: F401
from hidapi.udev import read # noqa: F401
from hidapi.udev import write # noqa: F401
__version__ = "0.9"

20
lib/hidapi/common.py Normal file
View File

@ -0,0 +1,20 @@
from __future__ import annotations
import dataclasses
@dataclasses.dataclass
class DeviceInfo:
path: str
bus_id: str | None
vendor_id: str
product_id: str
interface: str | None
driver: str | None
manufacturer: str | None
product: str | None
serial: str | None
release: str | None
isDevice: bool
hidpp_short: str | None
hidpp_long: str | None

View File

@ -22,43 +22,32 @@ See https://github.com/libusb/hidapi for how to obtain binaries.
Parts of this code are adapted from https://github.com/apmorton/pyhidapi
which is MIT licensed.
"""
from __future__ import annotations
import atexit
import ctypes
import logging
import platform as _platform
import platform
import typing
from collections import namedtuple
from threading import Thread
from time import sleep
from typing import Any
from typing import Callable
import gi
from hidapi.common import DeviceInfo
gi.require_version("Gdk", "3.0")
from gi.repository import GLib # NOQA: E402
if typing.TYPE_CHECKING:
import gi
gi.require_version("Gdk", "3.0")
from gi.repository import GLib # NOQA: E402
logger = logging.getLogger(__name__)
native_implementation = "hidapi"
# Device info as expected by Solaar
DeviceInfo = namedtuple(
"DeviceInfo",
[
"path",
"bus_id",
"vendor_id",
"product_id",
"interface",
"driver",
"manufacturer",
"product",
"serial",
"release",
"isDevice",
"hidpp_short",
"hidpp_long",
],
)
ACTION_ADD = "add"
ACTION_REMOVE = "remove"
# Global handle to hidapi
_hidapi = None
@ -83,7 +72,7 @@ for lib in _library_paths:
except OSError:
pass
else:
raise ImportError(f"Unable to load hdiapi library, tried: {' '.join(_library_paths)}")
raise ImportError(f"Unable to load hidapi library, tried: {' '.join(_library_paths)}")
# Retrieve version of hdiapi library
@ -172,7 +161,7 @@ atexit.register(_hidapi.hid_exit)
# Solaar opens the same device more than once which will fail unless we
# allow non-exclusive opening. On windows opening with shared access is
# the default, for macOS we need to set it explicitly.
if _platform.system() == "Darwin":
if platform.system() == "Darwin":
_hidapi.hid_darwin_set_open_exclusive.argtypes = [ctypes.c_int]
_hidapi.hid_darwin_set_open_exclusive.restype = None
_hidapi.hid_darwin_set_open_exclusive(0)
@ -192,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:
@ -216,6 +198,7 @@ class _DeviceMonitor(Thread):
def __init__(self, device_callback, polling_delay=5.0):
self.device_callback = device_callback
self.polling_delay = polling_delay
self.prev_devices = None
# daemon threads are automatically killed when main thread exits
super().__init__(daemon=True)
@ -228,20 +211,29 @@ class _DeviceMonitor(Thread):
current_devices = {tuple(dev.items()): dev for dev in _enumerate_devices()}
for key, device in self.prev_devices.items():
if key not in current_devices:
self.device_callback("remove", device)
self.device_callback(ACTION_REMOVE, device)
for key, device in current_devices.items():
if key not in self.prev_devices:
self.device_callback("add", device)
self.device_callback(ACTION_ADD, device)
self.prev_devices = current_devices
sleep(self.polling_delay)
# The filterfn is used to determine whether this is a device of 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.
def _match(action, device, filterfn):
def _match(
action: str,
device: dict[str, Any],
filter_func: Callable[[int, int, int, bool, bool], dict[str, Any]],
):
"""
The filter_func is used to determine whether this is a device of
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.
"""
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:
@ -250,40 +242,65 @@ def _match(action, device, filterfn):
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"])
report = get_input_report(device_handle, 0x10, 32)
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
report = get_input_report(device_handle, 0x11, 32)
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
filter = filterfn(bus_id, vid, pid, device["hidpp_short"], device["hidpp_long"])
if not filter:
filtered_result = filter_func(bus_id, vid, pid, device["hidpp_short"], device["hidpp_long"])
if not filtered_result:
return
isDevice = filter.get("isDevice")
is_device = filtered_result.get("isDevice")
if action == "add":
if action == ACTION_ADD:
d_info = DeviceInfo(
path=device["path"].decode(),
bus_id=bus_id,
@ -295,13 +312,13 @@ def _match(action, device, filterfn):
product=device["product_string"],
serial=device["serial_number"],
release=device["release_number"],
isDevice=isDevice,
isDevice=is_device,
hidpp_short=device["hidpp_short"],
hidpp_long=device["hidpp_long"],
)
return d_info
elif action == "remove":
elif action == ACTION_REMOVE:
d_info = DeviceInfo(
path=device["path"].decode(),
bus_id=None,
@ -313,31 +330,48 @@ def _match(action, device, filterfn):
product=None,
serial=None,
release=None,
isDevice=isDevice,
isDevice=is_device,
hidpp_short=None,
hidpp_long=None,
)
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, index, timeout):
def find_paired_node(receiver_path: str, index: int, timeout: int):
"""Find the node of a device paired with a receiver"""
return None
def find_paired_node_wpid(receiver_path, index):
def find_paired_node_wpid(receiver_path: str, index: int):
"""Find the node of a device paired with a receiver, get wpid from udev"""
return None
def monitor_glib(callback, filterfn):
def device_callback(action, device):
# print(f"device_callback({action}): {device}")
if action == "add":
d_info = _match(action, device, filterfn)
def monitor_glib(
glib: GLib,
callback: Callable,
filter_func: Callable[[int, int, int, bool, bool], dict[str, Any]],
) -> None:
"""Monitor GLib.
Parameters
----------
glib
GLib instance.
callback
Called when device found.
filter_func
Filter devices callback.
"""
def device_callback(action: str, device):
if action == ACTION_ADD:
d_info = _match(action, device, filter_func)
if d_info:
GLib.idle_add(callback, action, d_info)
elif action == "remove":
glib.idle_add(callback, action, d_info)
elif action == ACTION_REMOVE:
# Removed devices will be detected by Solaar directly
pass
@ -345,7 +379,7 @@ def monitor_glib(callback, filterfn):
monitor.start()
def enumerate(filterfn):
def enumerate(filter_func) -> DeviceInfo:
"""Enumerate the HID Devices.
List all the HID devices attached to the system, optionally filtering by
@ -354,7 +388,7 @@ def enumerate(filterfn):
:returns: a list of matching ``DeviceInfo`` tuples.
"""
for device in _enumerate_devices():
d_info = _match("add", device, filterfn)
d_info = _match(ACTION_ADD, device, filter_func)
if d_info:
yield d_info
@ -375,7 +409,7 @@ def open(vendor_id, product_id, serial=None):
return device_handle
def open_path(device_path):
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().
@ -391,7 +425,7 @@ def open_path(device_path):
return device_handle
def close(device_handle):
def close(device_handle) -> None:
"""Close a HID device.
:param device_handle: a device handle returned by open() or open_path().
@ -400,7 +434,7 @@ def close(device_handle):
_hidapi.hid_close(device_handle)
def write(device_handle, data):
def write(device_handle: int, data: bytes) -> int:
"""Write an Output report to a HID device.
:param device_handle: a device handle returned by open() or open_path().
@ -457,12 +491,11 @@ def read(device_handle, bytes_count, timeout_ms=None):
if bytes_read < 0:
raise HIDError(_hidapi.hid_error(device_handle))
return None
return data.raw[:bytes_read]
def get_input_report(device_handle, report_id, size):
def _get_input_report(device_handle, report_id, size):
assert device_handle
data = ctypes.create_string_buffer(size)
data[0] = bytearray((report_id,))

View File

@ -17,17 +17,23 @@
import argparse
import os
import os.path
import platform
import readline
import sys
import time
from binascii import hexlify
from binascii import unhexlify
from select import select as _select
from select import select
from threading import Lock
from threading import Thread
import hidapi as _hid
if platform.system() == "Linux":
import hidapi.udev_impl as hidapi
else:
import hidapi.hidapi_impl as hidapi
LOGITECH_VENDOR_ID = 0x046D
interactive = os.isatty(0)
prompt = "?? Input: " if interactive else ""
@ -38,17 +44,13 @@ def strhex(d):
return hexlify(d).decode("ascii").upper()
#
#
#
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))
@ -86,9 +88,9 @@ def _error(text, scroll=False):
def _continuous_read(handle, timeout=2000):
while True:
try:
reply = _hid.read(handle, 128, timeout)
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:
@ -99,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:
@ -109,7 +111,7 @@ def _validate_input(line, hidpp=False):
if data[:1] not in b"\x10\x11":
_error("Invalid HID++ request: first byte must be 0x10 or 0x11")
return None
if data[1:2] not in b"\xFF\x00\x01\x02\x03\x04\x05\x06\x07":
if data[1:2] not in b"\xff\x00\x01\x02\x03\x04\x05\x06\x07":
_error("Invalid HID++ request: second byte must be 0xFF or one of 0x00..0x07")
return None
if data[:1] == b"\x10":
@ -130,12 +132,12 @@ def _validate_input(line, hidpp=False):
def _open(args):
def matchfn(bid, vid, pid, _a, _b):
if vid == 0x046D:
return {"vid": 0x046D}
if vid == LOGITECH_VENDOR_ID:
return {"vid": vid}
device = args.device
if args.hidpp and not device:
for d in _hid.enumerate(matchfn):
for d in hidapi.enumerate(matchfn):
if d.driver == "logitech-djreceiver":
device = d.path
break
@ -145,30 +147,25 @@ def _open(args):
sys.exit("!! Device path required.")
print(".. Opening device", device)
handle = _hid.open_path(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, _hid.get_manufacturer(handle), _hid.get_product(handle), _hid.get_serial(handle))
% (handle, hidapi.get_manufacturer(handle), hidapi.get_product(handle), hidapi.get_serial(handle))
)
if args.hidpp:
if _hid.get_manufacturer(handle) is not None and _hid.get_manufacturer(handle) != b"Logitech":
if hidapi.get_manufacturer(handle) is not None and hidapi.get_manufacturer(handle) != b"Logitech":
sys.exit("!! Only Logitech devices support the HID++ protocol.")
print(".. HID++ validation enabled.")
else:
if _hid.get_manufacturer(handle) == b"Logitech" and b"Receiver" in _hid.get_product(handle):
if hidapi.get_manufacturer(handle) == b"Logitech" and b"Receiver" in hidapi.get_product(handle):
args.hidpp = True
print(".. Logitech receiver detected, HID++ validation enabled.")
return handle
#
#
#
def _parse_arguments():
arg_parser = argparse.ArgumentParser()
arg_parser.add_argument("--history", help="history file (default ~/.hidconsole-history)")
@ -218,11 +215,11 @@ def main():
continue
_print("<<", data)
_hid.write(handle, data)
hidapi.write(handle, data)
# wait for some kind of reply
if args.hidpp and not interactive:
rlist, wlist, xlist = _select([handle], [], [], 1)
if data[1:2] == b"\xFF":
rlist, wlist, xlist = select([handle], [], [], 1)
if data[1:2] == b"\xff":
# the receiver will reply very fast, in a few milliseconds
time.sleep(0.010)
else:
@ -236,7 +233,7 @@ def main():
finally:
print(f".. Closing handle {handle!r}")
_hid.close(handle)
hidapi.close(handle)
if interactive:
readline.write_history_file(args.history)

View File

@ -13,6 +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.
"""Generic Human Interface Device API.
It is currently a partial pure-Python implementation of the native HID API
@ -22,52 +23,37 @@ The docstrings are mostly copied from the hidapi API header, with changes where
necessary.
"""
import errno as _errno
from __future__ import annotations
import errno
import logging
import os as _os
import warnings as _warnings
import os
import typing
import warnings
# the tuple object we'll expose when enumerating devices
from collections import namedtuple
from select import select as _select
from select import select
from time import sleep
from time import time as _timestamp
from time import time
from typing import Callable
import gi
import pyudev
from pyudev import Context as _Context
from pyudev import Device as _Device
from pyudev import DeviceNotFoundError
from pyudev import Devices as _Devices
from pyudev import Monitor as _Monitor
from hidapi.common import DeviceInfo
gi.require_version("Gdk", "3.0")
from gi.repository import GLib # NOQA: E402
if typing.TYPE_CHECKING:
import gi
gi.require_version("Gdk", "3.0")
from gi.repository import GLib # NOQA: E402
logger = logging.getLogger(__name__)
native_implementation = "udev"
fileopen = open
DeviceInfo = namedtuple(
"DeviceInfo",
[
"path",
"bus_id",
"vendor_id",
"product_id",
"interface",
"driver",
"manufacturer",
"product",
"serial",
"release",
"isDevice",
"hidpp_short",
"hidpp_long",
],
)
ACTION_ADD = "add"
ACTION_REMOVE = "remove"
#
# exposed API
@ -93,12 +79,14 @@ def exit():
return True
# The filterfn is used to determine whether this is a device of 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.
def _match(action, device, filterfn):
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f"Dbus event {action} {device}")
def _match(action: str, device, filter_func: typing.Callable[[int, int, int, bool, bool], dict[str, typing.Any]]):
"""
The filter_func is used to determine whether this is a device of
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."""
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
@ -111,14 +99,14 @@ def _match(action, device, filterfn):
return # these are devices connected through a receiver so don't pick them up here
try: # if report descriptor does not indicate HID++ capabilities then this device is not of interest to Solaar
from hid_parser import ReportDescriptor as _ReportDescriptor
from hid_parser import ReportDescriptor
hidpp_short = hidpp_long = False
devfile = "/sys" + hid_device.properties.get("DEVPATH") + "/report_descriptor"
with fileopen(devfile, "rb") as fd:
with _warnings.catch_warnings():
_warnings.simplefilter("ignore")
rd = _ReportDescriptor(fd.read())
with warnings.catch_warnings():
warnings.simplefilter("ignore")
rd = ReportDescriptor(fd.read())
hidpp_short = 0x10 in rd.input_report_ids and 6 * 8 == int(rd.get_input_report_size(0x10))
# and _Usage(0xFF00, 0x0001) in rd.get_input_items(0x10)[0].usages # be more permissive
hidpp_long = 0x11 in rd.input_report_ids and 19 * 8 == int(rd.get_input_report_size(0x11))
@ -126,43 +114,39 @@ def _match(action, device, filterfn):
if not hidpp_short and not hidpp_long:
return
except Exception as e: # if can't process report descriptor fall back to old scheme
hidpp_short = hidpp_long = None
hidpp_short = None
hidpp_long = None
logger.info(
"Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s", device.device_node, bid, vid, pid, e
"Report Descriptor not processed for DEVICE %s BID %s VID %s PID %s: %s",
device.device_node,
bid,
vid,
pid,
e,
)
filter = filterfn(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short, hidpp_long)
if not filter:
filtered_result = filter_func(int(bid, 16), int(vid, 16), int(pid, 16), hidpp_short, hidpp_long)
if not filtered_result:
return
hid_driver = filter.get("hid_driver")
interface_number = filter.get("usb_interface")
isDevice = filter.get("isDevice")
interface_number = filtered_result.get("usb_interface")
isDevice = filtered_result.get("isDevice")
if action == "add":
if action == ACTION_ADD:
hid_driver_name = hid_device.properties.get("DRIVER")
# print ("** found hid", action, device, "hid:", hid_device, hid_driver_name)
if hid_driver:
if isinstance(hid_driver, tuple):
if hid_driver_name not in hid_driver:
return
elif hid_driver_name != hid_driver:
return
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
@ -184,9 +168,7 @@ def _match(action, device, filterfn):
)
return d_info
elif action == "remove":
# print (dict(device), dict(usb_device))
elif action == ACTION_REMOVE:
d_info = DeviceInfo(
path=device.device_node,
bus_id=None,
@ -205,31 +187,31 @@ def _match(action, device, filterfn):
return d_info
def find_paired_node(receiver_path, index, timeout):
def find_paired_node(receiver_path: str, index: int, timeout: int):
"""Find the node of a device paired with a receiver"""
context = _Context()
receiver_phys = _Devices.from_device_file(context, receiver_path).find_parent("hid").get("HID_PHYS")
context = pyudev.Context()
receiver_phys = pyudev.Devices.from_device_file(context, receiver_path).find_parent("hid").get("HID_PHYS")
if not receiver_phys:
return None
phys = f"{receiver_phys}:{index}" # noqa: E231
timeout += _timestamp()
delta = _timestamp()
timeout += time()
delta = time()
while delta < timeout:
for dev in context.list_devices(subsystem="hidraw"):
dev_phys = dev.find_parent("hid").get("HID_PHYS")
if dev_phys and dev_phys == phys:
return dev.device_node
delta = _timestamp()
delta = time()
return None
def find_paired_node_wpid(receiver_path, index):
def find_paired_node_wpid(receiver_path: str, index: int):
"""Find the node of a device paired with a receiver, get wpid from udev"""
context = _Context()
receiver_phys = _Devices.from_device_file(context, receiver_path).find_parent("hid").get("HID_PHYS")
context = pyudev.Context()
receiver_phys = pyudev.Devices.from_device_file(context, receiver_path).find_parent("hid").get("HID_PHYS")
if not receiver_phys:
return None
@ -247,55 +229,48 @@ def find_paired_node_wpid(receiver_path, index):
return None
def monitor_glib(callback, filterfn):
c = _Context()
def monitor_glib(glib: GLib, callback: Callable, filter_func: Callable):
"""Monitor GLib.
# already existing devices
# for device in c.list_devices(subsystem='hidraw'):
# # print (device, dict(device), dict(device.attributes))
# for filter in device_filters:
# d_info = _match('add', device, *filter)
# if d_info:
# GLib.idle_add(callback, 'add', d_info)
# break
m = _Monitor.from_netlink(c)
Parameters
----------
glib
GLib instance.
"""
c = pyudev.Context()
m = pyudev.Monitor.from_netlink(c)
m.filter_by(subsystem="hidraw")
def _process_udev_event(monitor, condition, cb, filterfn):
if condition == GLib.IO_IN:
def _process_udev_event(monitor, condition, cb, filter_func):
if condition == glib.IO_IN:
event = monitor.receive_device()
if event:
action, device = event
# print ("***", action, device)
if action == "add":
d_info = _match(action, device, filterfn)
if action == ACTION_ADD:
d_info = _match(action, device, filter_func)
if d_info:
GLib.idle_add(cb, action, d_info)
elif action == "remove":
glib.idle_add(cb, action, d_info)
elif action == ACTION_REMOVE:
# the GLib notification does _not_ match!
pass
return True
try:
# io_add_watch_full may not be available...
GLib.io_add_watch_full(m, GLib.PRIORITY_LOW, GLib.IO_IN, _process_udev_event, callback, filterfn)
# print ("did io_add_watch_full")
glib.io_add_watch_full(m, glib.PRIORITY_LOW, glib.IO_IN, _process_udev_event, callback, filter_func)
except AttributeError:
try:
# and the priority parameter appeared later in the API
GLib.io_add_watch(m, GLib.PRIORITY_LOW, GLib.IO_IN, _process_udev_event, callback, filterfn)
# print ("did io_add_watch with priority")
glib.io_add_watch(m, glib.PRIORITY_LOW, glib.IO_IN, _process_udev_event, callback, filter_func)
except Exception:
GLib.io_add_watch(m, GLib.IO_IN, _process_udev_event, callback, filterfn)
# print ("did io_add_watch")
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()
def enumerate(filterfn):
def enumerate(filter_func: typing.Callable[[int, int, int, bool, bool], dict[str, typing.Any]]):
"""Enumerate the HID Devices.
List all the HID devices attached to the system, optionally filtering by
@ -304,10 +279,9 @@ def enumerate(filterfn):
:returns: a list of matching ``DeviceInfo`` tuples.
"""
if logger.isEnabledFor(logging.DEBUG):
logger.debug("Starting dbus enumeration")
for dev in _Context().list_devices(subsystem="hidraw"):
dev_info = _match("add", dev, filterfn)
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:
yield dev_info
@ -343,22 +317,22 @@ def open_path(device_path):
while retrycount < 3:
retrycount += 1
try:
return _os.open(device_path, _os.O_RDWR | _os.O_SYNC)
return os.open(device_path, os.O_RDWR | os.O_SYNC)
except OSError as e:
logger.info("OPEN PATH FAILED %s ERROR %s %s", device_path, e.errno, e)
if e.errno == _errno.EACCES:
if e.errno == errno.EACCES:
sleep(0.1)
else:
raise
def close(device_handle):
def close(device_handle) -> None:
"""Close a HID device.
:param device_handle: a device handle returned by open() or open_path().
"""
assert device_handle
_os.close(device_handle)
os.close(device_handle)
def write(device_handle, data):
@ -390,14 +364,14 @@ def write(device_handle, data):
while retrycount < 3:
try:
retrycount += 1
bytes_written = _os.write(device_handle, data)
bytes_written = os.write(device_handle, data)
except OSError as e:
if e.errno == _errno.EPIPE:
if e.errno == errno.EPIPE:
sleep(0.1)
else:
break
if bytes_written != len(data):
raise OSError(_errno.EIO, f"written {int(bytes_written)} bytes out of expected {len(data)}")
raise OSError(errno.EIO, f"written {int(bytes_written)} bytes out of expected {len(data)}")
def read(device_handle, bytes_count, timeout_ms=-1):
@ -418,15 +392,15 @@ def read(device_handle, bytes_count, timeout_ms=-1):
"""
assert device_handle
timeout = None if timeout_ms < 0 else timeout_ms / 1000.0
rlist, wlist, xlist = _select([device_handle], [], [device_handle], timeout)
rlist, wlist, xlist = select([device_handle], [], [device_handle], timeout)
if xlist:
assert xlist == [device_handle]
raise OSError(_errno.EIO, f"exception on file descriptor {int(device_handle)}")
raise OSError(errno.EIO, f"exception on file descriptor {int(device_handle)}")
if rlist:
assert rlist == [device_handle]
data = _os.read(device_handle, bytes_count)
data = os.read(device_handle, bytes_count)
assert data is not None
assert isinstance(data, bytes), (repr(data), type(data))
return data
@ -482,10 +456,10 @@ def get_indexed_string(device_handle, index):
return None
assert device_handle
stat = _os.fstat(device_handle)
stat = os.fstat(device_handle)
try:
dev = _Device.from_device_number(_Context(), "char", stat.st_rdev)
except (DeviceNotFoundError, ValueError):
dev = pyudev.Devices.from_device_number(pyudev.Context(), "char", stat.st_rdev)
except (pyudev.DeviceNotFoundError, ValueError):
return None
hid_dev = dev.find_parent("hid")

View File

@ -1,12 +1,12 @@
#!/usr/bin/env python3
"""Extract key symbol encodings from X11 header files."""
from pathlib import Path
from pprint import pprint
from re import findall
from subprocess import run
from tempfile import TemporaryDirectory
repo = "https://github.com/freedesktop/xorg-proto-x11proto.git"
xx = "https://gitlab.freedesktop.org/xorg/proto/xorgproto/-/tree/master/include/X11/"
repo = "https://gitlab.freedesktop.org/xorg/proto/xorgproto.git"
pattern = r"#define XK_(\w+)\s+0x(\w+)(?:\s+/\*\s+U\+(\w+))?"
xf86pattern = r"#define XF86XK_(\w+)\s+0x(\w+)(?:\s+/\*\s+U\+(\w+))?"
@ -14,28 +14,24 @@ xf86pattern = r"#define XF86XK_(\w+)\s+0x(\w+)(?:\s+/\*\s+U\+(\w+))?"
def main():
keysymdef = {}
keysym_files = [
("include/X11/keysymdef.h", pattern, ""),
("include/X11/XF86keysym.h", xf86pattern, "XF86_"),
]
with TemporaryDirectory() as temp:
run(["git", "clone", repo, "."], cwd=temp)
# text = Path(temp, 'keysymdef.h').read_text()
text = Path(temp, "include/X11/keysymdef.h").read_text()
for name, sym, uni in findall(pattern, text):
sym = int(sym, 16)
uni = int(uni, 16) if uni else None
if keysymdef.get(name, None):
print("KEY DUP", name)
keysymdef[name] = sym
# text = Path(temp, 'keysymdef.h').read_text()
text = Path(temp, "include/X11/XF86keysym.h").read_text()
for name, sym, uni in findall(xf86pattern, text):
sym = int(sym, 16)
uni = int(uni, 16) if uni else None
if keysymdef.get("XF86_" + name, None):
print("KEY DUP", "XF86_" + name)
keysymdef["XF86_" + name] = sym
for filename, extraction_pattern, prefix in keysym_files:
text = Path(temp, filename).read_text()
for name, sym, _ in findall(extraction_pattern, text):
sym = int(sym, 16)
if keysymdef.get(f"{prefix}{name}", None):
print(f"KEY DUP {prefix}{name}")
keysymdef[f"{prefix}{name}"] = sym
with open("keysymdef.py", "w") as f:
f.write("# flake8: noqa\nkeysymdef = \\\n")
f.write("# flake8: noqa\nkey_symbols = \\\n")
pprint(keysymdef, f)

View File

@ -1,5 +1,5 @@
# flake8: noqa
keysymdef = {
key_symbols = {
"0": 48,
"1": 49,
"2": 50,

View File

@ -15,7 +15,7 @@
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Low-level interface for devices using Logitech HID++ protocol.
Uses the HID api exposed through hidapi.py, a Python thin layer over a native
Uses the HID api exposed through hidapi_impl.py, a Python thin layer over a native
implementation.
"""

View File

@ -14,84 +14,80 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# Base low-level functions used by the API proper.
# Unlikely to be used directly unless you're expanding the API.
"""Base low-level functions as API for upper layers."""
from __future__ import annotations
import dataclasses
import logging
import threading as _threading
import platform
import struct
import threading
import typing
from collections import namedtuple
from contextlib import contextmanager
from random import getrandbits as _random_bits
from struct import pack as _pack
from time import time as _timestamp
import hidapi as _hid
from random import getrandbits
from time import time
from typing import Any
from typing import Callable
from . import base_usb
from . import common
from . import descriptors
from . import exceptions
from . import hidpp10_constants as _hidpp10_constants
from . import hidpp20
from . import hidpp20_constants as _hidpp20_constants
from .base_usb import ALL as _RECEIVER_USB_IDS
from .common import strhex as _strhex
from .descriptors import DEVICES as _DEVICES
from .common import LOGITECH_VENDOR_ID
from .common import BusID
from .hidpp10_constants import ErrorCode as Hidpp10ErrorCode
from .hidpp20_constants import ErrorCode as Hidpp20ErrorCode
if typing.TYPE_CHECKING:
import gi
from hidapi.common import DeviceInfo
gi.require_version("Gdk", "3.0")
from gi.repository import GLib # NOQA: E402
if platform.system() == "Linux":
import hidapi.udev_impl as hidapi
else:
import hidapi.hidapi_impl as hidapi
logger = logging.getLogger(__name__)
_hidpp20 = hidpp20.Hidpp20()
#
#
#
class HIDProtocol(typing.Protocol):
def find_paired_node_wpid(self, receiver_path: str, index: int):
...
def find_paired_node(self, receiver_path: str, index: int, timeout: int):
...
def open(self, vendor_id, product_id, serial=None):
...
def open_path(self, path) -> int:
...
def enumerate(self, filter_func: Callable[[int, int, int, bool, bool], dict[str, typing.Any]]) -> DeviceInfo:
...
def monitor_glib(
self, glib: GLib, callback: Callable, filter_func: Callable[[int, int, int, bool, bool], dict[str, typing.Any]]
) -> None:
...
def read(self, device_handle, bytes_count, timeout_ms):
...
def write(self, device_handle: int, data: bytes) -> int:
...
def close(self, device_handle) -> None:
...
def _wired_device(product_id, interface):
return {"vendor_id": 1133, "product_id": product_id, "bus_id": 3, "usb_interface": interface, "isDevice": True}
def _bt_device(product_id):
return {"vendor_id": 1133, "product_id": product_id, "bus_id": 5, "isDevice": True}
DEVICE_IDS = []
for _ignore, d in _DEVICES.items():
if d.usbid:
DEVICE_IDS.append(_wired_device(d.usbid, d.interface if d.interface else 2))
if d.btid:
DEVICE_IDS.append(_bt_device(d.btid))
def other_device_check(bus_id, vendor_id, product_id):
"""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 != 0x46D: # Logitech
return
if bus_id == 0x3: # USB
if product_id >= 0xC07D and product_id <= 0xC094 or product_id >= 0xC32B and product_id <= 0xC344:
return _wired_device(product_id, 2)
elif bus_id == 0x5: # Bluetooth
if product_id >= 0xB012 and product_id <= 0xB0FF or product_id >= 0xB317 and product_id <= 0xB3FF:
return _bt_device(product_id)
def product_information(usb_id: int | str) -> dict:
if isinstance(usb_id, str):
usb_id = int(usb_id, 16)
for r in _RECEIVER_USB_IDS:
if usb_id == r.get("product_id"):
return r
return {}
#
#
#
_SHORT_MESSAGE_SIZE = 7
SHORT_MESSAGE_SIZE = 7
_LONG_MESSAGE_SIZE = 20
_MEDIUM_MESSAGE_SIZE = 15
_MAX_READ_SIZE = 32
@ -100,13 +96,7 @@ HIDPP_SHORT_MESSAGE_ID = 0x10
HIDPP_LONG_MESSAGE_ID = 0x11
DJ_MESSAGE_ID = 0x20
# mapping from report_id to message length
report_lengths = {
HIDPP_SHORT_MESSAGE_ID: _SHORT_MESSAGE_SIZE,
HIDPP_LONG_MESSAGE_ID: _LONG_MESSAGE_SIZE,
DJ_MESSAGE_ID: _MEDIUM_MESSAGE_SIZE,
0x21: _MAX_READ_SIZE,
}
"""Default timeout on read (in seconds)."""
DEFAULT_TIMEOUT = 4
# the receiver itself should reply very fast, within 500ms
@ -116,12 +106,111 @@ _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 = {}
def match(record, bus_id, vendor_id, product_id):
@dataclasses.dataclass
class HIDPPNotification:
report_id: int
devnumber: int
sub_id: int
address: int
data: bytes
def __str__(self):
text_as_hex = common.strhex(self.data)
return f"Notification({self.report_id:02x},{self.devnumber},{self.sub_id:02X},{self.address:02X},{text_as_hex})"
def _usb_device(product_id: int, usb_interface: int) -> dict[str, Any]:
return {
"vendor_id": LOGITECH_VENDOR_ID,
"product_id": product_id,
"bus_id": BusID.USB,
"usb_interface": usb_interface,
"isDevice": True,
}
def _bluetooth_device(product_id: int) -> dict[str, Any]:
return {"vendor_id": LOGITECH_VENDOR_ID, "product_id": product_id, "bus_id": BusID.BLUETOOTH, "isDevice": True}
KNOWN_DEVICE_IDS = []
for _ignore, d in descriptors.DEVICES.items():
if d.usbid:
usb_interface = d.interface if d.interface else 2
KNOWN_DEVICE_IDS.append(_usb_device(d.usbid, usb_interface))
if d.btid:
KNOWN_DEVICE_IDS.append(_bluetooth_device(d.btid))
def product_information(usb_id: int) -> dict[str, Any]:
"""Returns hardcoded information from USB receiver."""
return base_usb.get_receiver_info(usb_id)
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)
@ -129,50 +218,44 @@ def match(record, bus_id, vendor_id, product_id):
)
def filter_receivers(bus_id, vendor_id, product_id, hidpp_short=False, hidpp_long=False):
"""Check that this product is a Logitech receiver and if so return the receiver record for further checking"""
for record in _RECEIVER_USB_IDS: # known receivers
if match(record, bus_id, vendor_id, product_id):
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.
Filters based on bus_id, vendor_id and product_id.
If so return the receiver record for further checking.
"""
try:
record = base_usb.get_receiver_info(product_id)
if _match_device(record, bus_id, vendor_id, product_id):
return record
if vendor_id == 0x046D and 0xC500 <= product_id <= 0xC5FF: # unknown receiver
except ValueError:
pass
if vendor_id == LOGITECH_VENDOR_ID and 0xC500 <= product_id <= 0xC5FF: # unknown receiver
return {"vendor_id": vendor_id, "product_id": product_id, "bus_id": bus_id, "isDevice": False}
def receivers():
"""Enumerate all the receivers attached to the machine."""
yield from _hid.enumerate(filter_receivers)
def filter(bus_id, vendor_id, product_id, hidpp_short=False, hidpp_long=False):
"""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 DEVICE_IDS: # known devices
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 _hid.enumerate(filter)
yield from hidapi.enumerate(filter_products_of_interest)
def notify_on_receivers_glib(callback):
"""Watch for matching devices and notifies the callback on the GLib thread."""
return _hid.monitor_glib(callback, filter)
def notify_on_receivers_glib(glib: GLib, callback: Callable):
"""Watch for matching devices and notifies the callback on the GLib thread.
Parameters
----------
glib
GLib instance.
"""
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.
@ -185,7 +268,7 @@ def open_path(path):
:returns: an open receiver handle if this is the right Linux device, or
``None``.
"""
return _hid.open_path(path)
return hidapi.open_path(path)
def open():
@ -204,13 +287,11 @@ def close(handle):
if handle:
try:
if isinstance(handle, int):
_hid.close(handle)
hidapi.close(handle)
else:
handle.close()
# logger.info("closed receiver handle %r", handle)
return True
except Exception:
# logger.exception("closing receiver handle %r", handle)
pass
return False
@ -233,15 +314,22 @@ def write(handle, devnumber, data, long_message=False):
assert data is not None
assert isinstance(data, bytes), (repr(data), type(data))
if long_message or len(data) > _SHORT_MESSAGE_SIZE - 2 or data[:1] == b"\x82":
wdata = _pack("!BB18s", HIDPP_LONG_MESSAGE_ID, devnumber, data)
if long_message or len(data) > SHORT_MESSAGE_SIZE - 2 or data[:1] == b"\x82":
wdata = struct.pack("!BB18s", HIDPP_LONG_MESSAGE_ID, devnumber, data)
else:
wdata = _pack("!BB5s", HIDPP_SHORT_MESSAGE_ID, devnumber, data)
wdata = struct.pack("!BB5s", HIDPP_SHORT_MESSAGE_ID, devnumber, data)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("(%s) <= w[%02X %02X %s %s]", handle, ord(wdata[:1]), devnumber, _strhex(wdata[2:4]), _strhex(wdata[4:]))
logger.debug(
"(%s) <= w[%02X %02X %s %s]",
handle,
ord(wdata[:1]),
devnumber,
common.strhex(wdata[2:4]),
common.strhex(wdata[4:]),
)
try:
_hid.write(int(handle), wdata)
hidapi.write(int(handle), wdata)
except Exception as reason:
logger.error("write failed, assuming handle %r no longer available", handle)
close(handle)
@ -266,19 +354,31 @@ def read(handle, timeout=DEFAULT_TIMEOUT):
return reply
# sanity checks on message report id and size
def check_message(data):
def _is_relevant_message(data: bytes) -> bool:
"""Checks if given id is a HID++ or DJ message.
Applies sanity checks on message report ID and message size.
"""
assert isinstance(data, bytes), (repr(data), type(data))
# mapping from report_id to message length
report_lengths = {
HIDPP_SHORT_MESSAGE_ID: SHORT_MESSAGE_SIZE,
HIDPP_LONG_MESSAGE_ID: _LONG_MESSAGE_SIZE,
DJ_MESSAGE_ID: _MEDIUM_MESSAGE_SIZE,
0x21: _MAX_READ_SIZE,
}
report_id = ord(data[:1])
if report_id in report_lengths: # is this an HID++ or DJ message?
if report_id in report_lengths:
if report_lengths.get(report_id) == len(data):
return True
else:
logger.warning(f"unexpected message size: report_id {report_id:02X} message {_strhex(data)}")
logger.warning(f"unexpected message size: report_id {report_id:02X} message {common.strhex(data)}")
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`.
@ -290,73 +390,48 @@ def _read(handle, timeout):
try:
# convert timeout to milliseconds, the hidapi expects it
timeout = int(timeout * 1000)
data = _hid.read(int(handle), _MAX_READ_SIZE, timeout)
data = hidapi.read(int(handle), _MAX_READ_SIZE, timeout)
except Exception as reason:
logger.warning("read failed, assuming handle %r no longer available", handle)
close(handle)
raise exceptions.NoReceiver(reason=reason) from reason
if data and check_message(data): # ignore messages that fail check
if data and _is_relevant_message(data): # ignore messages that fail check
report_id = ord(data[:1])
devnumber = ord(data[1:2])
if logger.isEnabledFor(logging.DEBUG) and (
report_id != DJ_MESSAGE_ID or ord(data[2:3]) > 0x10
): # ignore DJ input messages
logger.debug("(%s) => r[%02X %02X %s %s]", handle, report_id, devnumber, _strhex(data[2:4]), _strhex(data[4:]))
logger.debug(
"(%s) => r[%02X %02X %s %s]",
handle,
report_id,
devnumber,
common.strhex(data[2:4]),
common.strhex(data[4:]),
)
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 = _hid.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 check_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, devnumber, data):
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 tuple if it is."""
return a Notification if it is."""
sub_id = ord(data[:1])
if sub_id & 0x80 == 0x80:
# this is either a HID++1.0 register r/w, or an error reply
return
return None
# DJ input records are not notifications
if report_id == DJ_MESSAGE_ID and (sub_id < 0x10):
return
return None
address = ord(data[1:2])
if sub_id == 0x00 and (address & 0x0F == 0x00):
# this is a no-op notification - don't do anything with it
return
return None
if (
# standard HID++ 1.0 notification, SubId may be 0x40 - 0x7F
@ -371,24 +446,8 @@ def make_notification(report_id, devnumber, data):
# HID++ 2.0 feature notifications have the SoftwareID 0
(address & 0x0F == 0x00)
): # noqa: E129
return _HIDPP_Notification(report_id, devnumber, sub_id, address, data[2:])
_HIDPP_Notification = namedtuple("_HIDPP_Notification", ("report_id", "devnumber", "sub_id", "address", "data"))
_HIDPP_Notification.__str__ = lambda self: "Notification(%02x,%d,%02X,%02X,%s)" % (
self.report_id,
self.devnumber,
self.sub_id,
self.address,
_strhex(self.data),
)
#
#
#
request_lock = _threading.Lock() # serialize all requests
handles_lock = {}
return HIDPPNotification(report_id, devnumber, sub_id, address, data[2:])
return None
def handle_lock(handle):
@ -396,7 +455,7 @@ def handle_lock(handle):
if handles_lock.get(handle) is None:
if logger.isEnabledFor(logging.INFO):
logger.info("New lock %s", repr(handle))
handles_lock[handle] = _threading.Lock() # Serialize requests on the handle
handles_lock[handle] = threading.Lock() # Serialize requests on the handle
return handles_lock[handle]
@ -413,8 +472,30 @@ def acquire_timeout(lock, handle, timeout):
lock.release()
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)
def find_paired_node_wpid(receiver_path: str, index: int):
"""Find the node of a device paired with a receiver.
Get wpid from udev.
"""
return hidapi.find_paired_node_wpid(receiver_path, index)
# a very few requests (e.g., host switching) do not expect a reply, but use no_reply=True with extreme caution
def request(handle, devnumber, request_id, *params, no_reply=False, return_error=False, long_message=False, protocol=1.0):
def request(
handle,
devnumber,
request_id: int,
*params,
no_reply: bool = False,
return_error: bool = False,
long_message: bool = False,
protocol: float = 1.0,
):
"""Makes a feature call to a device and waits for a matching reply.
:param handle: an open UR handle.
:param devnumber: attached device number.
@ -422,19 +503,14 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
:param params: parameters for the feature call, 3 to 16 bytes.
:returns: the reply data, or ``None`` if some error occurred. or no reply expected
"""
# import inspect as _inspect
# print ('\n '.join(str(s) for s in _inspect.stack()))
with acquire_timeout(handle_lock(handle), handle, 10.0):
assert isinstance(request_id, int)
if (devnumber != 0xFF or protocol >= 2.0) and request_id < 0x8000:
# For HID++ 2.0 feature requests, randomize the SoftwareId to make it
# easier to recognize the reply for this request. also, always set the
# most significant bit (8) in SoftwareId, to make notifications easier
# to distinguish from request replies.
# Always set the most significant bit (8) in SoftwareId,
# to make notifications easier to distinguish from request replies.
# This only applies to peripheral requests, ofc.
request_id = (request_id & 0xFFF0) | 0x08 | _random_bits(3)
sw_id = _get_next_sw_id()
request_id = (request_id & 0xFFF0) | sw_id # was 0x08 | getrandbits(3)
timeout = _RECEIVER_REQUEST_TIMEOUT if devnumber == 0xFF else _DEVICE_REQUEST_TIMEOUT
# be extra patient on long register read
@ -442,17 +518,15 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
timeout *= 2
if params:
params = b"".join(_pack("B", p) if isinstance(p, int) else p for p in params)
params = b"".join(struct.pack("B", p) if isinstance(p, int) else p for p in params)
else:
params = b""
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug("(%s) device %d request_id {%04X} params [%s]", handle, devnumber, request_id, _strhex(params))
request_data = _pack("!H", request_id) + params
request_data = struct.pack("!H", request_id) + params
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
@ -462,18 +536,17 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
return None
# we consider timeout from this point
request_started = _timestamp()
request_started = time()
delta = 0
while delta < timeout:
reply = _read(handle, timeout)
if reply:
report_id, reply_devnumber, reply_data = reply
if reply_devnumber == devnumber or reply_devnumber == devnumber ^ 0xFF: # BT device returning 0x00
if (
report_id == HIDPP_SHORT_MESSAGE_ID
and reply_data[:1] == b"\x8F"
and reply_data[:1] == b"\x8f"
and reply_data[1:3] == request_data[:2]
):
error = ord(reply_data[3:4])
@ -485,10 +558,10 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
devnumber,
request_id,
error,
_hidpp10_constants.ERROR[error],
Hidpp10ErrorCode(error),
)
return _hidpp10_constants.ERROR[error] if return_error else None
if reply_data[:1] == b"\xFF" and reply_data[1:3] == request_data[:2]:
return Hidpp10ErrorCode(error) if return_error else None
if reply_data[:1] == b"\xff" and reply_data[1:3] == request_data[:2]:
# a HID++ 2.0 feature call returned with an error
error = ord(reply_data[3:4])
logger.error(
@ -497,9 +570,14 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
devnumber,
request_id,
error,
_hidpp20_constants.ERROR[error],
Hidpp20ErrorCode(error),
)
raise exceptions.FeatureCallError(
number=devnumber,
request=request_id,
error=error,
params=params,
)
raise exceptions.FeatureCallError(number=devnumber, request=request_id, error=error, params=params)
if reply_data[:2] == request_data[:2]:
if devnumber == 0xFF:
@ -517,20 +595,13 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
else:
# a reply was received, but did not match our request in any way
# reset the timeout starting point
request_started = _timestamp()
request_started = time()
if notifications_hook:
n = make_notification(report_id, reply_devnumber, reply_data)
if n:
notifications_hook(n)
# elif logger.isEnabledFor(logging.DEBUG):
# logger.debug("(%s) ignoring reply %02X [%s]", handle, reply_devnumber, _strhex(reply_data))
# elif logger.isEnabledFor(logging.DEBUG):
# logger.debug("(%s) ignoring reply %02X [%s]", handle, reply_devnumber, _strhex(reply_data))
delta = _timestamp() - request_started
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug("(%s) still waiting for reply, delta %f", handle, delta)
delta = time() - request_started
logger.warning(
"timeout (%0.2f/%0.2f) on device %d request {%04X} params [%s]",
@ -538,12 +609,12 @@ def request(handle, devnumber, request_id, *params, no_reply=False, return_error
timeout,
devnumber,
request_id,
_strhex(params),
common.strhex(params),
)
# raise DeviceUnreachable(number=devnumber, request=request_id)
def ping(handle, devnumber, long_message=False):
def ping(handle, devnumber, long_message: bool = False):
"""Check if a device is connected to the receiver.
:returns: The HID protocol supported by the device, as a floating point number, if the device is active.
"""
@ -552,19 +623,18 @@ def ping(handle, devnumber, long_message=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
# randomize the SoftwareId and mark byte to be able to identify the ping
# reply, and set most significant (0x8) bit in SoftwareId so that the reply
# is always distinguishable from notifications
request_id = 0x0018 | _random_bits(3)
request_data = _pack("!HBBB", request_id, 0, 0, _random_bits(8))
# randomize the mark byte to be able to identify the ping reply
sw_id = _get_next_sw_id()
request_id = 0x0010 | sw_id # was 0x0018 | getrandbits(3)
request_data = struct.pack("!HBBB", request_id, 0, 0, getrandbits(8))
write(int(handle), devnumber, request_data, long_message)
request_started = _timestamp() # we consider timeout from this point
request_started = time() # we consider timeout from this point
delta = 0
while delta < _PING_TIMEOUT:
reply = _read(handle, _PING_TIMEOUT)
@ -577,18 +647,16 @@ def ping(handle, devnumber, long_message=False):
if (
report_id == HIDPP_SHORT_MESSAGE_ID
and reply_data[:1] == b"\x8F"
and reply_data[:1] == b"\x8f"
and reply_data[1:3] == request_data[:2]
): # error response
error = ord(reply_data[3:4])
if error == _hidpp10_constants.ERROR.invalid_SubID__command: # a valid reply from a HID++ 1.0 device
if error == Hidpp10ErrorCode.INVALID_SUB_ID_COMMAND:
# a valid reply from a HID++ 1.0 device
return 1.0
if (
error == _hidpp10_constants.ERROR.resource_error
or error == _hidpp10_constants.ERROR.connection_request_failed
):
if error in [Hidpp10ErrorCode.RESOURCE_ERROR, Hidpp10ErrorCode.CONNECTION_REQUEST_FAILED]:
return # device unreachable
if error == _hidpp10_constants.ERROR.unknown_device: # no paired device with that number
if error == Hidpp10ErrorCode.UNKNOWN_DEVICE: # no paired device with that number
logger.error("(%s) device %d error on ping request: unknown device", handle, devnumber)
raise exceptions.NoSuchDevice(number=devnumber, request=request_id)
@ -596,9 +664,50 @@ def ping(handle, devnumber, long_message=False):
n = make_notification(report_id, reply_devnumber, reply_data)
if n:
notifications_hook(n)
# elif logger.isEnabledFor(logging.DEBUG):
# logger.debug("(%s) ignoring reply %02X [%s]", handle, reply_devnumber, _strhex(reply_data))
delta = _timestamp() - request_started
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

@ -14,34 +14,41 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
## According to Logitech, they use the following product IDs (as of September 2020)
## USB product IDs for receivers: 0xC526 - 0xC5xx
## Wireless PIDs for hidpp10 devices: 0x2006 - 0x2019
## Wireless PIDs for hidpp20 devices: 0x4002 - 0x4097, 0x4101 - 0x4102
## USB product IDs for hidpp20 devices: 0xC07D - 0xC094, 0xC32B - 0xC344
## Bluetooth product IDs (for hidpp20 devices): 0xB012 - 0xB0xx, 0xB32A - 0xB3xx
"""Collection of known Logitech product IDs.
# USB ids of Logitech wireless receivers.
# Only receivers supporting the HID++ protocol can go in here.
According to Logitech, they use the following product IDs (as of September 2020)
USB product IDs for receivers: 0xC526 - 0xC5xx
Wireless PIDs for hidpp10 devices: 0x2006 - 0x2019
Wireless PIDs for hidpp20 devices: 0x4002 - 0x4097, 0x4101 - 0x4102
USB product IDs for hidpp20 devices: 0xC07D - 0xC094, 0xC32B - 0xC344
Bluetooth product IDs (for hidpp20 devices): 0xB012 - 0xB0xx, 0xB32A - 0xB3xx
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 _R.receiver_info offset 0x03, default to 1
# may_unpair is only used for receivers that do not support reading from _R.receiver_info offset 0x03, default to False
# unpair is for receivers that do support reading from _R.receiver_info offset 0x03, no default
## should this last be changed so that may_unpair is used for all receivers? writing to _R.receiver_pairing doesn't seem right
# re_pairs determines whether a receiver pairs by replacing existing pairings, default to False
## currently only one receiver is so marked - should there be more?
# max_devices is only used for receivers that do not support reading from Registers.RECEIVER_INFO offset 0x03, default
# to 1.
# may_unpair is only used for receivers that do not support reading from Registers.RECEIVER_INFO offset 0x03,
# default to False.
# unpair is for receivers that do support reading from Registers.RECEIVER_INFO offset 0x03, no default.
## should this last be changed so that may_unpair is used for all receivers? writing to Registers.RECEIVER_PAIRING
## doesn't seem right
DRIVER = ("hid-generic", "generic-usb", "logitech-djreceiver")
LOGITECH_VENDOR_ID = 0x046D
def _bolt_receiver(product_id):
def _bolt_receiver(product_id: int) -> dict:
return {
"vendor_id": 1133,
"vendor_id": LOGITECH_VENDOR_ID,
"product_id": product_id,
"usb_interface": 2,
"hid_driver": DRIVER,
"name": _("Bolt Receiver"),
"receiver_kind": "bolt",
"max_devices": 6,
@ -49,24 +56,22 @@ def _bolt_receiver(product_id):
}
def _unifying_receiver(product_id):
def _unifying_receiver(product_id: int) -> dict:
return {
"vendor_id": 1133,
"vendor_id": LOGITECH_VENDOR_ID,
"product_id": product_id,
"usb_interface": 2,
"hid_driver": DRIVER,
"name": _("Unifying Receiver"),
"receiver_kind": "unifying",
"may_unpair": True,
}
def _nano_receiver(product_id):
def _nano_receiver(product_id: int) -> dict:
return {
"vendor_id": 1133,
"vendor_id": LOGITECH_VENDOR_ID,
"product_id": product_id,
"usb_interface": 1,
"hid_driver": DRIVER,
"name": _("Nano Receiver"),
"receiver_kind": "nano",
"may_unpair": False,
@ -74,12 +79,11 @@ def _nano_receiver(product_id):
}
def _nano_receiver_no_unpair(product_id):
def _nano_receiver_no_unpair(product_id: int) -> dict:
return {
"vendor_id": 1133,
"vendor_id": LOGITECH_VENDOR_ID,
"product_id": product_id,
"usb_interface": 1,
"hid_driver": DRIVER,
"name": _("Nano Receiver"),
"receiver_kind": "nano",
"may_unpair": False,
@ -88,12 +92,11 @@ def _nano_receiver_no_unpair(product_id):
}
def _nano_receiver_max2(product_id):
def _nano_receiver_max2(product_id: int) -> dict:
return {
"vendor_id": 1133,
"vendor_id": LOGITECH_VENDOR_ID,
"product_id": product_id,
"usb_interface": 1,
"hid_driver": DRIVER,
"name": _("Nano Receiver"),
"receiver_kind": "nano",
"max_devices": 2,
@ -102,50 +105,33 @@ def _nano_receiver_max2(product_id):
}
def _nano_receiver_maxn(product_id, max):
return {
"vendor_id": 1133,
"product_id": product_id,
"usb_interface": 1,
"hid_driver": DRIVER,
"name": _("Nano Receiver"),
"receiver_kind": "nano",
"max_devices": max,
"may_unpair": False,
"re_pairs": True,
}
def _lenovo_receiver(product_id):
def _lenovo_receiver(product_id: int) -> dict:
return {
"vendor_id": 6127,
"product_id": product_id,
"usb_interface": 1,
"hid_driver": DRIVER,
"name": _("Nano Receiver"),
"receiver_kind": "nano",
"may_unpair": False,
}
def _lightspeed_receiver(product_id):
def _lightspeed_receiver(product_id: int) -> dict:
return {
"vendor_id": 1133,
"vendor_id": LOGITECH_VENDOR_ID,
"product_id": product_id,
"usb_interface": 2,
"hid_driver": DRIVER,
"receiver_kind": "lightspeed",
"name": _("Lightspeed Receiver"),
"may_unpair": False,
}
def _ex100_receiver(product_id):
def _ex100_receiver(product_id: int) -> dict:
return {
"vendor_id": 1133,
"vendor_id": LOGITECH_VENDOR_ID,
"product_id": product_id,
"usb_interface": 1,
"hid_driver": DRIVER,
"name": _("EX100 Receiver 27 Mhz"),
"receiver_kind": "27Mhz",
"max_devices": 4,
@ -155,7 +141,7 @@ def _ex100_receiver(product_id):
# Receivers added here should also be listed in
# share/solaar/io.github.pwr_solaar.solaar.metainfo.xml
# share/solaar/io.github.pwr_solaar.solaar.meta-info.xml
# Look in https://github.com/torvalds/linux/blob/master/drivers/hid/hid-ids.h
# Bolt receivers (marked with the yellow lightning bolt logo)
@ -178,7 +164,6 @@ NANO_RECEIVER_C531 = _nano_receiver(0xC531)
NANO_RECEIVER_C534 = _nano_receiver_max2(0xC534)
NANO_RECEIVER_C535 = _nano_receiver(0xC535) # branded as Dell
NANO_RECEIVER_C537 = _nano_receiver(0xC537)
# NANO_RECEIVER_C542 = _nano_receiver(0xc542) # does not use HID++
NANO_RECEIVER_6042 = _lenovo_receiver(0x6042)
# Lightspeed receivers (usually sold with gaming devices)
@ -191,34 +176,60 @@ LIGHTSPEED_RECEIVER_C545 = _lightspeed_receiver(0xC545)
LIGHTSPEED_RECEIVER_C547 = _lightspeed_receiver(0xC547)
# EX100 old style receiver pre-unifying protocol
# EX100_27MHZ_RECEIVER_C50C = _ex100_receiver(0xc50C) # in hid/hid-ids.h
EX100_27MHZ_RECEIVER_C517 = _ex100_receiver(0xC517)
# EX100_27MHZ_RECEIVER_C51B = _ex100_receiver(0xc51B) # in hid/hid-ids.h
ALL = (
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_C542, # does not use HID++
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,
0xC517: EX100_27MHZ_RECEIVER_C517,
}
def get_receiver_info(product_id: int) -> dict[str, Any]:
"""Returns hardcoded information about a Logitech receiver.
Parameters
----------
product_id
Product ID (pid) of the receiver, e.g. 0xC548 for a Logitech
Bolt receiver.
Returns
-------
dict[str, Any]
Receiver info with mandatory fields:
- vendor_id
- product_id
Raises
------
ValueError
If the product ID is unknown.
"""
try:
return KNOWN_RECEIVERS[product_id]
except KeyError:
pass
raise ValueError(f"Unknown product ID '0x{product_id:02X}'")

View File

@ -14,27 +14,27 @@
## 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
# Some common functions and types.
import binascii
import dataclasses
import typing
from binascii import hexlify as _hexlify
from collections import namedtuple
from dataclasses import dataclass
from enum import Flag
from enum import IntEnum
from typing import Generator
from typing import Iterable
from typing import Optional
from typing import Union
import yaml as _yaml
import yaml
from solaar.i18n import _
if typing.TYPE_CHECKING:
from logitech_receiver.hidpp20_constants import FirmwareKind
def is_string(d):
return isinstance(d, str)
#
#
#
LOGITECH_VENDOR_ID = 0x046D
def crc16(data: bytes):
@ -314,7 +314,7 @@ class NamedInt(int):
(case-insensitive)."""
def __new__(cls, value, name):
assert is_string(name)
assert isinstance(name, str)
obj = int.__new__(cls, value)
obj.name = str(name)
return obj
@ -329,11 +329,11 @@ class NamedInt(int):
return int(self) == int(other) and self.name == other.name
if isinstance(other, int):
return int(self) == int(other)
if is_string(other):
if isinstance(other, str):
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)
@ -357,8 +357,8 @@ class NamedInt(int):
return dumper.represent_mapping("!NamedInt", {"value": int(data), "name": data.name}, flow_style=True)
_yaml.SafeLoader.add_constructor("!NamedInt", NamedInt.from_yaml)
_yaml.add_representer(NamedInt, NamedInt.to_yaml)
yaml.SafeLoader.add_constructor("!NamedInt", NamedInt.from_yaml)
yaml.add_representer(NamedInt, NamedInt.to_yaml)
class NamedInts:
@ -377,12 +377,12 @@ class NamedInts:
__slots__ = ("__dict__", "_values", "_indexed", "_fallback", "_is_sorted")
def __init__(self, dict=None, **kwargs):
def __init__(self, dict_=None, **kwargs):
def _readable_name(n):
return n.replace("__", "/").replace("_", " ")
# print (repr(kwargs))
elements = dict if dict else kwargs
elements = dict_ if dict_ else kwargs
values = {k: NamedInt(v, _readable_name(k)) for (k, v) in elements.items()}
self.__dict__ = values
self._is_sorted = False
@ -430,7 +430,7 @@ class NamedInts:
self._sort_values()
return value
elif is_string(index):
elif isinstance(index, str):
if index in self.__dict__:
return self.__dict__[index]
return next((x for x in self._values if str(x) == index), None)
@ -467,9 +467,9 @@ 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 is_string(name):
elif isinstance(name, str):
value = NamedInt(index, name)
else:
raise TypeError("name must be a string")
@ -490,7 +490,7 @@ class NamedInts:
return self[value] == value
elif isinstance(value, int):
return value in self._indexed
elif is_string(value):
elif isinstance(value, str):
return value in self.__dict__ or value in self._values
def __iter__(self):
@ -506,7 +506,32 @@ class NamedInts:
return NamedInts(**self.__dict__, **other.__dict__)
def __eq__(self, other):
return type(self) == type(other) and self._values == other._values
return isinstance(other, self.__class__) and self._values == other._values
def flag_names(enum_class: Iterable, value: int) -> Generator[str]:
"""Extracts single bit flags from a (binary) number.
Parameters
----------
enum_class
Enum class to extract flags from.
value
Number to extract binary flags from.
"""
indexed = {item.value: item.name for item in enum_class}
unknown_bits = value
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 & value == k:
unknown_bits &= ~k
yield indexed[k].lower()
# Yield any remaining unknown bits
if unknown_bits != 0:
yield f"unknown:{unknown_bits:06X}"
class UnsortedNamedInts(NamedInts):
@ -521,7 +546,7 @@ class UnsortedNamedInts(NamedInts):
def strhex(x):
assert x is not None
"""Produce a hex-string representation of a sequence of bytes."""
return _hexlify(x).decode("ascii").upper()
return binascii.hexlify(x).decode("ascii").upper()
def bytes2int(x, signed=False):
@ -550,63 +575,102 @@ class KwException(Exception):
return self.args[0].get(k) # was self.args[0][k]
"""Firmware information."""
FirmwareInfo = namedtuple("FirmwareInfo", ["kind", "name", "version", "extras"])
class FirmwareKind(IntEnum):
Firmware = 0x00
Bootloader = 0x01
Hardware = 0x02
Other = 0x03
@dataclass
@dataclasses.dataclass
class FirmwareInfo:
kind: FirmwareKind
name: str
version: str
extras: str | None
class BatteryStatus(Flag):
DISCHARGING = 0x00
RECHARGING = 0x01
ALMOST_FULL = 0x02
FULL = 0x03
SLOW_RECHARGE = 0x04
INVALID_BATTERY = 0x05
THERMAL_ERROR = 0x06
class BatteryLevelApproximation(IntEnum):
EMPTY = 0
CRITICAL = 5
LOW = 20
GOOD = 50
FULL = 90
@dataclasses.dataclass
class Battery:
"""Information about the current state of a battery"""
level: Optional[Union[NamedInt, int]]
ATTENTION_LEVEL = 5
level: Optional[Union[BatteryLevelApproximation, int]]
next_level: Optional[Union[NamedInt, int]]
status: Optional[NamedInt]
status: Optional[BatteryStatus]
voltage: Optional[int]
light_level: Optional[int] = None # light level for devices with solaar recharging
def __post_init__(self):
if self.level is None: # infer level from status if needed and possible
if self.status == Battery.STATUS.full:
self.level = Battery.APPROX.full
elif self.status in (Battery.STATUS.almost_full, Battery.STATUS.recharging):
self.level = Battery.APPROX.good
elif self.status == Battery.STATUS.slow_recharge:
self.level = Battery.APPROX.low
if self.status == BatteryStatus.FULL:
self.level = BatteryLevelApproximation.FULL
elif self.status in (BatteryStatus.ALMOST_FULL, BatteryStatus.RECHARGING):
self.level = BatteryLevelApproximation.GOOD
elif self.status == BatteryStatus.SLOW_RECHARGE:
self.level = BatteryLevelApproximation.LOW
STATUS = NamedInts(
discharging=0x00,
recharging=0x01,
almost_full=0x02,
full=0x03,
slow_recharge=0x04,
invalid_battery=0x05,
thermal_error=0x06,
)
APPROX = NamedInts(empty=0, critical=5, low=20, good=50, full=90)
ATTENTION_LEVEL = 5
def ok(self):
return self.status not in (Battery.STATUS.invalid_battery, Battery.STATUS.thermal_error) and (
def ok(self) -> bool:
return self.status not in (BatteryStatus.INVALID_BATTERY, BatteryStatus.THERMAL_ERROR) and (
self.level is None or self.level > Battery.ATTENTION_LEVEL
)
def charging(self):
def charging(self) -> bool:
return self.status in (
Battery.STATUS.recharging,
Battery.STATUS.almost_full,
Battery.STATUS.full,
Battery.STATUS.slow_recharge,
BatteryStatus.RECHARGING,
BatteryStatus.ALMOST_FULL,
BatteryStatus.FULL,
BatteryStatus.SLOW_RECHARGE,
)
def to_str(self):
if isinstance(self.level, NamedInt):
return _("Battery: %(level)s (%(status)s)") % {"level": _(self.level), "status": _(self.status)}
def to_str(self) -> str:
if isinstance(self.level, BatteryLevelApproximation):
level = self.level.name.lower()
status = self.status.name.lower().replace("_", " ") if self.status is not None else "Unknown"
return _("Battery: %(level)s (%(status)s)") % {"level": _(level), "status": _(status)}
elif isinstance(self.level, int):
return _("Battery: %(percent)d%% (%(status)s)") % {"percent": self.level, "status": _(self.status)}
else:
return ""
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)}
return ""
ALERT = NamedInts(NONE=0x00, NOTIFICATION=0x01, SHOW_WINDOW=0x02, ATTENTION=0x04, ALL=0xFF)
class Alert(IntEnum):
NONE = 0x00
NOTIFICATION = 0x01
SHOW_WINDOW = 0x02
ATTENTION = 0x04
ALL = 0xFF
class Notification(IntEnum):
NO_OPERATION = 0x00
CONNECT_DISCONNECT = 0x40
DJ_PAIRING = 0x41
CONNECTED = 0x42
RAW_INPUT = 0x49
PAIRING_LOCK = 0x4A
POWER = 0x4B
class BusID(IntEnum):
USB = 0x03
BLUETOOTH = 0x05

View File

@ -14,20 +14,18 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# Devices (not receivers) known to Solaar.
# Solaar can handle many recent devices without having any entry here.
# An entry should only be added to fix problems, such as
# - the device's device ID or WPID falls outside the range that Solaar searches
# - the device uses a USB interface other than 2
# - the name or codename should be different from what the device reports
from .hidpp10_constants import DEVICE_KIND as _DK
from .hidpp10_constants import REGISTERS as _R
"""Devices (not receivers) known to Solaar.
#
#
#
Solaar can handle many recent devices without having any entry here.
An entry should only be added to fix problems, such as
- the device's device ID or WPID falls outside the range that Solaar searches
- the device uses a USB interface other than 2
- the name or codename should be different from what the device reports
"""
from .hidpp10_constants import DEVICE_KIND
from .hidpp10_constants import Registers as Reg
class _DeviceDescriptor:
@ -73,15 +71,15 @@ def _D(
):
if kind is None:
kind = (
_DK.mouse
DEVICE_KIND.mouse
if "Mouse" in name
else _DK.keyboard
else DEVICE_KIND.keyboard
if "Keyboard" in name
else _DK.numpad
else DEVICE_KIND.numpad
if "Number Pad" in name
else _DK.touchpad
else DEVICE_KIND.touchpad
if "Touchpad" in name
else _DK.trackball
else DEVICE_KIND.trackball
if "Trackball" in name
else None
)
@ -94,9 +92,12 @@ def _D(
assert w[0:1] == "4", f"{name} has protocol {protocol:0.1f}, wpid {w}"
else:
if w[0:1] == "1":
assert kind == _DK.mouse, f"{name} has protocol {protocol:0.1f}, wpid {w}"
assert kind == DEVICE_KIND.mouse, f"{name} has protocol {protocol:0.1f}, wpid {w}"
elif w[0:1] == "2":
assert kind in (_DK.keyboard, _DK.numpad), f"{name} has protocol {protocol:0.1f}, wpid {w}"
assert kind in (
DEVICE_KIND.keyboard,
DEVICE_KIND.numpad,
), f"{name} has protocol {protocol:0.1f}, wpid {w}"
device_descriptor = _DeviceDescriptor(
name=name,
@ -192,24 +193,24 @@ def get_btid(btid):
# Keyboards
_D("Wireless Keyboard EX110", codename="EX110", protocol=1.0, wpid="0055", registers=(_R.battery_status,))
_D("Wireless Keyboard S510", codename="S510", protocol=1.0, wpid="0056", registers=(_R.battery_status,))
_D("Wireless Wave Keyboard K550", codename="K550", protocol=1.0, wpid="0060", registers=(_R.battery_status,))
_D("Wireless Keyboard EX100", codename="EX100", protocol=1.0, wpid="0065", registers=(_R.battery_status,))
_D("Wireless Keyboard MK300", codename="MK300", protocol=1.0, wpid="0068", registers=(_R.battery_status,))
_D("Number Pad N545", codename="N545", protocol=1.0, wpid="2006", registers=(_R.battery_status,))
_D("Wireless Compact Keyboard K340", codename="K340", protocol=1.0, wpid="2007", registers=(_R.battery_status,))
_D("Wireless Keyboard MK700", codename="MK700", protocol=1.0, wpid="2008", registers=(_R.battery_status,))
_D("Wireless Wave Keyboard K350", codename="K350", protocol=1.0, wpid="200A", registers=(_R.battery_status,))
_D("Wireless Keyboard MK320", codename="MK320", protocol=1.0, wpid="200F", registers=(_R.battery_status,))
_D("Wireless Keyboard EX110", codename="EX110", protocol=1.0, wpid="0055", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Keyboard S510", codename="S510", protocol=1.0, wpid="0056", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Wave Keyboard K550", codename="K550", protocol=1.0, wpid="0060", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Keyboard EX100", codename="EX100", protocol=1.0, wpid="0065", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Keyboard MK300", codename="MK300", protocol=1.0, wpid="0068", registers=(Reg.BATTERY_STATUS,))
_D("Number Pad N545", codename="N545", protocol=1.0, wpid="2006", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Compact Keyboard K340", codename="K340", protocol=1.0, wpid="2007", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Keyboard MK700", codename="MK700", protocol=1.0, wpid="2008", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Wave Keyboard K350", codename="K350", protocol=1.0, wpid="200A", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Keyboard MK320", codename="MK320", protocol=1.0, wpid="200F", registers=(Reg.BATTERY_STATUS,))
_D(
"Wireless Illuminated Keyboard K800",
codename="K800",
protocol=1.0,
wpid="2010",
registers=(_R.battery_status, _R.three_leds),
registers=(Reg.BATTERY_STATUS, Reg.THREE_LEDS),
)
_D("Wireless Keyboard K520", codename="K520", protocol=1.0, wpid="2011", registers=(_R.battery_status,))
_D("Wireless Keyboard K520", codename="K520", protocol=1.0, wpid="2011", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Solar Keyboard K750", codename="K750", protocol=2.0, wpid="4002")
_D("Wireless Keyboard K270 (unifying)", codename="K270", protocol=2.0, wpid="4003")
_D("Wireless Keyboard K360", codename="K360", protocol=2.0, wpid="4004")
@ -224,7 +225,13 @@ _D("Craft Advanced Keyboard", codename="Craft", protocol=4.5, wpid="4066", btid=
_D("Wireless Illuminated Keyboard K800 new", codename="K800 new", protocol=4.5, wpid="406E")
_D("Wireless Keyboard K470", codename="K470", protocol=4.5, wpid="4075")
_D("MX Keys Keyboard", codename="MX Keys", protocol=4.5, wpid="408A", btid=0xB35B)
_D("G915 TKL LIGHTSPEED Wireless RGB Mechanical Gaming Keyboard", codename="G915 TKL", protocol=4.2, wpid="408E", usbid=0xC343)
_D(
"G915 TKL LIGHTSPEED Wireless RGB Mechanical Gaming Keyboard",
codename="G915 TKL",
protocol=4.2,
wpid="408E",
usbid=0xC343,
)
_D("Illuminated Keyboard", codename="Illuminated", protocol=1.0, usbid=0xC318, interface=1)
_D("G213 Prodigy Gaming Keyboard", codename="G213", usbid=0xC336, interface=1)
_D("G512 RGB Mechanical Gaming Keyboard", codename="G512", usbid=0xC33C, interface=1)
@ -234,52 +241,118 @@ _D("K845 Mechanical Keyboard", codename="K845", usbid=0xC341, interface=3)
# Mice
_D("LX5 Cordless Mouse", codename="LX5", protocol=1.0, wpid="0036", registers=(_R.battery_status,))
_D("LX7 Cordless Laser Mouse", codename="LX7", protocol=1.0, wpid="0039", registers=(_R.battery_status,))
_D("Wireless Wave Mouse M550", codename="M550", protocol=1.0, wpid="003C", registers=(_R.battery_status,))
_D("Wireless Mouse EX100", codename="EX100m", protocol=1.0, wpid="003F", registers=(_R.battery_status,))
_D("Wireless Mouse M30", codename="M30", protocol=1.0, wpid="0085", registers=(_R.battery_status,))
_D("MX610 Laser Cordless Mouse", codename="MX610", protocol=1.0, wpid="1001", registers=(_R.battery_status,))
_D("G7 Cordless Laser Mouse", codename="G7", protocol=1.0, wpid="1002", registers=(_R.battery_status,))
_D("V400 Laser Cordless Mouse", codename="V400", protocol=1.0, wpid="1003", registers=(_R.battery_status,))
_D("MX610 Left-Handled Mouse", codename="MX610L", protocol=1.0, wpid="1004", registers=(_R.battery_status,))
_D("V450 Laser Cordless Mouse", codename="V450", protocol=1.0, wpid="1005", registers=(_R.battery_status,))
_D("LX5 Cordless Mouse", codename="LX5", protocol=1.0, wpid="0036", registers=(Reg.BATTERY_STATUS,))
_D("LX7 Cordless Laser Mouse", codename="LX7", protocol=1.0, wpid="0039", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Wave Mouse M550", codename="M550", protocol=1.0, wpid="003C", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Mouse EX100", codename="EX100m", protocol=1.0, wpid="003F", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Mouse M30", codename="M30", protocol=1.0, wpid="0085", registers=(Reg.BATTERY_STATUS,))
_D("MX610 Laser Cordless Mouse", codename="MX610", protocol=1.0, wpid="1001", registers=(Reg.BATTERY_STATUS,))
_D("G7 Cordless Laser Mouse", codename="G7", protocol=1.0, wpid="1002", registers=(Reg.BATTERY_STATUS,))
_D("V400 Laser Cordless Mouse", codename="V400", protocol=1.0, wpid="1003", registers=(Reg.BATTERY_STATUS,))
_D("MX610 Left-Handled Mouse", codename="MX610L", protocol=1.0, wpid="1004", registers=(Reg.BATTERY_STATUS,))
_D("V450 Laser Cordless Mouse", codename="V450", protocol=1.0, wpid="1005", registers=(Reg.BATTERY_STATUS,))
_D(
"VX Revolution",
codename="VX Revolution",
kind=_DK.mouse,
kind=DEVICE_KIND.mouse,
protocol=1.0,
wpid=("1006", "100D", "0612"),
registers=(_R.battery_charge,),
registers=(Reg.BATTERY_CHARGE,),
)
_D(
"MX Air",
codename="MX Air",
protocol=1.0,
kind=DEVICE_KIND.mouse,
wpid=("1007", "100E"),
registers=Reg.BATTERY_CHARGE,
)
_D("MX Air", codename="MX Air", protocol=1.0, kind=_DK.mouse, wpid=("1007", "100E"), registers=(_R.battery_charge,))
_D(
"MX Revolution",
codename="MX Revolution",
protocol=1.0,
kind=_DK.mouse,
kind=DEVICE_KIND.mouse,
wpid=("1008", "100C"),
registers=(_R.battery_charge,),
registers=(Reg.BATTERY_CHARGE,),
)
_D(
"MX620 Laser Cordless Mouse",
codename="MX620",
protocol=1.0,
wpid=("100A", "1016"),
registers=(Reg.BATTERY_CHARGE,),
)
_D(
"VX Nano Cordless Laser Mouse",
codename="VX Nano",
protocol=1.0,
wpid=("100B", "100F"),
registers=(Reg.BATTERY_CHARGE,),
)
_D(
"V450 Nano Cordless Laser Mouse",
codename="V450 Nano",
protocol=1.0,
wpid="1011",
registers=(Reg.BATTERY_CHARGE,),
)
_D(
"V550 Nano Cordless Laser Mouse",
codename="V550 Nano",
protocol=1.0,
wpid="1013",
registers=(Reg.BATTERY_CHARGE,),
)
_D("MX620 Laser Cordless Mouse", codename="MX620", protocol=1.0, wpid=("100A", "1016"), registers=(_R.battery_charge,))
_D("VX Nano Cordless Laser Mouse", codename="VX Nano", protocol=1.0, wpid=("100B", "100F"), registers=(_R.battery_charge,))
_D("V450 Nano Cordless Laser Mouse", codename="V450 Nano", protocol=1.0, wpid="1011", registers=(_R.battery_charge,))
_D("V550 Nano Cordless Laser Mouse", codename="V550 Nano", protocol=1.0, wpid="1013", registers=(_R.battery_charge,))
_D(
"MX 1100 Cordless Laser Mouse",
codename="MX 1100",
protocol=1.0,
kind=_DK.mouse,
kind=DEVICE_KIND.mouse,
wpid="1014",
registers=(_R.battery_charge,),
registers=(Reg.BATTERY_CHARGE,),
)
_D("Anywhere Mouse MX", codename="Anywhere MX", protocol=1.0, wpid="1017", registers=(Reg.BATTERY_CHARGE,))
_D(
"Performance Mouse MX",
codename="Performance MX",
protocol=1.0,
wpid="101A",
registers=(Reg.BATTERY_STATUS, Reg.THREE_LEDS),
)
_D(
"Marathon Mouse M705 (M-R0009)",
codename="M705 (M-R0009)",
protocol=1.0,
wpid="101B",
registers=(Reg.BATTERY_CHARGE,),
)
_D(
"Wireless Mouse M350",
codename="M350",
protocol=1.0,
wpid="101C",
registers=(Reg.BATTERY_CHARGE,),
)
_D(
"Wireless Mouse M505",
codename="M505/B605",
protocol=1.0,
wpid="101D",
registers=(Reg.BATTERY_CHARGE,),
)
_D(
"Wireless Mouse M305",
codename="M305",
protocol=1.0,
wpid="101F",
registers=(Reg.BATTERY_STATUS,),
)
_D(
"Wireless Mouse M215",
codename="M215",
protocol=1.0,
wpid="1020",
)
_D("Anywhere Mouse MX", codename="Anywhere MX", protocol=1.0, wpid="1017", registers=(_R.battery_charge,))
_D("Performance Mouse MX", codename="Performance MX", protocol=1.0, wpid="101A", registers=(_R.battery_status, _R.three_leds))
_D("Marathon Mouse M705 (M-R0009)", codename="M705 (M-R0009)", protocol=1.0, wpid="101B", registers=(_R.battery_charge,))
_D("Wireless Mouse M350", codename="M350", protocol=1.0, wpid="101C", registers=(_R.battery_charge,))
_D("Wireless Mouse M505", codename="M505/B605", protocol=1.0, wpid="101D", registers=(_R.battery_charge,))
_D("Wireless Mouse M305", codename="M305", protocol=1.0, wpid="101F", registers=(_R.battery_status,))
_D("Wireless Mouse M215", codename="M215", protocol=1.0, wpid="1020")
_D(
"G700 Gaming Mouse",
codename="G700",
@ -288,12 +361,12 @@ _D(
usbid=0xC06B,
interface=1,
registers=(
_R.battery_status,
_R.three_leds,
Reg.BATTERY_STATUS,
Reg.THREE_LEDS,
),
)
_D("Wireless Mouse M310", codename="M310", protocol=1.0, wpid="1024", registers=(_R.battery_status,))
_D("Wireless Mouse M510", codename="M510", protocol=1.0, wpid="1025", registers=(_R.battery_status,))
_D("Wireless Mouse M310", codename="M310", protocol=1.0, wpid="1024", registers=(Reg.BATTERY_STATUS,))
_D("Wireless Mouse M510", codename="M510", protocol=1.0, wpid="1025", registers=(Reg.BATTERY_STATUS,))
_D("Fujitsu Sonic Mouse", codename="Sonic", protocol=1.0, wpid="1029")
_D(
"G700s Gaming Mouse",
@ -303,8 +376,8 @@ _D(
usbid=0xC07C,
interface=1,
registers=(
_R.battery_status,
_R.three_leds,
Reg.BATTERY_STATUS,
Reg.THREE_LEDS,
),
)
_D("Couch Mouse M515", codename="M515", protocol=2.0, wpid="4007")
@ -348,7 +421,7 @@ _D("G502 Lightspeed Gaming Mouse", codename="G502 Lightspeed", usbid=0xC08D)
_D("MX518 Gaming Mouse", codename="MX518", usbid=0xC08E, interface=1)
_D("G703 Hero Gaming Mouse", codename="G703 Hero", usbid=0xC090)
_D("G903 Hero Gaming Mouse", codename="G903 Hero", usbid=0xC091)
_D(None, kind=_DK.mouse, usbid=0xC092, interface=1) # two mice share this ID
_D(None, kind=DEVICE_KIND.mouse, usbid=0xC092, interface=1) # two mice share this ID
_D("M500S Mouse", codename="M500S", usbid=0xC093, interface=1)
# _D('G600 Gaming Mouse', codename='G600 Gaming', usbid=0xc24a, interface=1) # not an HID++ device
_D("G500s Gaming Mouse", codename="G500s Gaming", usbid=0xC24E, interface=1, protocol=1.0)
@ -365,13 +438,29 @@ _D("Wireless Trackball M570", codename="M570")
_D("Wireless Touchpad", codename="Wireless Touch", protocol=2.0, wpid="4011")
_D("Wireless Rechargeable Touchpad T650", codename="T650", protocol=2.0, wpid="4101")
_D("G Powerplay", codename="Powerplay", protocol=2.0, kind=_DK.touchpad, wpid="405F") # To override self-identification
_D(
"G Powerplay", codename="Powerplay", protocol=2.0, kind=DEVICE_KIND.touchpad, wpid="405F"
) # To override self-identification
# Headset
_D("G533 Gaming Headset", codename="G533 Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0A66)
_D("G535 Gaming Headset", codename="G535 Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0AC4)
_D("G935 Gaming Headset", codename="G935 Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0A87)
_D("G733 Gaming Headset", codename="G733 Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0AB5)
_D("G733 Gaming Headset", codename="G733 Headset New", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0AFE)
_D("PRO X Wireless Gaming Headset", codename="PRO Headset", protocol=2.0, interface=3, kind=_DK.headset, usbid=0x0ABA)
_D("G533 Gaming Headset", codename="G533 Headset", protocol=2.0, interface=3, kind=DEVICE_KIND.headset, usbid=0x0A66)
_D("G535 Gaming Headset", codename="G535 Headset", protocol=2.0, interface=3, kind=DEVICE_KIND.headset, usbid=0x0AC4)
_D("G935 Gaming Headset", codename="G935 Headset", protocol=2.0, interface=3, kind=DEVICE_KIND.headset, usbid=0x0A87)
_D("G733 Gaming Headset", codename="G733 Headset", protocol=2.0, interface=3, kind=DEVICE_KIND.headset, usbid=0x0AB5)
_D(
"G733 Gaming Headset",
codename="G733 Headset New",
protocol=2.0,
interface=3,
kind=DEVICE_KIND.headset,
usbid=0x0AFE,
)
_D(
"PRO X Wireless Gaming Headset",
codename="PRO Headset",
protocol=2.0,
interface=3,
kind=DEVICE_KIND.headset,
usbid=0x0ABA,
)

View File

@ -14,29 +14,46 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Implements the desktop notification service."""
import importlib
import logging
logger = logging.getLogger(__name__)
try:
import gi
gi.require_version("Notify", "0.7")
gi.require_version("Gtk", "3.0")
from gi.repository import GLib # this import is allowed to fail making the entire feature unavailable
from gi.repository import Gtk # this import is allowed to fail making the entire feature unavailable
from gi.repository import Notify # this import is allowed to fail making the entire feature unavailable
def notifications_available():
"""Checks if notification service is available."""
notifications_supported = False
try:
import gi
available = True
except (ValueError, ImportError):
available = False
gi.require_version("Notify", "0.7")
gi.require_version("Gtk", "3.0")
importlib.util.find_spec("gi.repository.GLib")
importlib.util.find_spec("gi.repository.Gtk")
importlib.util.find_spec("gi.repository.Notify")
notifications_supported = True
except ValueError as e:
logger.warning(f"Notification service is not available: {e}")
return notifications_supported
available = notifications_available()
if available:
from gi.repository import GLib
from gi.repository import Gtk
from gi.repository import Notify
# cache references to shown notifications here to allow reuse
_notifications = {}
_ICON_LISTS = {}
def init():
"""Init the notifications system."""
"""Initialize desktop notifications."""
global available
if available:
if not Notify.is_initted():
@ -50,13 +67,14 @@ if available:
return available and Notify.is_initted()
def uninit():
"""Stop desktop notifications."""
if available and Notify.is_initted():
if logger.isEnabledFor(logging.INFO):
logger.info("stopping desktop notifications")
_notifications.clear()
Notify.uninit()
def show(dev, message, icon=None):
def show(dev, message: str, icon=None):
"""Show a notification with title and text."""
if available and (Notify.is_initted() or init()):
summary = dev.name
@ -68,13 +86,9 @@ if available:
n.set_urgency(Notify.Urgency.NORMAL)
n.set_hint("desktop-entry", GLib.Variant("s", "solaar")) # replace with better name late
try:
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug("showing %s", n)
n.show()
return n.show()
except Exception:
logger.exception("showing %s", n)
_ICON_LISTS = {}
logger.exception(f"showing {n}")
def device_icon_list(name="_", kind=None):
icon_list = _ICON_LISTS.get(name)
@ -82,16 +96,17 @@ if available:
# names of possible icons, in reverse order of likelihood
# the theme will hopefully pick up the most appropriate
icon_list = ["preferences-desktop-peripherals"]
kind = str(kind)
if kind:
if str(kind) == "numpad":
if kind == "numpad":
icon_list += ("input-keyboard", "input-dialpad")
elif str(kind) == "touchpad":
elif kind == "touchpad":
icon_list += ("input-mouse", "input-tablet")
elif str(kind) == "trackball":
elif kind == "trackball":
icon_list += ("input-mouse",)
elif str(kind) == "headset":
elif kind == "headset":
icon_list += ("audio-headphones", "audio-headset")
icon_list += ("input-" + str(kind),)
icon_list += (f"input-{kind}",)
_ICON_LISTS[name] = icon_list
return icon_list

View File

@ -15,64 +15,104 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import errno as _errno
from __future__ import annotations
import errno
import logging
import threading as _threading
import threading
import time
import typing
from typing import Callable
from typing import Optional
from typing import Protocol
import hidapi as _hid
import solaar.configuration as _configuration
from solaar import configuration
from . import base
from . import descriptors
from . import exceptions
from . import hidpp10
from . import hidpp10_constants
from . import hidpp20
from . import hidpp20_constants
from . import settings
from .common import ALERT
from . import settings_templates
from .common import Alert
from .common import Battery
from .settings_templates import check_feature_settings as _check_feature_settings
from .hidpp10_constants import NotificationFlag
from .hidpp20_constants import SupportedFeature
if typing.TYPE_CHECKING:
from logitech_receiver import common
logger = logging.getLogger(__name__)
_hidpp10 = hidpp10.Hidpp10()
_hidpp20 = hidpp20.Hidpp20()
_R = hidpp10_constants.REGISTERS
_IR = hidpp10_constants.INFO_SUBREGISTERS
class DeviceFactory:
@staticmethod
def create_device(device_info, setting_callback=None):
"""Opens a Logitech Device found attached to the machine, by Linux device path.
:returns: An open file handle for the found receiver, or None.
"""
try:
handle = base.open_path(device_info.path)
if handle:
# a direct connected device might not be online (as reported by user)
return Device(None, None, None, handle=handle, device_info=device_info, setting_callback=setting_callback)
except OSError as e:
logger.exception("open %s", device_info)
if e.errno == _errno.EACCES:
raise
except Exception:
logger.exception("open %s", device_info)
class LowLevelInterface(Protocol):
def open_path(self, path) -> int:
...
def find_paired_node(self, receiver_path: str, index: int, timeout: int):
...
def ping(self, handle, number, long_message: bool):
...
def request(self, handle, devnumber, request_id, *params, **kwargs):
...
def close(self, handle, *args, **kwargs) -> bool:
...
def create_device(low_level: LowLevelInterface, device_info, setting_callback=None):
"""Opens a Logitech Device found attached to the machine, by Linux device path.
:returns: An open file handle for the found receiver, or None.
"""
try:
handle = low_level.open_path(device_info.path)
if handle:
# a direct connected device might not be online (as reported by user)
return Device(
low_level,
None,
None,
None,
handle=handle,
device_info=device_info,
setting_callback=setting_callback,
)
except OSError as e:
logger.exception("open %s", device_info)
if e.errno == errno.EACCES:
raise
except Exception:
logger.exception("open %s", device_info)
raise
class Device:
instances = []
read_register = hidpp10.read_register
write_register = hidpp10.write_register
read_register: Callable = hidpp10.read_register
write_register: Callable = hidpp10.write_register
def __init__(self, receiver, number, online, pairing_info=None, handle=None, device_info=None, setting_callback=None):
def __init__(
self,
low_level: LowLevelInterface,
receiver,
number,
online,
pairing_info=None,
handle=None,
device_info=None,
setting_callback=None,
):
assert receiver or device_info
if receiver:
assert 0 < number <= 15 # some receivers have devices past their max # of devices
self.low_level = low_level
self.number = number # will be None at this point for directly connected devices
self.online = online # is the device online? - gates many atempts to contact the device
self.descriptor = None
@ -107,23 +147,24 @@ class Device:
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()
self._settings_lock = _threading.Lock()
self._persister_lock = _threading.Lock()
self._gestures_lock = threading.Lock()
self._settings_lock = threading.Lock()
self._persister_lock = threading.Lock()
self._notification_handlers = {} # See `add_notification_handler`
self.cleanups = [] # functions to run on the device when it is closed
if not self.path:
self.path = _hid.find_paired_node(receiver.path, number, 1) if receiver else None
self.path = self.low_level.find_paired_node(receiver.path, number, 1) if receiver else None
if not self.handle:
try:
self.handle = base.open_path(self.path) if self.path else None
self.handle = self.low_level.open_path(self.path) if self.path else None
except Exception: # maybe the device wasn't set up
try:
time.sleep(1)
self.handle = base.open_path(self.path) if self.path else None
self.handle = self.low_level.open_path(self.path) if self.path else None
except Exception: # give up
self.handle = None # should this give up completely?
@ -143,7 +184,11 @@ class Device:
descriptors.get_btid(self.product_id) if self.bluetooth else descriptors.get_usbid(self.product_id)
)
if self.number is None: # for direct-connected devices get 'number' from descriptor protocol else use 0xFF
self.number = 0x00 if self.descriptor and self.descriptor.protocol and self.descriptor.protocol < 2.0 else 0xFF
if self.descriptor and self.descriptor.protocol and self.descriptor.protocol < 2.0:
number = 0x00
else:
number = 0xFF
self.number = number
self.ping() # determine whether a direct-connected device is online
if self.descriptor:
@ -162,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
@ -177,8 +222,6 @@ class Device:
@property
def codename(self):
if not self._codename:
if not self.online: # be very defensive
self.ping()
if self.online and self.protocol >= 2.0:
self._codename = _hidpp20.get_friendly_name(self)
if not self._codename:
@ -188,20 +231,15 @@ class Device:
if codename:
self._codename = codename
elif self.protocol < 2.0:
self._codename = "? (%s)" % (self.wpid or hex(self.product_id)[2:].upper())
return self._codename or "?? (%s)" % (self.wpid or hex(self.product_id)[2:].upper())
self._codename = "? (%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 not self.online: # be very defensive
try:
self.ping()
except exceptions.NoSuchDevice:
pass
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 hex(self.product_id)[2:].upper()))
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)
@ -235,7 +273,7 @@ class Device:
return self._kind or "?"
@property
def firmware(self):
def firmware(self) -> tuple[common.FirmwareInfo]:
if self._firmware is None and self.online:
if self.protocol >= 2.0:
self._firmware = _hidpp20.get_firmware(self)
@ -265,9 +303,9 @@ class Device:
@property
def led_effects(self):
if not self._led_effects and self.online and self.protocol >= 2.0:
if hidpp20_constants.FEATURE.COLOR_LED_EFFECTS in self.features:
if SupportedFeature.COLOR_LED_EFFECTS in self.features:
self._led_effects = hidpp20.LEDEffectsInfo(self)
elif hidpp20_constants.FEATURE.RGB_EFFECTS in self.features:
elif SupportedFeature.RGB_EFFECTS in self.features:
self._led_effects = hidpp20.RGBEffectsInfo(self)
return self._led_effects
@ -308,9 +346,9 @@ class Device:
self._profiles = _hidpp20.get_profiles(self)
return self._profiles
def set_configuration(self, configuration, no_reply=False):
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)
_hidpp20.config_change(self, configuration_, no_reply=no_reply)
def reset(self, no_reply=False):
self.set_configuration(0, no_reply)
@ -320,7 +358,7 @@ class Device:
if not self._persister:
with self._persister_lock:
if not self._persister:
self._persister = _configuration.persister(self)
self._persister = configuration.persister(self)
return self._persister
@property
@ -343,7 +381,7 @@ class Device:
if not self._feature_settings_checked:
with self._settings_lock:
if not self._feature_settings_checked:
self._feature_settings_checked = _check_feature_settings(self, self._settings)
self._feature_settings_checked = settings_templates.check_feature_settings(self, self._settings)
return self._settings
def battery(self): # None or level, next, status, voltage
@ -356,11 +394,11 @@ class Device:
try:
feature, battery = result
if self.persister and battery_feature is None:
self.persister["_battery"] = feature
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:
self.persister["_battery"] = result.value
def set_battery_info(self, info):
"""Update battery information for device, calling changed callback if necessary"""
@ -374,11 +412,11 @@ class Device:
if old_info is None:
old_info = Battery(None, None, None, None)
alert, reason = ALERT.NONE, None
alert, reason = Alert.NONE, None
if not info.ok():
logger.warning("%s: battery %d%%, ALERT %s", self, info.level, info.status)
if old_info.status != info.status:
alert = ALERT.NOTIFICATION | ALERT.ATTENTION
alert = Alert.NOTIFICATION | Alert.ATTENTION
reason = info.to_str()
if changed or reason:
@ -392,9 +430,11 @@ class Device:
battery = self.battery()
self.set_battery_info(battery if battery is not None else Battery(None, None, None, None))
def changed(self, active=None, alert=ALERT.NONE, reason=None, push=False):
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
@ -405,7 +445,7 @@ class Device:
was_active is None
or not was_active
or push
and (not self.features or hidpp20_constants.FEATURE.WIRELESS_DEVICE_STATUS not in self.features)
and (not self.features or SupportedFeature.WIRELESS_DEVICE_STATUS not in self.features)
):
if logger.isEnabledFor(logging.INFO):
logger.info("%s pushing device settings %s", self, self.settings)
@ -417,7 +457,7 @@ class Device:
self.set_configuration(0x11) # signal end of configuration
self.read_battery() # battery information may have changed so try to read it now
elif was_active and self.receiver: # need to set configuration pending flag in receiver
hidpp10.Hidpp10().set_configuration_pending_flags(self.receiver, 0xFF)
hidpp10.set_configuration_pending_flags(self.receiver, 0xFF)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("device %d changed: active=%s %s", self.number, self._active, self.battery_info)
if self.status_callback is not None:
@ -430,11 +470,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)
@ -443,8 +479,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):
@ -481,7 +521,7 @@ class Device:
long = self.hidpp_long is True or (
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
)
return base.request(
return self.low_level.request(
self.handle or self.receiver.handle,
self.number,
request_id,
@ -496,14 +536,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)
)
protocol = base.ping(self.handle or self.receiver.handle, self.number, long_message=long)
self.online = protocol is not None
handle = self.handle or self.receiver.handle
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
@ -516,7 +563,7 @@ class Device:
if hasattr(self, "cleanups"):
for cleanup in self.cleanups:
cleanup(self)
return handle and base.close(handle)
return handle and self.low_level.close(handle)
def __index__(self):
return self.number

View File

@ -14,48 +14,48 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import ctypes as _ctypes
from __future__ import annotations
import ctypes
import logging
import math
import numbers
import os as _os
import os.path as _path
import platform as _platform
import os
import platform
import socket
import struct
import subprocess
import sys as _sys
import time as _time
import sys
import time
import typing
from typing import Any
from typing import Dict
from typing import Tuple
import dbus
import gi
import psutil
import yaml
from keysyms import keysymdef
# There is no evdev on macOS or Windows. Diversion will not work without
# it but other Solaar functionality is available.
if _platform.system() in ("Darwin", "Windows"):
if platform.system() in ("Darwin", "Windows"):
evdev = None
else:
import evdev
from math import sqrt as _sqrt
from struct import unpack as _unpack
from yaml import add_representer as _yaml_add_representer
from yaml import dump_all as _yaml_dump_all
from yaml import safe_load_all as _yaml_safe_load_all
from .common import NamedInt
from .hidpp20 import FEATURE as _F
from .special_keys import CONTROL as _CONTROL
from .hidpp20 import SupportedFeature
from .special_keys import CONTROL
gi.require_version("Gdk", "3.0") # isort:skip
from gi.repository import Gdk, GLib # NOQA: E402 # isort:skip
if typing.TYPE_CHECKING:
from .base import HIDPPNotification
logger = logging.getLogger(__name__)
#
@ -88,7 +88,7 @@ logger = logging.getLogger(__name__)
# Xtest extension to X11 - provides input simulation, partly works under Wayland
# Wayland - provides input simulation
XK_KEYS: Dict[str, int] = keysymdef.keysymdef
XK_KEYS: Dict[str, int] = keysymdef.key_symbols
# Event codes - can't use Xlib.X codes because Xlib might not be available
_KEY_RELEASE = 0
@ -103,7 +103,7 @@ gkeymap = Gdk.Keymap.get_for_display(gdisplay) if gdisplay else None
if logger.isEnabledFor(logging.INFO):
logger.info("GDK Keymap %sset up", "" if gkeymap else "not ")
wayland = _os.getenv("WAYLAND_DISPLAY") # is this Wayland?
wayland = os.getenv("WAYLAND_DISPLAY") # is this Wayland?
if wayland:
logger.warning(
"rules cannot access modifier keys in Wayland, "
@ -120,9 +120,17 @@ except Exception:
# Globals
xtest_available = True # Xtest might be available
xdisplay = None
Xkbdisplay = None # xkb might be available
X11Lib = None
modifier_keycodes = []
XkbUseCoreKbd = 0x100
NET_ACTIVE_WINDOW = None
NET_WM_PID = None
WM_CLASS = None
udevice = None
@ -138,26 +146,26 @@ thumb_wheel_displacement = 0
_dbus_interface = None
class XkbDisplay(_ctypes.Structure):
class XkbDisplay(ctypes.Structure):
"""opaque struct"""
class XkbStateRec(_ctypes.Structure):
class XkbStateRec(ctypes.Structure):
_fields_ = [
("group", _ctypes.c_ubyte),
("locked_group", _ctypes.c_ubyte),
("base_group", _ctypes.c_ushort),
("latched_group", _ctypes.c_ushort),
("mods", _ctypes.c_ubyte),
("base_mods", _ctypes.c_ubyte),
("latched_mods", _ctypes.c_ubyte),
("locked_mods", _ctypes.c_ubyte),
("compat_state", _ctypes.c_ubyte),
("grab_mods", _ctypes.c_ubyte),
("compat_grab_mods", _ctypes.c_ubyte),
("lookup_mods", _ctypes.c_ubyte),
("compat_lookup_mods", _ctypes.c_ubyte),
("ptr_buttons", _ctypes.c_ushort),
("group", ctypes.c_ubyte),
("locked_group", ctypes.c_ubyte),
("base_group", ctypes.c_ushort),
("latched_group", ctypes.c_ushort),
("mods", ctypes.c_ubyte),
("base_mods", ctypes.c_ubyte),
("latched_mods", ctypes.c_ubyte),
("locked_mods", ctypes.c_ubyte),
("compat_state", ctypes.c_ubyte),
("grab_mods", ctypes.c_ubyte),
("compat_grab_mods", ctypes.c_ubyte),
("lookup_mods", ctypes.c_ubyte),
("compat_lookup_mods", ctypes.c_ubyte),
("ptr_buttons", ctypes.c_ushort),
] # something strange is happening here but it is not being used
@ -177,7 +185,7 @@ def x11_setup():
if logger.isEnabledFor(logging.INFO):
logger.info("X11 library loaded and display set up")
except Exception:
logger.warning("X11 not available - some rule capabilities inoperable", exc_info=_sys.exc_info())
logger.warning("X11 not available - some rule capabilities inoperable", exc_info=sys.exc_info())
_x11 = False
xtest_available = False
return _x11
@ -188,11 +196,16 @@ def gnome_dbus_interface_setup():
if _dbus_interface is not None:
return _dbus_interface
try:
import dbus
bus = dbus.SessionBus()
remote_object = bus.get_object("org.gnome.Shell", "/io/github/pwr_solaar/solaar")
_dbus_interface = dbus.Interface(remote_object, "io.github.pwr_solaar.solaar")
except dbus.exceptions.DBusException:
logger.warning("Solaar Gnome extension not installed - some rule capabilities inoperable", exc_info=_sys.exc_info())
logger.warning(
"Solaar Gnome extension not installed - some rule capabilities inoperable",
exc_info=sys.exc_info(),
)
_dbus_interface = False
return _dbus_interface
@ -202,14 +215,14 @@ def xkb_setup():
if Xkbdisplay is not None:
return Xkbdisplay
try: # set up to get keyboard state using ctypes interface to libx11
X11Lib = _ctypes.cdll.LoadLibrary("libX11.so")
X11Lib.XOpenDisplay.restype = _ctypes.POINTER(XkbDisplay)
X11Lib.XkbGetState.argtypes = [_ctypes.POINTER(XkbDisplay), _ctypes.c_uint, _ctypes.POINTER(XkbStateRec)]
X11Lib = ctypes.cdll.LoadLibrary("libX11.so")
X11Lib.XOpenDisplay.restype = ctypes.POINTER(XkbDisplay)
X11Lib.XkbGetState.argtypes = [ctypes.POINTER(XkbDisplay), ctypes.c_uint, ctypes.POINTER(XkbStateRec)]
Xkbdisplay = X11Lib.XOpenDisplay(None)
if logger.isEnabledFor(logging.INFO):
logger.info("XKB display set up")
except Exception:
logger.warning("XKB display not available - rules cannot access keyboard group", exc_info=_sys.exc_info())
logger.warning("XKB display not available - rules cannot access keyboard group", exc_info=sys.exc_info())
Xkbdisplay = False
return Xkbdisplay
@ -226,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
@ -233,7 +248,10 @@ if evdev:
for _, evcode in buttons.values():
if evcode:
key_events.append(evcode)
devicecap = {evdev.ecodes.EV_KEY: key_events, evdev.ecodes.EV_REL: [evdev.ecodes.REL_WHEEL, evdev.ecodes.REL_HWHEEL]}
devicecap = {
evdev.ecodes.EV_KEY: key_events,
evdev.ecodes.EV_REL: [evdev.ecodes.REL_WHEEL, evdev.ecodes.REL_HWHEEL],
}
else:
# Just mock these since they won't be useful without evdev anyway
buttons = {}
@ -261,7 +279,7 @@ if wayland: # Wayland can't use xtest so may as well set up uinput now
def kbdgroup():
if xkb_setup():
state = XkbStateRec()
X11Lib.XkbGetState(Xkbdisplay, XkbUseCoreKbd, _ctypes.pointer(state))
X11Lib.XkbGetState(Xkbdisplay, XkbUseCoreKbd, ctypes.pointer(state))
return state.group
else:
return None
@ -281,16 +299,16 @@ def signed(bytes_: bytes) -> int:
def xy_direction(_x, _y):
# normalize x and y
m = _sqrt((_x * _x) + (_y * _y))
m = math.sqrt((_x * _x) + (_y * _y))
if m == 0:
return "noop"
x = round(_x / m)
y = round(_y / m)
if x < 0 and y < 0:
return "Mouse Up-left"
elif x > 0 and y < 0:
elif x > 0 > y:
return "Mouse Up-right"
elif x < 0 and y > 0:
elif x < 0 < y:
return "Mouse Down-left"
elif x > 0 and y > 0:
return "Mouse Down-right"
@ -348,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")
@ -418,7 +436,7 @@ def simulate_scroll(dx, dy):
def thumb_wheel_up(f, r, d, a):
global thumb_wheel_displacement
if f != _F.THUMB_WHEEL or r != 0:
if f != SupportedFeature.THUMB_WHEEL or r != 0:
return False
if a is None:
return signed(d[0:2]) < 0 and signed(d[0:2])
@ -431,7 +449,7 @@ def thumb_wheel_up(f, r, d, a):
def thumb_wheel_down(f, r, d, a):
global thumb_wheel_displacement
if f != _F.THUMB_WHEEL or r != 0:
if f != SupportedFeature.THUMB_WHEEL or r != 0:
return False
if a is None:
return signed(d[0:2]) > 0 and signed(d[0:2])
@ -444,9 +462,9 @@ def thumb_wheel_down(f, r, d, a):
def charging(f, r, d, _a):
if (
(f == _F.BATTERY_STATUS and r == 0 and 1 <= d[2] <= 4)
or (f == _F.BATTERY_VOLTAGE and r == 0 and d[2] & (1 << 7))
or (f == _F.UNIFIED_BATTERY and r == 0 and 1 <= d[2] <= 3)
(f == SupportedFeature.BATTERY_STATUS and r == 0 and 1 <= d[2] <= 4)
or (f == SupportedFeature.BATTERY_VOLTAGE and r == 0 and d[2] & (1 << 7))
or (f == SupportedFeature.UNIFIED_BATTERY and r == 0 and 1 <= d[2] <= 3)
):
return 1
else:
@ -454,20 +472,32 @@ def charging(f, r, d, _a):
TESTS = {
"crown_right": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[1] < 128 and d[1], False],
"crown_left": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[1] >= 128 and 256 - d[1], False],
"crown_right_ratchet": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[2] < 128 and d[2], False],
"crown_left_ratchet": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[2] >= 128 and 256 - d[2], False],
"crown_tap": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[5] == 0x01 and d[5], False],
"crown_start_press": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[6] == 0x01 and d[6], False],
"crown_end_press": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[6] == 0x05 and d[6], False],
"crown_pressed": [lambda f, r, d, a: f == _F.CROWN and r == 0 and d[6] >= 0x01 and d[6] <= 0x04 and d[6], False],
"crown_right": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[1] < 128 and d[1], False],
"crown_left": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[1] >= 128 and 256 - d[1], False],
"crown_right_ratchet": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[2] < 128 and d[2], False],
"crown_left_ratchet": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[2] >= 128 and 256 - d[2], False],
"crown_tap": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[5] == 0x01 and d[5], False],
"crown_start_press": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[6] == 0x01 and d[6], False],
"crown_end_press": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and d[6] == 0x05 and d[6], False],
"crown_pressed": [lambda f, r, d, a: f == SupportedFeature.CROWN and r == 0 and 0x01 <= d[6] <= 0x04 and d[6], False],
"thumb_wheel_up": [thumb_wheel_up, True],
"thumb_wheel_down": [thumb_wheel_down, True],
"lowres_wheel_up": [lambda f, r, d, a: f == _F.LOWRES_WHEEL and r == 0 and signed(d[0:1]) > 0 and signed(d[0:1]), False],
"lowres_wheel_down": [lambda f, r, d, a: f == _F.LOWRES_WHEEL and r == 0 and signed(d[0:1]) < 0 and signed(d[0:1]), False],
"hires_wheel_up": [lambda f, r, d, a: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) > 0 and signed(d[1:3]), False],
"hires_wheel_down": [lambda f, r, d, a: f == _F.HIRES_WHEEL and r == 0 and signed(d[1:3]) < 0 and signed(d[1:3]), False],
"lowres_wheel_up": [
lambda f, r, d, a: f == SupportedFeature.LOWRES_WHEEL and r == 0 and signed(d[0:1]) > 0 and signed(d[0:1]),
False,
],
"lowres_wheel_down": [
lambda f, r, d, a: f == SupportedFeature.LOWRES_WHEEL and r == 0 and signed(d[0:1]) < 0 and signed(d[0:1]),
False,
],
"hires_wheel_up": [
lambda f, r, d, a: f == SupportedFeature.HIRES_WHEEL and r == 0 and signed(d[1:3]) > 0 and signed(d[1:3]),
False,
],
"hires_wheel_down": [
lambda f, r, d, a: f == SupportedFeature.HIRES_WHEEL and r == 0 and signed(d[1:3]) < 0 and signed(d[1:3]),
False,
],
"charging": [charging, False],
"False": [lambda f, r, d, a: False, False],
"True": [lambda f, r, d, a: True, False],
@ -481,7 +511,7 @@ MOUSE_GESTURE_TESTS = {
"mouse-noop": [],
}
COMPONENTS = {}
# COMPONENTS = {}
class RuleComponent:
@ -496,28 +526,32 @@ class RuleComponent:
return Condition()
def _evaluate(components, feature, notification: HIDPPNotification, device, result) -> Any:
res = True
for component in components:
res = component.evaluate(feature, notification, device, result)
if not isinstance(component, Action) and res is None:
return None
if isinstance(component, Condition) and not res:
return res
return res
class Rule(RuleComponent):
def __init__(self, args, source=None, warn=True):
self.components = [self.compile(a) for a in args]
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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate rule: %s", self)
result = True
for component in self.components:
result = component.evaluate(feature, notification, device, result)
if not isinstance(component, Action) and result is None:
return None
if isinstance(component, Condition) and not result:
return result
return result
return _evaluate(self.components, feature, notification, device, True)
def once(self, feature, notification, device, last_result):
def once(self, feature, notification: HIDPPNotification, device, last_result):
self.evaluate(feature, notification, device, last_result)
return False
@ -532,7 +566,7 @@ class Condition(RuleComponent):
def __str__(self):
return "CONDITION"
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
return False
@ -546,9 +580,9 @@ 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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
result = self.component.evaluate(feature, notification, device, last_result)
@ -565,7 +599,7 @@ class Or(Condition):
def __str__(self):
return "Or: [" + ", ".join(str(c) for c in self.components) + "]"
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
result = False
@ -588,17 +622,10 @@ class And(Condition):
def __str__(self):
return "And: [" + ", ".join(str(c) for c in self.components) + "]"
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
result = True
for component in self.components:
result = component.evaluate(feature, notification, device, last_result)
if not isinstance(component, Action) and result is None:
return None
if isinstance(component, Condition) and not result:
return result
return result
return _evaluate(self.components, feature, notification, device, last_result)
def data(self):
return {"And": [c.data() for c in self.components]}
@ -656,7 +683,8 @@ class Process(Condition):
if (not wayland and not x11_setup()) or (wayland and not gnome_dbus_interface_setup()):
if warn:
logger.warning(
"rules can only access active process in X11 or in Wayland under GNOME with Solaar Gnome extension - %s",
"rules can only access active process in X11 or in Wayland under GNOME with Solaar Gnome "
"extension - %s",
self,
)
if not isinstance(process, str):
@ -665,9 +693,9 @@ 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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
if not isinstance(self.process, str):
@ -696,9 +724,9 @@ 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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
if not isinstance(self.process, str):
@ -712,17 +740,18 @@ class MouseProcess(Condition):
class Feature(Condition):
def __init__(self, feature, warn=True):
if not (isinstance(feature, str) and feature in _F):
def __init__(self, feature: str, warn: bool = True):
try:
self.feature = SupportedFeature[feature.replace(" ", "_")]
except KeyError:
self.feature = None
if warn:
logger.warning("rule Feature argument not name of a feature: %s", feature)
self.feature = None
self.feature = _F[feature]
def __str__(self):
return "Feature: " + str(self.feature)
return f"Feature: {str(self.feature)}"
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
return feature == self.feature
@ -741,9 +770,9 @@ 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, device, last_result):
def evaluate(self, report, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
return (notification.address >> 4) == self.report
@ -765,7 +794,7 @@ class Setting(Condition):
def __str__(self):
return "Setting: " + " ".join([str(a) for a in self.args])
def evaluate(self, report, notification, device, last_result):
def evaluate(self, report, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
if len(self.args) < 3:
@ -814,9 +843,9 @@ 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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
if gkeymap:
@ -856,8 +885,11 @@ class Key(Condition):
elif len(args) >= 2:
key, action = args[:2]
if isinstance(key, str) and key in _CONTROL:
self.key = _CONTROL[key]
if isinstance(key, str) and key in CONTROL:
self.key = CONTROL[key]
elif isinstance(key, str) and key.startswith("unknown:"):
logger.info(f"rule Key key name currently unknown: {key}")
self.key = CONTROL[int(key[-4:], 16)]
else:
if warn:
logger.warning(f"rule Key key name not name of a Logitech key: {key}")
@ -873,7 +905,7 @@ class Key(Condition):
def __str__(self):
return f"Key: {str(self.key) if self.key else 'None'} ({self.action})"
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
return bool(self.key and self.key == (key_down if self.action == self.DOWN else key_up))
@ -895,8 +927,8 @@ class KeyIsDown(Condition):
elif isinstance(args, str):
key = args
if isinstance(key, str) and key in _CONTROL:
self.key = _CONTROL[key]
if isinstance(key, str) and key in CONTROL:
self.key = CONTROL[key]
else:
if warn:
logger.warning(f"rule Key key name not name of a Logitech key: {key}")
@ -905,7 +937,7 @@ class KeyIsDown(Condition):
def __str__(self):
return f"KeyIsDown: {str(self.key) if self.key else 'None'}"
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
return key_is_down(self.key)
@ -957,9 +989,9 @@ 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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
return self.function(feature, notification.address, notification.data, self.parameter)
@ -985,9 +1017,9 @@ 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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
return self.function(feature, notification.address, notification.data)
@ -1012,7 +1044,7 @@ class MouseGesture(Condition):
if isinstance(movements, str):
movements = [movements]
for x in movements:
if x not in self.MOVEMENTS and x not in _CONTROL:
if x not in self.MOVEMENTS and x not in CONTROL:
if warn:
logger.warning("rule Mouse Gesture argument not direction or name of a Logitech key: %s", x)
self.movements = movements
@ -1020,17 +1052,17 @@ class MouseGesture(Condition):
def __str__(self):
return "MouseGesture: " + " ".join(self.movements)
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
if feature == _F.MOUSE_GESTURE:
if feature == SupportedFeature.MOUSE_GESTURE:
d = notification.data
data = _unpack("!" + (int(len(d) / 2) * "h"), d)
data = struct.unpack("!" + (int(len(d) / 2) * "h"), d)
data_offset = 1
movement_offset = 0
if self.movements and self.movements[0] not in self.MOVEMENTS: # matching against initiating key
movement_offset = 1
if self.movements[0] != str(_CONTROL[data[0]]):
if self.movements[0] != str(CONTROL[data[0]]):
return False
for m in self.movements[movement_offset:]:
if data_offset >= len(data):
@ -1041,7 +1073,7 @@ class MouseGesture(Condition):
return False
data_offset += 3
elif data[data_offset] == 1:
if m != str(_CONTROL[data[data_offset + 1]]):
if m != str(CONTROL[data[data_offset + 1]]):
return False
data_offset += 2
return data_offset == len(data)
@ -1060,9 +1092,9 @@ 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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
dev = device.find(self.devID)
@ -1081,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, device, last_result):
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}
@ -1101,9 +1138,9 @@ 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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluate condition: %s", self)
hostname = socket.getfqdn()
@ -1117,7 +1154,7 @@ class Action(RuleComponent):
def __init__(self, *args):
pass
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
return None
@ -1204,16 +1241,22 @@ class KeyPress(Action):
simulate_key(keycode, _KEY_RELEASE)
self.mods(level, modifiers, _KEY_RELEASE)
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if gkeymap:
current = gkeymap.get_modifier_state()
if logger.isEnabledFor(logging.INFO):
logger.info("KeyPress action: %s %s, group %s, modifiers %s", self.key_names, self.action, kbdgroup(), current)
logger.info(
"KeyPress action: %s %s, group %s, modifiers %s",
self.key_names,
self.action,
kbdgroup(),
current,
)
if self.action != RELEASE:
self.keyDown(self.key_symbols, current)
if self.action != DEPRESS:
self.keyUp(reversed(self.key_symbols), current)
_time.sleep(0.01)
time.sleep(0.01)
else:
logger.warning("no keymap so cannot determine which keycode to send")
return None
@ -1224,10 +1267,10 @@ class KeyPress(Action):
# KeyDown is dangerous as the key can auto-repeat and make your system unusable
# class KeyDown(KeyPress):
# def evaluate(self, feature, notification, device, last_result):
# def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
# super().keyDown(self.keys, current_key_modifiers)
# class KeyUp(KeyPress):
# def evaluate(self, feature, notification, device, last_result):
# def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
# super().keyUp(self.keys, current_key_modifiers)
@ -1244,7 +1287,7 @@ class MouseScroll(Action):
def __str__(self):
return "MouseScroll: " + " ".join([str(a) for a in self.amounts])
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
amounts = self.amounts
if isinstance(last_result, numbers.Number):
amounts = [math.floor(last_result * a) for a in self.amounts]
@ -1252,7 +1295,7 @@ class MouseScroll(Action):
logger.info("MouseScroll action: %s %s %s", self.amounts, last_result, amounts)
dx, dy = amounts
simulate_scroll(dx, dy)
_time.sleep(0.01)
time.sleep(0.01)
return None
def data(self):
@ -1277,18 +1320,21 @@ class MouseClick(Action):
if count in [CLICK, DEPRESS, RELEASE]:
self.count = count
elif warn:
logger.warning("rule MouseClick action: argument %s should be an integer or CLICK, PRESS, or RELEASE", count)
logger.warning(
"rule MouseClick action: argument %s should be an integer or CLICK, PRESS, or RELEASE",
count,
)
self.count = 1
def __str__(self):
return f"MouseClick: {self.button} ({int(self.count)})"
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.INFO):
logger.info(f"MouseClick action: {int(self.count)} {self.button}")
if self.button and self.count:
click(buttons[self.button], self.count)
_time.sleep(0.01)
time.sleep(0.01)
return None
def data(self):
@ -1307,7 +1353,7 @@ class Set(Action):
def __str__(self):
return "Set: " + " ".join([str(a) for a in self.args])
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if len(self.args) < 3:
return None
if logger.isEnabledFor(logging.INFO):
@ -1322,7 +1368,12 @@ class Set(Action):
return None
args = setting.acceptable(self.args[2:], setting.read())
if args is None:
logger.warning("Set Action: invalid args %s for setting %s of %s", self.args[2:], self.args[1], self.args[0])
logger.warning(
"Set Action: invalid args %s for setting %s of %s",
self.args[2:],
self.args[1],
self.args[0],
)
return None
if len(args) > 1:
setting.write_key_value(args[0], args[1])
@ -1350,7 +1401,7 @@ class Execute(Action):
def __str__(self):
return "Execute: " + " ".join([a for a in self.args])
def evaluate(self, feature, notification, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if logger.isEnabledFor(logging.INFO):
logger.info("Execute action: %s", self.args)
subprocess.Popen(self.args)
@ -1379,9 +1430,9 @@ 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, device, last_result):
def evaluate(self, feature, notification: HIDPPNotification, device, last_result):
if self.delay and self.rule:
if self.delay >= 1:
GLib.timeout_add_seconds(int(self.delay), Rule.once, self.rule, feature, notification, device, last_result)
@ -1422,93 +1473,93 @@ COMPONENTS = {
"Later": Later,
}
built_in_rules = Rule([])
if True:
built_in_rules = Rule(
[
{
"Rule": [ # Implement problematic keys for Craft and MX Master
{"Rule": [{"Key": ["Brightness Down", "pressed"]}, {"KeyPress": "XF86_MonBrightnessDown"}]},
{"Rule": [{"Key": ["Brightness Up", "pressed"]}, {"KeyPress": "XF86_MonBrightnessUp"}]},
]
},
]
)
built_in_rules = Rule(
[
{
"Rule": [ # Implement problematic keys for Craft and MX Master
{"Rule": [{"Key": ["Brightness Down", "pressed"]}, {"KeyPress": "XF86_MonBrightnessDown"}]},
{"Rule": [{"Key": ["Brightness Up", "pressed"]}, {"KeyPress": "XF86_MonBrightnessUp"}]},
]
},
]
)
def key_is_down(key):
if key == _CONTROL.MR:
def key_is_down(key: NamedInt) -> bool:
"""Checks if given key is pressed or not."""
if key == CONTROL.MR:
return mr_key_down
elif _CONTROL.M1 <= key <= _CONTROL.M8:
return bool(m_keys_down & (0x01 << (key - _CONTROL.M1)))
elif _CONTROL.G1 <= key <= _CONTROL.G32:
return bool(g_keys_down & (0x01 << (key - _CONTROL.G1)))
else:
return key in keys_down
elif CONTROL.M1 <= key <= CONTROL.M8:
return bool(m_keys_down & (0x01 << (key - CONTROL.M1)))
elif CONTROL.G1 <= key <= CONTROL.G32:
return bool(g_keys_down & (0x01 << (key - CONTROL.G1)))
return key in keys_down
def evaluate_rules(feature, notification, device):
def evaluate_rules(feature, notification: HIDPPNotification, device):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("evaluating rules on %s", notification)
rules.evaluate(feature, notification, device, True)
# process a notification
def process_notification(device, notification, feature):
def process_notification(device, notification: HIDPPNotification, feature) -> None:
"""Processes HID++ notifications."""
global keys_down, g_keys_down, m_keys_down, mr_key_down, key_down, key_up, thumb_wheel_displacement
key_down, key_up = None, None
# need to keep track of keys that are down to find a new key down
if feature == _F.REPROG_CONTROLS_V4 and notification.address == 0x00:
new_keys_down = _unpack("!4H", notification.data[:8])
for key in new_keys_down:
if key and key not in keys_down:
key_down = key
for key in keys_down:
if key and key not in new_keys_down:
key_up = key
keys_down = new_keys_down
# and also G keys down
elif feature == _F.GKEY and notification.address == 0x00:
new_g_keys_down = _unpack("<I", notification.data[:4])[0]
for i in range(32):
if new_g_keys_down & (0x01 << i) and not g_keys_down & (0x01 << i):
key_down = _CONTROL["G" + str(i + 1)]
if g_keys_down & (0x01 << i) and not new_g_keys_down & (0x01 << i):
key_up = _CONTROL["G" + str(i + 1)]
g_keys_down = new_g_keys_down
# and also M keys down
elif feature == _F.MKEYS and notification.address == 0x00:
new_m_keys_down = _unpack("!1B", notification.data[:1])[0]
for i in range(1, 9):
if new_m_keys_down & (0x01 << (i - 1)) and not m_keys_down & (0x01 << (i - 1)):
key_down = _CONTROL["M" + str(i)]
if m_keys_down & (0x01 << (i - 1)) and not new_m_keys_down & (0x01 << (i - 1)):
key_up = _CONTROL["M" + str(i)]
m_keys_down = new_m_keys_down
# and also MR key
elif feature == _F.MR and notification.address == 0x00:
new_mr_key_down = _unpack("!1B", notification.data[:1])[0]
if not mr_key_down and new_mr_key_down:
key_down = _CONTROL["MR"]
if mr_key_down and not new_mr_key_down:
key_up = _CONTROL["MR"]
mr_key_down = new_mr_key_down
# keep track of thumb wheel movment
elif feature == _F.THUMB_WHEEL and notification.address == 0x00:
if notification.data[4] <= 0x01: # when wheel starts, zero out last movement
thumb_wheel_displacement = 0
thumb_wheel_displacement += signed(notification.data[0:2])
if notification.address == 0x00:
if feature == SupportedFeature.REPROG_CONTROLS_V4:
new_keys_down = struct.unpack("!4H", notification.data[:8])
for key in new_keys_down:
if key and key not in keys_down:
key_down = key
for key in keys_down:
if key and key not in new_keys_down:
key_up = key
keys_down = new_keys_down
# and also G keys down
elif feature == SupportedFeature.GKEY:
new_g_keys_down = struct.unpack("<I", notification.data[:4])[0]
for i in range(32):
if new_g_keys_down & (0x01 << i) and not g_keys_down & (0x01 << i):
key_down = CONTROL["G" + str(i + 1)]
if g_keys_down & (0x01 << i) and not new_g_keys_down & (0x01 << i):
key_up = CONTROL["G" + str(i + 1)]
g_keys_down = new_g_keys_down
# and also M keys down
elif feature == SupportedFeature.MKEYS:
new_m_keys_down = struct.unpack("!1B", notification.data[:1])[0]
for i in range(1, 9):
if new_m_keys_down & (0x01 << (i - 1)) and not m_keys_down & (0x01 << (i - 1)):
key_down = CONTROL["M" + str(i)]
if m_keys_down & (0x01 << (i - 1)) and not new_m_keys_down & (0x01 << (i - 1)):
key_up = CONTROL["M" + str(i)]
m_keys_down = new_m_keys_down
# and also MR key
elif feature == SupportedFeature.MR:
new_mr_key_down = struct.unpack("!1B", notification.data[:1])[0]
if not mr_key_down and new_mr_key_down:
key_down = CONTROL["MR"]
if mr_key_down and not new_mr_key_down:
key_up = CONTROL["MR"]
mr_key_down = new_mr_key_down
# keep track of thumb wheel movement
elif feature == SupportedFeature.THUMB_WHEEL:
if notification.data[4] <= 0x01: # when wheel starts, zero out last movement
thumb_wheel_displacement = 0
thumb_wheel_displacement += signed(notification.data[0:2])
GLib.idle_add(evaluate_rules, feature, notification, device)
_XDG_CONFIG_HOME = _os.environ.get("XDG_CONFIG_HOME") or _path.expanduser(_path.join("~", ".config"))
_file_path = _path.join(_XDG_CONFIG_HOME, "solaar", "rules.yaml")
_XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser(os.path.join("~", ".config"))
_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "rules.yaml")
rules = built_in_rules
def _save_config_rule_file(file_name=_file_path):
def _save_config_rule_file(file_name: str = _file_path):
# This is a trick to show str/float/int lists in-line (inspired by https://stackoverflow.com/a/14001707)
class inline_list(list):
pass
@ -1516,7 +1567,7 @@ def _save_config_rule_file(file_name=_file_path):
def blockseq_rep(dumper, data):
return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True)
_yaml_add_representer(inline_list, blockseq_rep)
yaml.add_representer(inline_list, blockseq_rep)
def convert(elem):
if isinstance(elem, list):
@ -1542,17 +1593,17 @@ def _save_config_rule_file(file_name=_file_path):
}
# Save only user-defined rules
rules_to_save = sum((r.data()["Rule"] for r in rules.components if r.source == file_name), [])
if True: # save even if there are no rules to save
if logger.isEnabledFor(logging.INFO):
logger.info("saving %d rule(s) to %s", len(rules_to_save), file_name)
try:
with open(file_name, "w") as f:
if rules_to_save:
f.write("%YAML 1.3\n") # Write version manually
_yaml_dump_all(convert([r["Rule"] for r in rules_to_save]), f, **dump_settings)
except Exception as e:
logger.error("failed to save to %s\n%s", file_name, e)
return False
if logger.isEnabledFor(logging.INFO):
logger.info("saving %d rule(s) to %s", len(rules_to_save), file_name)
try:
with open(file_name, "w") as f:
if rules_to_save:
f.write("%YAML 1.3\n") # Write version manually
dump_data = [r["Rule"] for r in rules_to_save]
yaml.dump_all(convert(dump_data), f, **dump_settings)
except Exception as e:
logger.error("failed to save to %s\n%s", file_name, e)
return False
return True
@ -1560,7 +1611,7 @@ def load_config_rule_file():
"""Loads user configured rules."""
global rules
if _path.isfile(_file_path):
if os.path.isfile(_file_path):
rules = _load_rule_config(_file_path)
@ -1569,7 +1620,7 @@ def _load_rule_config(file_path: str) -> Rule:
try:
with open(file_path) as config_file:
loaded_rules = []
for loaded_rule in _yaml_safe_load_all(config_file):
for loaded_rule in yaml.safe_load_all(config_file):
rule = Rule(loaded_rule, source=file_path)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("load rule: %s", rule)

View File

@ -15,14 +15,12 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from .common import KwException as _KwException
from .common import KwException
#
# Exceptions that may be raised by this API.
#
"""Exceptions that may be raised by this API."""
class NoReceiver(_KwException):
class NoReceiver(KwException):
"""Raised when trying to talk through a previously open handle, when the
receiver is no longer available. Should only happen if the receiver is
physically disconnected from the machine, or its kernel driver module is
@ -31,25 +29,25 @@ class NoReceiver(_KwException):
pass
class NoSuchDevice(_KwException):
class NoSuchDevice(KwException):
"""Raised when trying to reach a device number not paired to the receiver."""
pass
class DeviceUnreachable(_KwException):
class DeviceUnreachable(KwException):
"""Raised when a request is made to an unreachable (turned off) device."""
pass
class FeatureNotSupported(_KwException):
class FeatureNotSupported(KwException):
"""Raised when trying to request a feature not supported by the device."""
pass
class FeatureCallError(_KwException):
class FeatureCallError(KwException):
"""Raised if the device replied to a feature call with an error."""
pass

View File

@ -13,40 +13,75 @@
## 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
import logging
from .common import Battery as _Battery
from .common import FirmwareInfo as _FirmwareInfo
from .common import bytes2int as _bytes2int
from .common import int2bytes as _int2bytes
from .common import strhex as _strhex
from .hidpp10_constants import REGISTERS
from .hidpp20_constants import FIRMWARE_KIND
from typing import Any
from typing_extensions import Protocol
from . import common
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__)
#
# functions
#
class Device(Protocol):
def request(self, request_id, *params):
...
@property
def kind(self) -> Any:
...
@property
def online(self) -> bool:
...
@property
def protocol(self) -> Any:
...
@property
def registers(self) -> list:
...
def read_register(device, register_number, *params):
assert device is not None, f"tried to read register {register_number:02X} from invalid device {device}"
def read_register(device: Device, register: Registers | int, *params) -> Any:
assert device is not None, f"tried to read register {register:02X} from invalid device {device}"
# support long registers by adding a 2 in front of the register number
request_id = 0x8100 | (int(register_number) & 0x2FF)
request_id = 0x8100 | (int(register) & 0x2FF)
return device.request(request_id, *params)
def write_register(device, register_number, *value):
assert device is not None, f"tried to write register {register_number:02X} to invalid device {device}"
def write_register(device: Device, register: Registers | int, *value) -> Any:
assert device is not None, f"tried to write register {register:02X} to invalid device {device}"
# support long registers by adding a 2 in front of the register number
request_id = 0x8000 | (int(register_number) & 0x2FF)
request_id = 0x8000 | (int(register) & 0x2FF)
return device.request(request_id, *value)
def get_configuration_pending_flags(receiver):
assert not receiver.isDevice
result = read_register(receiver, Registers.DEVICES_CONFIGURATION)
if result is not None:
return ord(result[:1])
def set_configuration_pending_flags(receiver, devices):
assert not receiver.isDevice
result = write_register(receiver, Registers.DEVICES_CONFIGURATION, devices)
return result is not None
class Hidpp10:
def get_battery(self, device):
def get_battery(self, device: Device):
assert device is not None
assert device.kind is not None
if not device.online:
@ -56,7 +91,7 @@ class Hidpp10:
# let's just assume HID++ 2.0 devices do not provide the battery info in a register
return
for r in (REGISTERS.battery_status, REGISTERS.battery_charge):
for r in (Registers.BATTERY_STATUS, Registers.BATTERY_CHARGE):
if r in device.registers:
reply = read_register(device, r)
if reply:
@ -64,74 +99,74 @@ class Hidpp10:
return
# the descriptor does not tell us which register this device has, try them both
reply = read_register(device, REGISTERS.battery_charge)
reply = read_register(device, Registers.BATTERY_CHARGE)
if reply:
# remember this for the next time
device.registers.append(REGISTERS.battery_charge)
return parse_battery_status(REGISTERS.battery_charge, reply)
device.registers.append(Registers.BATTERY_CHARGE)
return parse_battery_status(Registers.BATTERY_CHARGE, reply)
reply = read_register(device, REGISTERS.battery_status)
reply = read_register(device, Registers.BATTERY_STATUS)
if reply:
# remember this for the next time
device.registers.append(REGISTERS.battery_status)
return parse_battery_status(REGISTERS.battery_status, reply)
device.registers.append(Registers.BATTERY_STATUS)
return parse_battery_status(Registers.BATTERY_STATUS, reply)
def get_firmware(self, device):
def get_firmware(self, device: Device) -> tuple[common.FirmwareInfo] | None:
assert device is not None
firmware = [None, None, None]
reply = read_register(device, REGISTERS.firmware, 0x01)
reply = read_register(device, Registers.FIRMWARE, 0x01)
if not reply:
# won't be able to read any of it now...
return
fw_version = _strhex(reply[1:3])
fw_version = common.strhex(reply[1:3])
fw_version = f"{fw_version[0:2]}.{fw_version[2:4]}"
reply = read_register(device, REGISTERS.firmware, 0x02)
reply = read_register(device, Registers.FIRMWARE, 0x02)
if reply:
fw_version += ".B" + _strhex(reply[1:3])
fw = _FirmwareInfo(FIRMWARE_KIND.Firmware, "", fw_version, None)
fw_version += ".B" + common.strhex(reply[1:3])
fw = common.FirmwareInfo(FirmwareKind.Firmware, "", fw_version, None)
firmware[0] = fw
reply = read_register(device, REGISTERS.firmware, 0x04)
reply = read_register(device, Registers.FIRMWARE, 0x04)
if reply:
bl_version = _strhex(reply[1:3])
bl_version = common.strhex(reply[1:3])
bl_version = f"{bl_version[0:2]}.{bl_version[2:4]}"
bl = _FirmwareInfo(FIRMWARE_KIND.Bootloader, "", bl_version, None)
bl = common.FirmwareInfo(FirmwareKind.Bootloader, "", bl_version, None)
firmware[1] = bl
reply = read_register(device, REGISTERS.firmware, 0x03)
reply = read_register(device, Registers.FIRMWARE, 0x03)
if reply:
o_version = _strhex(reply[1:3])
o_version = common.strhex(reply[1:3])
o_version = f"{o_version[0:2]}.{o_version[2:4]}"
o = _FirmwareInfo(FIRMWARE_KIND.Other, "", o_version, None)
o = common.FirmwareInfo(FirmwareKind.Other, "", o_version, None)
firmware[2] = o
if any(firmware):
return tuple(f for f in firmware if f)
def set_3leds(self, device, battery_level=None, charging=None, warning=None):
def set_3leds(self, device: Device, battery_level=None, charging=None, warning=None):
assert device is not None
assert device.kind is not None
if not device.online:
return
if REGISTERS.three_leds not in device.registers:
if Registers.THREE_LEDS not in device.registers:
return
if battery_level is not None:
if battery_level < _Battery.APPROX.critical:
if battery_level < BatteryLevelApproximation.CRITICAL:
# 1 orange, and force blink
v1, v2 = 0x22, 0x00
warning = True
elif battery_level < _Battery.APPROX.low:
elif battery_level < BatteryLevelApproximation.LOW:
# 1 orange
v1, v2 = 0x22, 0x00
elif battery_level < _Battery.APPROX.good:
elif battery_level < BatteryLevelApproximation.GOOD:
# 1 green
v1, v2 = 0x20, 0x00
elif battery_level < _Battery.APPROX.full:
elif battery_level < BatteryLevelApproximation.FULL:
# 2 greens
v1, v2 = 0x20, 0x02
else:
@ -151,12 +186,12 @@ class Hidpp10:
# turn off all leds
v1, v2 = 0x11, 0x11
write_register(device, REGISTERS.three_leds, v1, v2)
write_register(device, Registers.THREE_LEDS, v1, v2)
def get_notification_flags(self, device):
return self._get_register(device, REGISTERS.notifications)
def get_notification_flags(self, device: Device):
return self._get_register(device, Registers.NOTIFICATIONS)
def set_notification_flags(self, 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,
@ -166,15 +201,15 @@ 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, _int2bytes(flag_bits, 3))
result = write_register(device, Registers.NOTIFICATIONS, common.int2bytes(flag_bits, 3))
return result is not None
def get_device_features(self, device):
return self._get_register(device, REGISTERS.mouse_button_flags)
def get_device_features(self, device: Device):
return self._get_register(device, Registers.MOUSE_BUTTON_FLAGS)
def _get_register(self, device, register):
def _get_register(self, device: Device, register: Registers | int):
assert device is not None
# Avoid a call if the device is not online,
@ -187,64 +222,64 @@ class Hidpp10:
flags = read_register(device, register)
if flags is not None:
assert len(flags) == 3
return _bytes2int(flags)
def get_configuration_pending_flags(self, receiver):
assert not receiver.isDevice
result = read_register(receiver, REGISTERS.devices_configuration)
if result is not None:
return ord(result[:1])
def set_configuration_pending_flags(self, receiver, devices):
assert not receiver.isDevice
result = write_register(receiver, REGISTERS.devices_configuration, devices)
return result is not None
return common.bytes2int(flags)
def parse_battery_status(register, reply):
if register == REGISTERS.battery_charge:
def parse_battery_status(register: Registers | int, reply) -> Battery | None:
def status_byte_to_charge(status_byte_: int) -> BatteryLevelApproximation:
if status_byte_ == 7:
charge_ = BatteryLevelApproximation.FULL
elif status_byte_ == 5:
charge_ = BatteryLevelApproximation.GOOD
elif status_byte_ == 3:
charge_ = BatteryLevelApproximation.LOW
elif status_byte_ == 1:
charge_ = BatteryLevelApproximation.CRITICAL
else:
# pure 'charging' notifications may come without a status
charge_ = BatteryLevelApproximation.EMPTY
return charge_
def status_byte_to_battery_status(status_byte_: int) -> BatteryStatus:
if status_byte_ == 0x30:
status_text_ = BatteryStatus.DISCHARGING
elif status_byte_ == 0x50:
status_text_ = BatteryStatus.RECHARGING
elif status_byte_ == 0x90:
status_text_ = BatteryStatus.FULL
else:
status_text_ = None
return status_text_
def charging_byte_to_status_text(charging_byte_: int) -> BatteryStatus:
if charging_byte_ == 0x00:
status_text_ = BatteryStatus.DISCHARGING
elif charging_byte_ & 0x21 == 0x21:
status_text_ = BatteryStatus.RECHARGING
elif charging_byte_ & 0x22 == 0x22:
status_text_ = BatteryStatus.FULL
else:
logger.warning("could not parse 0x07 battery status: %02X (level %02X)", charging_byte_, status_byte)
status_text_ = None
return status_text_
if register == Registers.BATTERY_CHARGE:
charge = ord(reply[:1])
status_byte = ord(reply[2:3]) & 0xF0
status_text = (
_Battery.STATUS.discharging
if status_byte == 0x30
else _Battery.STATUS.recharging
if status_byte == 0x50
else _Battery.STATUS.full
if status_byte == 0x90
else None
)
return _Battery(charge, None, status_text, None)
if register == REGISTERS.battery_status:
battery_status = status_byte_to_battery_status(status_byte)
return Battery(charge, None, battery_status, None)
if register == Registers.BATTERY_STATUS:
status_byte = ord(reply[:1])
charge = (
_Battery.APPROX.full
if status_byte == 7 # full
else _Battery.APPROX.good
if status_byte == 5 # good
else _Battery.APPROX.low
if status_byte == 3 # low
else _Battery.APPROX.critical
if status_byte == 1 # critical
# pure 'charging' notifications may come without a status
else _Battery.APPROX.empty
)
charging_byte = ord(reply[1:2])
if charging_byte == 0x00:
status_text = _Battery.STATUS.discharging
elif charging_byte & 0x21 == 0x21:
status_text = _Battery.STATUS.recharging
elif charging_byte & 0x22 == 0x22:
status_text = _Battery.STATUS.full
else:
logger.warning("could not parse 0x07 battery status: %02X (level %02X)", charging_byte, status_byte)
status_text = None
status_text = charging_byte_to_status_text(charging_byte)
charge = status_byte_to_charge(status_byte)
if charging_byte & 0x03 and status_byte == 0:
# some 'charging' notifications may come with no battery level information
charge = None
# Return None for next charge level and voltage as these are not in HID++ 1.0 spec
return _Battery(charge, None, status_text, None)
return Battery(charge, None, status_text, None)

View File

@ -14,13 +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 __future__ import annotations
from enum import Flag
from enum import IntEnum
from typing import List
from .common import NamedInts
#
# Constants - most of them as defined by the official Logitech HID++ 1.0
# documentation, some of them guessed.
#
"""HID constants for HID++ 1.0.
Most of them as defined by the official Logitech HID++ 1.0
documentation, some of them guessed.
"""
DEVICE_KIND = NamedInts(
unknown=0x00,
@ -39,129 +45,213 @@ 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
ERROR = NamedInts(
invalid_SubID__command=0x01,
invalid_address=0x02,
invalid_value=0x03,
connection_request_failed=0x04,
too_many_devices=0x05,
already_exists=0x06,
busy=0x07,
unknown_device=0x08,
resource_error=0x09,
request_unavailable=0x0A,
unsupported_parameter_value=0x0B,
wrong_pin_code=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, 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
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(flag_bits: int | None, fallback: str) -> str:
flag_names = []
if flag_bits is not None:
if flag_bits == 0:
flag_names = (fallback,)
else:
flag_names = NotificationFlag.flag_names(flag_bits)
return f"\n{' ':15}".join(sorted(flag_names))
class ErrorCode(IntEnum):
INVALID_SUB_ID_COMMAND = 0x01
INVALID_ADDRESS = 0x02
INVALID_VALUE = 0x03
CONNECTION_REQUEST_FAILED = 0x04
TOO_MANY_DEVICES = 0x05
ALREADY_EXISTS = 0x06
BUSY = 0x07
UNKNOWN_DEVICE = 0x08
RESOURCE_ERROR = 0x09
REQUEST_UNAVAILABLE = 0x0A
UNSUPPORTED_PARAMETER_VALUE = 0x0B
WRONG_PIN_CODE = 0x0C
class PairingError(IntEnum):
DEVICE_TIMEOUT = 0x01
DEVICE_NOT_SUPPORTED = 0x02
TOO_MANY_DEVICES = 0x03
SEQUENCE_TIMEOUT = 0x06
class BoltPairingError(IntEnum):
DEVICE_TIMEOUT = 0x01
FAILED = 0x02
class Registers(IntEnum):
"""Known HID registers.
Devices usually have a (small) sub-set of these. Some registers are only
applicable to certain device kinds (e.g. smooth_scroll only applies to mice).
"""
# Generally applicable
NOTIFICATIONS = 0x00
FIRMWARE = 0xF1
PAIRING_ERRORS = NamedInts(device_timeout=0x01, device_not_supported=0x02, too_many_devices=0x03, sequence_timeout=0x06)
BOLT_PAIRING_ERRORS = NamedInts(device_timeout=0x01, failed=0x02)
"""Known registers.
Devices usually have a (small) sub-set of these. Some registers are only
applicable to certain device kinds (e.g. smooth_scroll only applies to mice."""
REGISTERS = NamedInts(
# only apply to receivers
receiver_connection=0x02,
receiver_pairing=0xB2,
devices_activity=0x2B3,
receiver_info=0x2B5,
bolt_device_discovery=0xC0,
bolt_pairing=0x2C1,
bolt_uniqueId=0x02FB,
# only apply to devices
mouse_button_flags=0x01,
keyboard_hand_detection=0x01,
devices_configuration=0x03,
battery_status=0x07,
keyboard_fn_swap=0x09,
battery_charge=0x0D,
keyboard_illumination=0x17,
three_leds=0x51,
mouse_dpi=0x63,
# apply to both
notifications=0x00,
firmware=0xF1,
# notifications
passkey_request_notification=0x4D,
passkey_pressed_notification=0x4E,
device_discovery_notification=0x4F,
discovery_status_notification=0x53,
pairing_status_notification=0x54,
)
# 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,
)
RECEIVER_CONNECTION = 0x02
RECEIVER_PAIRING = 0xB2
DEVICES_ACTIVITY = 0x2B3
RECEIVER_INFO = 0x2B5
BOLT_DEVICE_DISCOVERY = 0xC0
BOLT_PAIRING = 0x2C1
BOLT_UNIQUE_ID = 0x02FB
# 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,
)
# only apply to devices
MOUSE_BUTTON_FLAGS = 0x01
KEYBOARD_HAND_DETECTION = 0x01
DEVICES_CONFIGURATION = 0x03
BATTERY_STATUS = 0x07
KEYBOARD_FN_SWAP = 0x09
BATTERY_CHARGE = 0x0D
KEYBOARD_ILLUMINATION = 0x17
THREE_LEDS = 0x51
MOUSE_DPI = 0x63
# notifications
PASSKEY_REQUEST_NOTIFICATION = 0x4D
PASSKEY_PRESSED_NOTIFICATION = 0x4E
DEVICE_DISCOVERY_NOTIFICATION = 0x4F
DISCOVERY_STATUS_NOTIFICATION = 0x53
PAIRING_STATUS_NOTIFICATION = 0x54
# Subregisters for receiver_info register
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
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

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,8 @@
## 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 IntEnum
from enum import IntFlag
from .common import NamedInts
@ -25,226 +27,252 @@ from .common import NamedInts
A particular device might not support all these features, and may support other
unknown features as well.
"""
FEATURE = NamedInts(
ROOT=0x0000,
FEATURE_SET=0x0001,
FEATURE_INFO=0x0002,
# Common
DEVICE_FW_VERSION=0x0003,
DEVICE_UNIT_ID=0x0004,
DEVICE_NAME=0x0005,
DEVICE_GROUPS=0x0006,
DEVICE_FRIENDLY_NAME=0x0007,
KEEP_ALIVE=0x0008,
CONFIG_CHANGE=0x0020,
CRYPTO_ID=0x0021,
TARGET_SOFTWARE=0x0030,
WIRELESS_SIGNAL_STRENGTH=0x0080,
DFUCONTROL_LEGACY=0x00C0,
DFUCONTROL_UNSIGNED=0x00C1,
DFUCONTROL_SIGNED=0x00C2,
DFUCONTROL=0x00C3,
DFU=0x00D0,
BATTERY_STATUS=0x1000,
BATTERY_VOLTAGE=0x1001,
UNIFIED_BATTERY=0x1004,
CHARGING_CONTROL=0x1010,
LED_CONTROL=0x1300,
FORCE_PAIRING=0x1500,
GENERIC_TEST=0x1800,
DEVICE_RESET=0x1802,
OOBSTATE=0x1805,
CONFIG_DEVICE_PROPS=0x1806,
CHANGE_HOST=0x1814,
HOSTS_INFO=0x1815,
BACKLIGHT=0x1981,
BACKLIGHT2=0x1982,
BACKLIGHT3=0x1983,
ILLUMINATION=0x1990,
PRESENTER_CONTROL=0x1A00,
SENSOR_3D=0x1A01,
REPROG_CONTROLS=0x1B00,
REPROG_CONTROLS_V2=0x1B01,
REPROG_CONTROLS_V2_2=0x1B02, # LogiOptions 2.10.73 features.xml
REPROG_CONTROLS_V3=0x1B03,
REPROG_CONTROLS_V4=0x1B04,
REPORT_HID_USAGE=0x1BC0,
PERSISTENT_REMAPPABLE_ACTION=0x1C00,
WIRELESS_DEVICE_STATUS=0x1D4B,
REMAINING_PAIRING=0x1DF0,
FIRMWARE_PROPERTIES=0x1F1F,
ADC_MEASUREMENT=0x1F20,
# Mouse
LEFT_RIGHT_SWAP=0x2001,
SWAP_BUTTON_CANCEL=0x2005,
POINTER_AXIS_ORIENTATION=0x2006,
VERTICAL_SCROLLING=0x2100,
SMART_SHIFT=0x2110,
SMART_SHIFT_ENHANCED=0x2111,
HI_RES_SCROLLING=0x2120,
HIRES_WHEEL=0x2121,
LOWRES_WHEEL=0x2130,
THUMB_WHEEL=0x2150,
MOUSE_POINTER=0x2200,
ADJUSTABLE_DPI=0x2201,
EXTENDED_ADJUSTABLE_DPI=0x2202,
POINTER_SPEED=0x2205,
ANGLE_SNAPPING=0x2230,
SURFACE_TUNING=0x2240,
XY_STATS=0x2250,
WHEEL_STATS=0x2251,
HYBRID_TRACKING=0x2400,
# Keyboard
FN_INVERSION=0x40A0,
NEW_FN_INVERSION=0x40A2,
K375S_FN_INVERSION=0x40A3,
ENCRYPTION=0x4100,
LOCK_KEY_STATE=0x4220,
SOLAR_DASHBOARD=0x4301,
KEYBOARD_LAYOUT=0x4520,
KEYBOARD_DISABLE_KEYS=0x4521,
KEYBOARD_DISABLE_BY_USAGE=0x4522,
DUALPLATFORM=0x4530,
MULTIPLATFORM=0x4531,
KEYBOARD_LAYOUT_2=0x4540,
CROWN=0x4600,
# Touchpad
TOUCHPAD_FW_ITEMS=0x6010,
TOUCHPAD_SW_ITEMS=0x6011,
TOUCHPAD_WIN8_FW_ITEMS=0x6012,
TAP_ENABLE=0x6020,
TAP_ENABLE_EXTENDED=0x6021,
CURSOR_BALLISTIC=0x6030,
TOUCHPAD_RESOLUTION=0x6040,
TOUCHPAD_RAW_XY=0x6100,
TOUCHMOUSE_RAW_POINTS=0x6110,
TOUCHMOUSE_6120=0x6120,
GESTURE=0x6500,
GESTURE_2=0x6501,
# Gaming Devices
GKEY=0x8010,
MKEYS=0x8020,
MR=0x8030,
BRIGHTNESS_CONTROL=0x8040,
REPORT_RATE=0x8060,
EXTENDED_ADJUSTABLE_REPORT_RATE=0x8061,
COLOR_LED_EFFECTS=0x8070,
RGB_EFFECTS=0x8071,
PER_KEY_LIGHTING=0x8080,
PER_KEY_LIGHTING_V2=0x8081,
MODE_STATUS=0x8090,
ONBOARD_PROFILES=0x8100,
MOUSE_BUTTON_SPY=0x8110,
LATENCY_MONITORING=0x8111,
GAMING_ATTACHMENTS=0x8120,
FORCE_FEEDBACK=0x8123,
# Headsets
SIDETONE=0x8300,
EQUALIZER=0x8310,
HEADSET_OUT=0x8320,
# Fake features for Solaar internal use
MOUSE_GESTURE=0xFE00,
)
FEATURE._fallback = lambda x: f"unknown:{x:04X}"
FEATURE_FLAG = NamedInts(internal=0x20, hidden=0x40, obsolete=0x80)
class SupportedFeature(IntEnum):
ROOT = 0x0000
FEATURE_SET = 0x0001
FEATURE_INFO = 0x0002
# Common
DEVICE_FW_VERSION = 0x0003
DEVICE_UNIT_ID = 0x0004
DEVICE_NAME = 0x0005
DEVICE_GROUPS = 0x0006
DEVICE_FRIENDLY_NAME = 0x0007
KEEP_ALIVE = 0x0008
CONFIG_CHANGE = 0x0020
CRYPTO_ID = 0x0021
TARGET_SOFTWARE = 0x0030
WIRELESS_SIGNAL_STRENGTH = 0x0080
DFUCONTROL_LEGACY = 0x00C0
DFUCONTROL_UNSIGNED = 0x00C1
DFUCONTROL_SIGNED = 0x00C2
DFUCONTROL = 0x00C3
DFU = 0x00D0
BATTERY_STATUS = 0x1000
BATTERY_VOLTAGE = 0x1001
UNIFIED_BATTERY = 0x1004
CHARGING_CONTROL = 0x1010
LED_CONTROL = 0x1300
FORCE_PAIRING = 0x1500
GENERIC_TEST = 0x1800
DEVICE_RESET = 0x1802
OOBSTATE = 0x1805
CONFIG_DEVICE_PROPS = 0x1806
CHANGE_HOST = 0x1814
HOSTS_INFO = 0x1815
BACKLIGHT = 0x1981
BACKLIGHT2 = 0x1982
BACKLIGHT3 = 0x1983
ILLUMINATION = 0x1990
PRESENTER_CONTROL = 0x1A00
SENSOR_3D = 0x1A01
REPROG_CONTROLS = 0x1B00
REPROG_CONTROLS_V2 = 0x1B01
REPROG_CONTROLS_V2_2 = 0x1B02 # LogiOptions 2.10.73 features.xml
REPROG_CONTROLS_V3 = 0x1B03
REPROG_CONTROLS_V4 = 0x1B04
REPORT_HID_USAGE = 0x1BC0
PERSISTENT_REMAPPABLE_ACTION = 0x1C00
WIRELESS_DEVICE_STATUS = 0x1D4B
REMAINING_PAIRING = 0x1DF0
FIRMWARE_PROPERTIES = 0x1F1F
ADC_MEASUREMENT = 0x1F20
# Mouse
LEFT_RIGHT_SWAP = 0x2001
SWAP_BUTTON_CANCEL = 0x2005
POINTER_AXIS_ORIENTATION = 0x2006
VERTICAL_SCROLLING = 0x2100
SMART_SHIFT = 0x2110
SMART_SHIFT_ENHANCED = 0x2111
HI_RES_SCROLLING = 0x2120
HIRES_WHEEL = 0x2121
LOWRES_WHEEL = 0x2130
THUMB_WHEEL = 0x2150
MOUSE_POINTER = 0x2200
ADJUSTABLE_DPI = 0x2201
EXTENDED_ADJUSTABLE_DPI = 0x2202
POINTER_SPEED = 0x2205
ANGLE_SNAPPING = 0x2230
SURFACE_TUNING = 0x2240
XY_STATS = 0x2250
WHEEL_STATS = 0x2251
HYBRID_TRACKING = 0x2400
# Keyboard
FN_INVERSION = 0x40A0
NEW_FN_INVERSION = 0x40A2
K375S_FN_INVERSION = 0x40A3
ENCRYPTION = 0x4100
LOCK_KEY_STATE = 0x4220
SOLAR_DASHBOARD = 0x4301
KEYBOARD_LAYOUT = 0x4520
KEYBOARD_DISABLE_KEYS = 0x4521
KEYBOARD_DISABLE_BY_USAGE = 0x4522
DUALPLATFORM = 0x4530
MULTIPLATFORM = 0x4531
KEYBOARD_LAYOUT_2 = 0x4540
CROWN = 0x4600
# Touchpad
TOUCHPAD_FW_ITEMS = 0x6010
TOUCHPAD_SW_ITEMS = 0x6011
TOUCHPAD_WIN8_FW_ITEMS = 0x6012
TAP_ENABLE = 0x6020
TAP_ENABLE_EXTENDED = 0x6021
CURSOR_BALLISTIC = 0x6030
TOUCHPAD_RESOLUTION = 0x6040
TOUCHPAD_RAW_XY = 0x6100
TOUCHMOUSE_RAW_POINTS = 0x6110
TOUCHMOUSE_6120 = 0x6120
GESTURE = 0x6500
GESTURE_2 = 0x6501
# Gaming Devices
GKEY = 0x8010
MKEYS = 0x8020
MR = 0x8030
BRIGHTNESS_CONTROL = 0x8040
REPORT_RATE = 0x8060
EXTENDED_ADJUSTABLE_REPORT_RATE = 0x8061
COLOR_LED_EFFECTS = 0x8070
RGB_EFFECTS = 0x8071
PER_KEY_LIGHTING = 0x8080
PER_KEY_LIGHTING_V2 = 0x8081
MODE_STATUS = 0x8090
ONBOARD_PROFILES = 0x8100
MOUSE_BUTTON_SPY = 0x8110
LATENCY_MONITORING = 0x8111
GAMING_ATTACHMENTS = 0x8120
FORCE_FEEDBACK = 0x8123
# Headsets
SIDETONE = 0x8300
EQUALIZER = 0x8310
HEADSET_OUT = 0x8320
# Fake features for Solaar internal use
MOUSE_GESTURE = 0xFE00
def __str__(self):
return self.name.replace("_", " ")
class FeatureFlag(IntFlag):
"""Single bit flags."""
INTERNAL = 0x20
HIDDEN = 0x40
OBSOLETE = 0x80
DEVICE_KIND = NamedInts(
keyboard=0x00, remote_control=0x01, numpad=0x02, mouse=0x03, touchpad=0x04, trackball=0x05, presenter=0x06, receiver=0x07
keyboard=0x00,
remote_control=0x01,
numpad=0x02,
mouse=0x03,
touchpad=0x04,
trackball=0x05,
presenter=0x06,
receiver=0x07,
)
FIRMWARE_KIND = NamedInts(Firmware=0x00, Bootloader=0x01, Hardware=0x02, Other=0x03)
ONBOARD_MODES = NamedInts(MODE_NO_CHANGE=0x00, MODE_ONBOARD=0x01, MODE_HOST=0x02)
class OnboardMode(IntEnum):
MODE_NO_CHANGE = 0x00
MODE_ONBOARD = 0x01
MODE_HOST = 0x02
CHARGE_STATUS = NamedInts(charging=0x00, full=0x01, not_charging=0x02, error=0x07)
CHARGE_LEVEL = NamedInts(average=50, full=90, critical=5)
class ChargeLevel(IntEnum):
AVERAGE = 50
FULL = 90
CRITICAL = 5
CHARGE_TYPE = NamedInts(standard=0x00, fast=0x01, slow=0x02)
ERROR = NamedInts(
unknown=0x01,
invalid_argument=0x02,
out_of_range=0x03,
hardware_error=0x04,
logitech_internal=0x05,
invalid_feature_index=0x06,
invalid_function=0x07,
busy=0x08,
unsupported=0x09,
)
class ChargeType(IntEnum):
STANDARD = 0x00
FAST = 0x01
SLOW = 0x02
# Gesture Ids for feature GESTURE_2
GESTURE = NamedInts(
Tap1Finger=1, # task Left_Click
Tap2Finger=2, # task Right_Click
Tap3Finger=3,
Click1Finger=4, # task Left_Click
Click2Finger=5, # task Right_Click
Click3Finger=6,
DoubleTap1Finger=10,
DoubleTap2Finger=11,
DoubleTap3Finger=12,
Track1Finger=20, # action MovePointer
TrackingAcceleration=21,
TapDrag1Finger=30, # action Drag
TapDrag2Finger=31, # action SecondaryDrag
Drag3Finger=32,
TapGestures=33, # group all tap gestures under a single UI setting
FnClickGestureSuppression=34, # suppresses Tap and Edge gestures, toggled by Fn+Click
Scroll1Finger=40, # action ScrollOrPageXY / ScrollHorizontal
Scroll2Finger=41, # action ScrollOrPageXY / ScrollHorizontal
Scroll2FingerHoriz=42, # action ScrollHorizontal
Scroll2FingerVert=43, # action WheelScrolling
Scroll2FingerStateless=44,
NaturalScrolling=45, # affects native HID wheel reporting by gestures, not when diverted
Thumbwheel=46, # action WheelScrolling
VScrollInertia=48,
VScrollBallistics=49,
Swipe2FingerHoriz=50, # action PageScreen
Swipe3FingerHoriz=51, # action PageScreen
Swipe4FingerHoriz=52, # action PageScreen
Swipe3FingerVert=53,
Swipe4FingerVert=54,
LeftEdgeSwipe1Finger=60,
RightEdgeSwipe1Finger=61,
BottomEdgeSwipe1Finger=62,
TopEdgeSwipe1Finger=63,
LeftEdgeSwipe1Finger2=64, # task HorzScrollNoRepeatSet
RightEdgeSwipe1Finger2=65, # task 122 ??
BottomEdgeSwipe1Finger2=66, #
TopEdgeSwipe1Finger2=67, # task 121 ??
LeftEdgeSwipe2Finger=70,
RightEdgeSwipe2Finger=71,
BottomEdgeSwipe2Finger=72,
TopEdgeSwipe2Finger=73,
Zoom2Finger=80, # action Zoom
Zoom2FingerPinch=81, # ZoomBtnInSet
Zoom2FingerSpread=82, # ZoomBtnOutSet
Zoom3Finger=83,
Zoom2FingerStateless=84, # action Zoom
TwoFingersPresent=85,
Rotate2Finger=87,
Finger1=90,
Finger2=91,
Finger3=92,
Finger4=93,
Finger5=94,
Finger6=95,
Finger7=96,
Finger8=97,
Finger9=98,
Finger10=99,
DeviceSpecificRawData=100,
)
GESTURE._fallback = lambda x: f"unknown:{x:04X}"
# Param Ids for feature GESTURE_2
PARAM = NamedInts(
ExtraCapabilities=1, # not suitable for use
PixelZone=2, # 4 2-byte integers, left, bottom, width, height; pixels
RatioZone=3, # 4 bytes, left, bottom, width, height; unit 1/240 pad size
ScaleFactor=4, # 2-byte integer, with 256 as normal scale
)
PARAM._fallback = lambda x: f"unknown:{x:04X}"
class ErrorCode(IntEnum):
UNKNOWN = 0x01
INVALID_ARGUMENT = 0x02
OUT_OF_RANGE = 0x03
HARDWARE_ERROR = 0x04
LOGITECH_ERROR = 0x05
INVALID_FEATURE_INDEX = 0x06
INVALID_FUNCTION = 0x07
BUSY = 0x08
UNSUPPORTED = 0x09
class GestureId(IntEnum):
"""Gesture IDs for feature GESTURE_2."""
TAP_1_FINGER = 1 # task Left_Click
TAP_2_FINGER = 2 # task Right_Click
TAP_3_FINGER = 3
CLICK_1_FINGER = 4 # task Left_Click
CLICK_2_FINGER = 5 # task Right_Click
CLICK_3_FINGER = 6
DOUBLE_TAP_1_FINGER = 10
DOUBLE_TAP_2_FINGER = 11
DOUBLE_TAP_3_FINGER = 12
TRACK_1_FINGER = 20 # action MovePointer
TRACKING_ACCELERATION = 21
TAP_DRAG_1_FINGER = 30 # action Drag
TAP_DRAG_2_FINGER = 31 # action SecondaryDrag
DRAG_3_FINGER = 32
TAP_GESTURES = 33 # group all tap gestures under a single UI setting
FN_CLICK_GESTURE_SUPPRESSION = 34 # suppresses Tap and Edge gestures, toggled by Fn+Click
SCROLL_1_FINGER = 40 # action ScrollOrPageXY / ScrollHorizontal
SCROLL_2_FINGER = 41 # action ScrollOrPageXY / ScrollHorizontal
SCROLL_2_FINGER_HORIZONTAL = 42 # action ScrollHorizontal
SCROLL_2_FINGER_VERTICAL = 43 # action WheelScrolling
SCROLL_2_FINGER_STATELESS = 44
NATURAL_SCROLLING = 45 # affects native HID wheel reporting by gestures, not when diverted
THUMBWHEEL = (46,) # action WheelScrolling
V_SCROLL_INTERTIA = 48
V_SCROLL_BALLISTICS = 49
SWIPE_2_FINGER_HORIZONTAL = 50 # action PageScreen
SWIPE_3_FINGER_HORIZONTAL = 51 # action PageScreen
SWIPE_4_FINGER_HORIZONTAL = 52 # action PageScreen
SWIPE_3_FINGER_VERTICAL = 53
SWIPE_4_FINGER_VERTICAL = 54
LEFT_EDGE_SWIPE_1_FINGER = 60
RIGHT_EDGE_SWIPE_1_FINGER = 61
BOTTOM_EDGE_SWIPE_1_FINGER = 62
TOP_EDGE_SWIPE_1_FINGER = 63
LEFT_EDGE_SWIPE_1_FINGER_2 = 64 # task HorzScrollNoRepeatSet
RIGHT_EDGE_SWIPE_1_FINGER_2 = 65
BOTTOM_EDGE_SWIPE_1_FINGER_2 = 66
TOP_EDGE_SWIPE_1_FINGER_2 = 67
LEFT_EDGE_SWIPE_2_FINGER = 70
RIGHT_EDGE_SWIPE_2_FINGER = 71
BottomEdgeSwipe2Finger = 72
BOTTOM_EDGE_SWIPE_2_FINGER = 72
TOP_EDGE_SWIPE_2_FINGER = 73
ZOOM_2_FINGER = 80 # action Zoom
ZOOM_2_FINGER_PINCH = 81 # ZoomBtnInSet
ZOOM_2_FINGER_SPREAD = 82 # ZoomBtnOutSet
ZOOM_3_FINGER = 83
ZOOM_2_FINGER_STATELESS = 84
TWO_FINGERS_PRESENT = 85
ROTATE_2_FINGER = 87
FINGER_1 = 90
FINGER_2 = 91
FINGER_3 = 92
FINGER_4 = 93
FINGER_5 = 94
FINGER_6 = 95
FINGER_7 = 96
FINGER_8 = 97
FINGER_9 = 98
FINGER_10 = 99
DEVICE_SPECIFIC_RAW_DATA = 100
class ParamId(IntEnum):
"""Param Ids for feature GESTURE_2"""
EXTRA_CAPABILITIES = 1 # not suitable for use
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

View File

@ -16,10 +16,10 @@
# Translation support for the Logitech receivers library
import gettext as _gettext
import gettext
_ = _gettext.gettext
ngettext = _gettext.ngettext
_ = gettext.gettext
ngettext = gettext.ngettext
# A few common strings, not always accessible as such in the code.

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

@ -15,153 +15,106 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# Handles incoming events from the receiver/devices, updating the object as appropriate.
"""Handles incoming events from the receiver/devices, updating the
object as appropriate.
"""
from __future__ import annotations
import logging
import threading as _threading
from struct import unpack as _unpack
import struct
import threading
import typing
from solaar.i18n import _
from . import diversion as _diversion
from . import base
from . import common
from . import diversion
from . import hidpp10
from . import hidpp10_constants as _hidpp10_constants
from . import hidpp10_constants
from . import hidpp20
from . import hidpp20_constants as _hidpp20_constants
from . import settings_templates as _st
from .base import DJ_MESSAGE_ID as _DJ_MESSAGE_ID
from .common import ALERT as _ALERT
from .common import Battery as _Battery
from .common import strhex as _strhex
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()
_R = _hidpp10_constants.REGISTERS
_F = _hidpp20_constants.FEATURE
notification_lock = threading.Lock()
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, n):
# supposedly only 0x4x notifications arrive for the receiver
assert n.sub_id & 0x40 == 0x40
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,
}
if n.sub_id == 0x4A: # pairing lock notification
receiver.pairing.lock_open = bool(n.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(n.data[:1])
if pair_error:
receiver.pairing.error = error_string = _hidpp10_constants.PAIRING_ERRORS[pair_error]
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
return True
try:
handler_func = event_handler_mapping[notification.sub_id]
return handler_func(receiver, notification)
except KeyError:
pass
elif n.sub_id == _R.discovery_status_notification: # Bolt pairing
with notification_lock:
receiver.pairing.discovering = n.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(n.data[:1])
if discover_error:
receiver.pairing.error = discover_string = _hidpp10_constants.BOLT_PAIRING_ERRORS[discover_error]
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
receiver.changed(reason=reason)
return True
assert notification.sub_id in [
Notification.CONNECT_DISCONNECT,
Notification.DJ_PAIRING,
Notification.CONNECTED,
Notification.RAW_INPUT,
Notification.POWER,
]
elif n.sub_id == _R.device_discovery_notification: # Bolt pairing
with notification_lock:
counter = n.address + n.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 n.data[1] == 0:
receiver.pairing.device_kind = n.data[3]
receiver.pairing.device_address = n.data[6:12]
receiver.pairing.device_authentication = n.data[14]
elif n.data[1] == 1:
receiver.pairing.device_name = n.data[3 : 3 + n.data[2]].decode("utf-8")
return True
elif n.sub_id == _R.pairing_status_notification: # Bolt pairing
with notification_lock:
receiver.pairing.device_passkey = None
receiver.pairing.lock_open = n.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 = (
receiver.pairing.device_address
) = receiver.pairing.device_authentication = receiver.pairing.device_name = None
pair_error = n.data[0]
if receiver.pairing.lock_open:
receiver.pairing.new_device = None
elif n.address == 0x02 and not pair_error:
receiver.pairing.new_device = receiver.register_new_device(n.data[7])
if pair_error:
receiver.pairing.error = error_string = _hidpp10_constants.BOLT_PAIRING_ERRORS[pair_error]
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
return True
elif n.sub_id == _R.passkey_request_notification: # Bolt pairing
with notification_lock:
receiver.pairing.device_passkey = n.data[0:6].decode("utf-8")
return True
elif n.sub_id == _R.passkey_pressed_notification: # Bolt pairing
return True
logger.warning("%s: unhandled notification %s", receiver, n)
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 == 00: # no-op feature notification, dispose of it quickly
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 == _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
@ -170,85 +123,82 @@ 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 == 0x40:
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 == 0x41:
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 == 0x42:
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)
device.changed(active=connected, alert=_ALERT.NONE, reason=_("connected") if connected else _("disconnected"))
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 (_R.battery_status, _R.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 == 0x40: # 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:
del device.receiver[device.number]
device.changed(active=False, alert=_ALERT.ALL, reason=_("unpaired"))
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 == 0x41: # device connection (and disconnection)
flags = ord(n.data[:1]) & 0xF0
if n.address == 0x02: # very old 27 MHz protocol
wpid = "00" + _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 = _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)
@ -256,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,
@ -264,193 +214,299 @@ def _process_hidpp10_notification(device, n):
)
device.link_encrypted = link_encrypted
if not link_established and device.receiver:
_hidpp10.set_configuration_pending_flags(device.receiver, 0xFF)
hidpp10.set_configuration_pending_flags(device.receiver, 0xFF)
device.changed(active=link_established)
return True
if n.sub_id == 0x49:
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 == 0x4B: # power notification
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)
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, _strhex(n.data))
logger.debug(
"%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.ALL, reason=_("ADC measurement notification"), push=not old_present)
else: # this feature is also used to signal device becoming inactive
device.present = False # exception to device presence
device.changed(active=False)
else:
logger.warning("%s: unknown ADC MEASUREMENT %s", device, 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 = _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 = _Battery.STATUS.discharging
if n.address == 0x00:
device.set_battery_info(_Battery(charge, None, status_text, None))
elif n.address == 0x10:
status_text = BatteryStatus.DISCHARGING
if notification.address == 0x00:
device.set_battery_info(common.Battery(charge, None, status_text, None))
elif notification.address == 0x10:
if lux > 200:
status_text = _Battery.STATUS.recharging
device.set_battery_info(_Battery(charge, None, status_text, None, lux))
elif n.address == 0x20:
status_text = BatteryStatus.RECHARGING
device.set_battery_info(common.Battery(charge, None, status_text, None, lux))
elif notification.address == 0x20:
if logger.isEnabledFor(logging.DEBUG):
logger.debug("%s: Light Check button pressed", device)
device.changed(alert=_ALERT.SHOW_WINDOW)
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
alert = _ALERT.NONE
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 = _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, _st.Backlight2Level, [level])
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 = _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 = _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 = _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
if device.setting_callback:
device.setting_callback(device, _st.ScrollRatchet, [2 if ratchet else 1])
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 = _unpack("!H", n.data[:2])[0]
if notification.address == 0x00:
profile_sector = struct.unpack("!H", notification.data[:2])[0]
if profile_sector:
_st.profile_change(device, profile_sector)
elif n.address == 0x10:
resolution_index = _unpack("!B", n.data[:1])[0]
profile_sector = _unpack("!H", device.feature_request(_F.ONBOARD_PROFILES, 0x40)[:2])[0]
settings_templates.profile_change(device, profile_sector)
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:
device.setting_callback(device, _st.AdjustableDpi, [profile.resolutions[resolution_index]])
device.setting_callback(
device, settings_templates.AdjustableDpi, [profile.resolutions[resolution_index]]
)
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 = _unpack("!H", n.data[:2])[0]
device.setting_callback(device, _st.BrightnessControl, [brightness])
elif n.address == 0x10:
brightness = n.data[0] & 0x01
if notification.address == 0x00:
brightness = struct.unpack("!H", notification.data[:2])[0]
device.setting_callback(device, settings_templates.BrightnessControl, [brightness])
elif notification.address == 0x10:
brightness = notification.data[0] & 0x01
if brightness:
brightness = _unpack("!H", device.feature_request(_F.BRIGHTNESS_CONTROL, 0x10)[:2])[0]
device.setting_callback(device, _st.BrightnessControl, [brightness])
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:
receiver.pairing.error = error_string = hidpp10_constants.PairingError(pair_error).name
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
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).name
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).name
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

@ -15,30 +15,56 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import errno as _errno
from __future__ import annotations
import errno
import logging
import time
import typing
from dataclasses import dataclass
from typing import Callable
from typing import Optional
import hidapi as _hid
from typing import Protocol
from solaar.i18n import _
from solaar.i18n import ngettext
from . import base as _base
from . import exceptions
from . import hidpp10
from . import hidpp10_constants
from .common import ALERT
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()
_R = hidpp10_constants.REGISTERS
_IR = hidpp10_constants.INFO_SUBREGISTERS
class LowLevelInterface(Protocol):
def open_path(self, path):
...
def find_paired_node_wpid(self, receiver_path: str, index: int):
...
def ping(self, handle, number, long_message=False):
...
def request(self, handle, devnumber, request_id, *params, **kwargs):
...
def close(self, handle):
...
@dataclass
@ -57,16 +83,75 @@ 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.
"""
read_register: Callable = hidpp10.read_register
write_register: Callable = hidpp10.write_register
number = 0xFF
kind = None
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
def __init__(
self,
low_level: LowLevelInterface,
receiver_kind,
product_info,
handle,
path,
product_id,
setting_callback=None,
):
assert handle
self.low_level = low_level
self.isDevice = False # some devices act as receiver so we need a property to distinguish them
self.handle = handle
self.path = path
@ -85,15 +170,15 @@ class Receiver:
self.notification_flags = None
self.pairing = Pairing()
self.initialize(product_info)
_hidpp10.set_configuration_pending_flags(self, 0xFF)
hidpp10.set_configuration_pending_flags(self, 0xFF)
def initialize(self, product_info: dict):
# read the receiver information subregister, so we can find out max_devices
serial_reply = self.read_register(_R.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
@ -105,18 +190,18 @@ class Receiver:
if d:
d.close()
self._devices.clear()
return handle and _base.close(handle)
return handle and self.low_level.close(handle)
def __del__(self):
self.close()
def changed(self, alert=ALERT.NOTIFICATION, reason=None):
def changed(self, alert=Alert.NOTIFICATION, reason=None):
"""The status of the device had changed, so invoke the status callback"""
if self.status_callback is not None:
self.status_callback(self, alert=alert, reason=reason)
@property
def firmware(self):
def firmware(self) -> tuple[common.FirmwareInfo]:
if self._firmware is None and self.handle:
self._firmware = _hidpp10.get_firmware(self)
return self._firmware
@ -124,10 +209,9 @@ class Receiver:
# how many pairings remain (None for unknown, -1 for unlimited)
def remaining_pairings(self, cache=True):
if self._remaining_pairings is None or not cache:
ps = self.read_register(_R.receiver_connection)
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):
@ -137,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)
@ -146,30 +230,32 @@ 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(_R.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."""
if self.handle:
if not self.write_register(_R.receiver_connection, 0x02):
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:
@ -177,30 +263,31 @@ class Receiver:
polling_rate = ""
serial = None
power_switch = "(unknown)"
pair_info = self.read_register(_R.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(_R.receiver_info, 0x04) # undocumented
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(_R.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):
@ -208,7 +295,7 @@ class Receiver:
raise IndexError(f"{self}: device number {int(number)} already registered")
assert notification is None or notification.devnumber == number
assert notification is None or notification.sub_id == 0x41
assert notification is None or notification.sub_id == Notification.DJ_PAIRING
try:
time.sleep(0.05) # let receiver settle
@ -225,7 +312,7 @@ class Receiver:
logger.warning("mismatch on device kind %s %s", info["kind"], nkind)
else:
online = True
dev = Device(self, number, online, pairing_info=info, setting_callback=self.setting_callback)
dev = Device(self.low_level, self, number, online, pairing_info=info, setting_callback=self.setting_callback)
if logger.isEnabledFor(logging.INFO):
logger.info("%s: found new device %d (%s)", self, number, dev.wpid)
self._devices[number] = dev
@ -239,25 +326,24 @@ class Receiver:
def set_lock(self, lock_closed=True, device=0, timeout=0):
if self.handle:
action = 0x02 if lock_closed else 0x01
reply = self.write_register(_R.receiver_pairing, action, device, timeout)
reply = self.write_register(Registers.RECEIVER_PAIRING, action, device, timeout)
if reply:
return True
logger.warning("%s: failed to %s the receiver lock", self, "close" if lock_closed else "open")
def count(self):
count = self.read_register(_R.receiver_connection)
return 0 if count is None else ord(count[1:2])
count = self.read_register(Registers.RECEIVER_CONNECTION)
if count is None:
return 0
return extract_connection_count(count)
def request(self, request_id, *params):
if bool(self):
return _base.request(self.handle, 0xFF, request_id, *params)
return self.low_level.request(self.handle, 0xFF, request_id, *params)
def reset_pairing(self):
self.pairing = Pairing()
read_register = hidpp10.read_register
write_register = hidpp10.write_register
def __iter__(self):
connected_devices = self.count()
found_devices = 0
@ -325,7 +411,7 @@ class Receiver:
def _unpair_device_per_receiver(self, key):
"""Receiver specific unpairing."""
return self.write_register(_R.receiver_pairing, 0x03, key)
return self.write_register(Registers.RECEIVER_PAIRING, 0x03, key)
def __len__(self):
return len([d for d in self._devices.values() if d is not None])
@ -369,26 +455,26 @@ class Receiver:
class BoltReceiver(Receiver):
"""Bolt receivers use a different pairing prototol and have different pairing registers"""
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def initialize(self, product_info: dict):
serial_reply = self.read_register(_R.bolt_uniqueId)
self.serial = serial_reply.hex().upper()
serial_reply = self.read_register(Registers.BOLT_UNIQUE_ID)
self.serial = extract_serial(serial_reply)
self.max_devices = product_info.get("max_devices", 1)
def device_codename(self, n):
codename = self.read_register(_R.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(_R.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")
@ -397,7 +483,7 @@ class BoltReceiver(Receiver):
"""Discover Logitech Bolt devices."""
if self.handle:
action = 0x02 if cancel else 0x01
reply = self.write_register(_R.bolt_device_discovery, timeout, action)
reply = self.write_register(Registers.BOLT_DEVICE_DISCOVERY, timeout, action)
if reply:
return True
logger.warning("%s: failed to %s device discovery", self, "cancel" if cancel else "start")
@ -406,36 +492,36 @@ class BoltReceiver(Receiver):
"""Pair a Bolt device."""
if self.handle:
action = 0x01 if pair is True else 0x03 if pair is False else 0x02
reply = self.write_register(_R.bolt_pairing, action, slot, address, authentication, entropy)
reply = self.write_register(Registers.BOLT_PAIRING, action, slot, address, authentication, entropy)
if reply:
return True
logger.warning("%s: failed to %s device %s", self, "pair" if pair else "unpair", address)
def _unpair_device_per_receiver(self, key):
"""Receiver specific unpairing."""
return self.write_register(_R.bolt_pairing, 0x03, key)
return self.write_register(Registers.BOLT_PAIRING, 0x03, key)
class UnifyingReceiver(Receiver):
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class NanoReceiver(Receiver):
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class LightSpeedReceiver(Receiver):
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class Ex100Receiver(Receiver):
"""A very old style receiver, somewhat different from newer receivers"""
def __init__(self, receiver_kind, product_info, handle, path, product_id, setting_callback=None):
super().__init__(receiver_kind, product_info, handle, path, product_id, setting_callback)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def initialize(self, product_info: dict):
self.serial = None
@ -446,20 +532,21 @@ 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:
wpid = _hid.find_paired_node_wpid(self.path, number) # extract WPID from udev path
# extract WPID from udev path
wpid = self.low_level.find_paired_node_wpid(self.path, number)
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
@ -485,24 +572,33 @@ receiver_class_mapping = {
}
class ReceiverFactory:
@staticmethod
def create_receiver(device_info, setting_callback=None) -> Optional[Receiver]:
"""Opens a Logitech Receiver found attached to the machine, by Linux device path."""
def create_receiver(low_level: LowLevelInterface, device_info, setting_callback=None) -> Optional[Receiver]:
"""Opens a Logitech Receiver found attached to the machine, by Linux device path."""
try:
handle = _base.open_path(device_info.path)
if handle:
product_info = _base.product_information(device_info.product_id)
if not product_info:
logger.warning("Unknown receiver type: %s", device_info.product_id)
product_info = {}
kind = product_info.get("receiver_kind", "unknown")
rclass = receiver_class_mapping.get(kind, Receiver)
return rclass(kind, product_info, handle, device_info.path, device_info.product_id, setting_callback)
except OSError as e:
logger.exception("open %s", device_info)
if e.errno == _errno.EACCES:
raise
except Exception:
logger.exception("open %s", device_info)
try:
handle = low_level.open_path(device_info.path)
if handle:
usb_id = device_info.product_id
if isinstance(usb_id, str):
usb_id = int(usb_id, 16)
try:
product_info = low_level.product_information(usb_id)
except ValueError:
product_info = {}
kind = product_info.get("receiver_kind", "unknown")
rclass = receiver_class_mapping.get(kind, Receiver)
return rclass(
low_level,
kind,
product_info,
handle,
device_info.path,
device_info.product_id,
setting_callback,
)
except OSError as e:
logger.exception("open %s", device_info)
if e.errno == errno.EACCES:
raise
except Exception:
logger.exception("open %s", device_info)

View File

@ -13,54 +13,36 @@
## 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
import logging
import math
import struct
import time
from struct import unpack as _unpack
from time import sleep as _sleep
from enum import IntEnum
from typing import Any
from solaar.i18n import _
from . import hidpp20_constants as _hidpp20_constants
from .common import NamedInt as _NamedInt
from .common import NamedInts as _NamedInts
from .common import bytes2int as _bytes2int
from .common import int2bytes as _int2bytes
from . import common
from . import hidpp20_constants
from . import settings_validator
from .common import NamedInt
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, new):
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 Setting:
@ -105,15 +87,15 @@ 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:
return (self._validator.min_value, self._validator.max_value)
if self._validator.kind == Kind.RANGE:
return self._validator.min_value, self._validator.max_value
def _pre_read(self, cached, key=None):
if self.persist and self._value is None and getattr(self._device, "persister", None):
@ -295,7 +277,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
@ -613,9 +595,9 @@ class RangeFieldSetting(Setting):
class RegisterRW:
__slots__ = ("register",)
kind = _NamedInt(0x01, _("register"))
kind = NamedInt(0x01, _("register"))
def __init__(self, register):
def __init__(self, register: int):
assert isinstance(register, int)
self.register = register
@ -627,12 +609,21 @@ class RegisterRW:
class FeatureRW:
kind = _NamedInt(0x02, _("feature"))
kind = NamedInt(0x02, _("feature"))
default_read_fnid = 0x00
default_write_fnid = 0x10
def __init__(self, feature, read_fnid=0x00, write_fnid=0x10, prefix=b"", suffix=b"", read_prefix=b"", no_reply=False):
assert isinstance(feature, _NamedInt)
def __init__(
self,
feature: hidpp20_constants.SupportedFeature,
read_fnid=0x00,
write_fnid=0x10,
prefix=b"",
suffix=b"",
read_prefix=b"",
no_reply=False,
):
assert isinstance(feature, hidpp20_constants.SupportedFeature)
self.feature = feature
self.read_fnid = read_fnid
self.write_fnid = write_fnid
@ -643,7 +634,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
@ -653,20 +647,20 @@ class FeatureRW:
class FeatureRWMap(FeatureRW):
kind = _NamedInt(0x02, _("feature"))
kind = NamedInt(0x02, _("feature"))
default_read_fnid = 0x00
default_write_fnid = 0x10
default_key_byte_count = 1
def __init__(
self,
feature,
feature: hidpp20_constants.SupportedFeature,
read_fnid=default_read_fnid,
write_fnid=default_write_fnid,
key_byte_count=default_key_byte_count,
no_reply=False,
):
assert isinstance(feature, _NamedInt)
assert isinstance(feature, hidpp20_constants.SupportedFeature)
self.feature = feature
self.read_fnid = read_fnid
self.write_fnid = write_fnid
@ -675,717 +669,16 @@ class FeatureRWMap(FeatureRW):
def read(self, device, key):
assert self.feature is not None
key_bytes = _int2bytes(key, self.key_byte_count)
key_bytes = common.int2bytes(key, self.key_byte_count)
return device.feature_request(self.feature, self.read_fnid, key_bytes)
def write(self, device, key, data_bytes):
assert self.feature is not None
key_bytes = _int2bytes(key, self.key_byte_count)
key_bytes = common.int2bytes(key, self.key_byte_count)
reply = device.feature_request(self.feature, self.write_fnid, key_bytes, data_bytes, no_reply=self.no_reply)
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 = _bytes2int(true_value)
fv = _bytes2int(false_value)
mv = _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 = _bytes2int(self.mask)
reply_value = _bytes2int(reply_bytes[:count]) & mask
true_value = _bytes2int(self.true_value)
if reply_value == true_value:
return True
false_value = _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 = _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 _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(_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 _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 = _bytes2int(query[0:1])
b += (self.byte_count - len(b)) * b"\x00"
value = _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 [
_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 = _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 = _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 = _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 = _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 and 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: _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(_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 _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 = _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 = _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 += _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 = _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 += _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"""
@ -1418,13 +711,16 @@ class ActionSettingRW:
pass
def read(self, device): # need to return bytes, as if read from device
return _int2bytes(self.key.key, 2) if self.active and self.key else b"\x00\x00"
return common.int2bytes(self.key.key, 2) if self.active and self.key else b"\x00\x00"
def write(self, device, data_bytes):
def handler(device, n): # Called on notification events from the device
if n.sub_id < 0x40 and device.features.get_feature(n.sub_id) == _hidpp20_constants.FEATURE.REPROG_CONTROLS_V4:
if (
n.sub_id < 0x40
and device.features.get_feature(n.sub_id) == hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4
):
if n.address == 0x00:
cids = _unpack("!HHHH", n.data[:8])
cids = struct.unpack("!HHHH", n.data[:8])
if not self.pressed and int(self.key.key) in cids: # trigger key pressed
self.pressed = True
self.press_action()
@ -1438,7 +734,7 @@ class ActionSettingRW:
self.key_action(key)
elif n.address == 0x10:
if self.pressed:
dx, dy = _unpack("!hh", n.data[:4])
dx, dy = struct.unpack("!hh", n.data[:4])
self.move_action(dx, dy)
divertSetting = next(filter(lambda s: s.name == self.divert_setting_name, device.settings), None)
@ -1446,7 +742,7 @@ class ActionSettingRW:
logger.warning("setting %s not found on %s", self.divert_setting_name, device.name)
return None
self.device = device
key = _bytes2int(data_bytes)
key = common.bytes2int(data_bytes)
if key: # Enable
self.key = next((k for k in device.keys if k.key == key), None)
if self.key:
@ -1484,13 +780,13 @@ class RawXYProcessing:
self.keys = [] # the keys that can initiate processing
self.initiating_key = None # the key that did initiate processing
self.active = False
self.feature_offset = device.features[_hidpp20_constants.FEATURE.REPROG_CONTROLS_V4]
self.feature_offset = device.features[hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4]
assert self.feature_offset is not False
def handler(self, device, n): # Called on notification events from the device
if n.sub_id < 0x40 and device.features.get_feature(n.sub_id) == _hidpp20_constants.FEATURE.REPROG_CONTROLS_V4:
if n.sub_id < 0x40 and device.features.get_feature(n.sub_id) == hidpp20_constants.SupportedFeature.REPROG_CONTROLS_V4:
if n.address == 0x00:
cids = _unpack("!HHHH", n.data[:8])
cids = struct.unpack("!HHHH", n.data[:8])
## generalize to list of keys
if not self.initiating_key: # no initiating key pressed
for k in self.keys:
@ -1508,7 +804,7 @@ class RawXYProcessing:
self.key_action(key)
elif n.address == 0x10:
if self.initiating_key:
dx, dy = _unpack("!hh", n.data[:4])
dx, dy = struct.unpack("!hh", n.data[:4])
self.move_action(dx, dy)
def start(self, key):
@ -1556,8 +852,8 @@ class RawXYProcessing:
def apply_all_settings(device):
if device.features and _hidpp20_constants.FEATURE.HIRES_WHEEL in device.features:
_sleep(0.2) # delay to try to get out of race condition with Linux HID++ driver
if device.features and hidpp20_constants.SupportedFeature.HIRES_WHEEL in device.features:
time.sleep(0.2) # delay to try to get out of race condition with Linux HID++ driver
persister = getattr(device, "persister", None)
sensitives = persister.get("_sensitive", {}) if persister else {}
for s in device.settings:
@ -1566,4 +862,4 @@ def apply_all_settings(device):
s.apply()
Setting.validator_class = BooleanValidator
Setting.validator_class = settings_validator.BooleanValidator

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,744 @@
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):
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

View File

@ -15,20 +15,22 @@
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
# Reprogrammable keys information
# Mostly from Logitech documentation, but with some edits for better Lunix compatibility
# Mostly from Logitech documentation, but with some edits for better Linux compatibility
import os as _os
import os
import yaml as _yaml
from enum import IntEnum
from .common import NamedInts as _NamedInts
from .common import UnsortedNamedInts as _UnsortedNamedInts
import yaml
_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")
from .common import NamedInts
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")
# <controls.xml awk -F\" '/<Control /{sub(/^LD_FINFO_(CTRLID_)?/, "", $2);printf("\t%s=0x%04X,\n", $2, $4)}' | sort -t= -k2
CONTROL = _NamedInts(
CONTROL = NamedInts(
{
"Volume_Up": 0x0001,
"Volume_Down": 0x0002,
@ -314,290 +316,297 @@ CONTROL = _NamedInts(
)
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,
)
CID_GROUP_BIT = _NamedInts(g8=0x80, g7=0x40, g6=0x20, g5=0x10, g4=0x08, g3=0x04, g2=0x02, g1=0x01)
CID_GROUP = _NamedInts(g8=8, g7=7, g6=6, g5=5, g4=4, g3=3, g2=2, g1=1)
DISABLE = _NamedInts(
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
CIRCLE = 0x01A3
TRIANGLE = 0x01A4
DIAMOND = 0x01A5
STAR = 0x01A6
def __str__(self):
return self.name.replace("_", " ").title()
class CIDGroupBit(IntEnum):
g1 = 0x01
g2 = 0x02
g3 = 0x04
g4 = 0x08
g5 = 0x10
g6 = 0x20
g7 = 0x40
g8 = 0x80
class CidGroup(IntEnum):
g1 = 1
g2 = 2
g3 = 3
g4 = 4
g5 = 5
g6 = 6
g7 = 7
g8 = 8
DISABLE = NamedInts(
Caps_Lock=0x01,
Num_Lock=0x02,
Scroll_Lock=0x04,
@ -608,7 +617,7 @@ DISABLE._fallback = lambda x: f"unknown:{x:02X}"
# HID USB Keycodes from https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf
# Modified by information from Linux HID driver linux/drivers/hid/hid-input.c
USB_HID_KEYCODES = _NamedInts(
USB_HID_KEYCODES = NamedInts(
A=0x04,
B=0x05,
C=0x06,
@ -780,7 +789,7 @@ USB_HID_KEYCODES[0x26] = "9"
USB_HID_KEYCODES[0x27] = "0"
USB_HID_KEYCODES[0x64] = "102ND"
HID_CONSUMERCODES = _NamedInts(
HID_CONSUMERCODES = NamedInts(
{
# Unassigned=0x00,
# Consumer_Control=0x01,
@ -1167,9 +1176,9 @@ HID_CONSUMERCODES._fallback = lambda x: f"unknown:{x:04X}"
## Information for x1c00 Persistent from https://drive.google.com/drive/folders/0BxbRzx7vEV7eWmgwazJ3NUFfQ28
KEYMOD = _NamedInts(CTRL=0x01, SHIFT=0x02, ALT=0x04, META=0x08, RCTRL=0x10, RSHIFT=0x20, RALT=0x40, RMETA=0x80)
KEYMOD = NamedInts(CTRL=0x01, SHIFT=0x02, ALT=0x04, META=0x08, RCTRL=0x10, RSHIFT=0x20, RALT=0x40, RMETA=0x80)
ACTIONID = _NamedInts(
ACTIONID = NamedInts(
Empty=0x00,
Key=0x01,
Mouse=0x02,
@ -1182,7 +1191,7 @@ ACTIONID = _NamedInts(
Power=0x09,
)
MOUSE_BUTTONS = _NamedInts(
MOUSE_BUTTONS = NamedInts(
Mouse_Button_Left=0x0001,
Mouse_Button_Right=0x0002,
Mouse_Button_Middle=0x0004,
@ -1202,14 +1211,14 @@ 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()
KEYS = UnsortedNamedInts()
KEYS_Default = 0x7FFFFFFF # Special value to reset key to default - has to be different from all others
KEYS[KEYS_Default] = "Default" # Value to reset to default
KEYS[0] = "None" # Value for no output
@ -1241,13 +1250,13 @@ 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)
# Construct subsets for known devices
def persistent_keys(action_ids):
keys = _UnsortedNamedInts()
keys = UnsortedNamedInts()
keys[KEYS_Default] = "Default" # Value to reset to default
keys[0] = "No Output (only as default)"
for key in KEYS:
@ -1259,7 +1268,7 @@ def persistent_keys(action_ids):
KEYS_KEYS_CONSUMER = persistent_keys([ACTIONID.Key, ACTIONID.Consumer])
KEYS_KEYS_MOUSE_HSCROLL = persistent_keys([ACTIONID.Key, ACTIONID.Mouse, ACTIONID.Hscroll])
COLORS = _UnsortedNamedInts(
COLORS = UnsortedNamedInts(
{
# from Xorg rgb.txt,v 1.3 2000/08/17
"red": 0xFF0000,
@ -1400,11 +1409,11 @@ COLORS = _UnsortedNamedInts(
}
)
COLORSPLUS = _UnsortedNamedInts({"No change": -1})
COLORSPLUS = UnsortedNamedInts({"No change": -1})
for i in COLORS:
COLORSPLUS[int(i)] = str(i)
KEYCODES = _NamedInts(
KEYCODES = NamedInts(
{
"A": 1,
"B": 2,
@ -1529,11 +1538,11 @@ KEYCODES = _NamedInts(
# load in override dictionary for KEYCODES
try:
if _os.path.isfile(_keys_file_path):
if os.path.isfile(_keys_file_path):
with open(_keys_file_path) as keys_file:
keys = _yaml.safe_load(keys_file)
keys = yaml.safe_load(keys_file)
if isinstance(keys, dict):
keys = _NamedInts(**keys)
keys = NamedInts(**keys)
for k in KEYCODES:
if int(k) not in keys and str(k) not in keys:
keys[int(k)] = str(k)

View File

@ -14,20 +14,28 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import pkgutil as _pkgutil
import subprocess as _subprocess
import sys as _sys
import pkgutil
import subprocess
import sys
NAME = "Solaar"
try:
__version__ = (
_subprocess.check_output(["git", "describe", "--always"], cwd=_sys.path[0], stderr=_subprocess.DEVNULL)
subprocess.check_output(
[
"git",
"describe",
"--always",
],
cwd=sys.path[0],
stderr=subprocess.DEVNULL,
)
.strip()
.decode()
)
except Exception:
try:
__version__ = _pkgutil.get_data("solaar", "commit").strip().decode()
__version__ = pkgutil.get_data("solaar", "commit").strip().decode()
except Exception:
__version__ = _pkgutil.get_data("solaar", "version").strip().decode()
__version__ = pkgutil.get_data("solaar", "version").strip().decode()

View File

@ -14,34 +14,30 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import argparse as _argparse
import argparse
import logging
import sys as _sys
import sys
from importlib import import_module
from traceback import extract_tb
from traceback import format_exc
import logitech_receiver.device as _device
import logitech_receiver.receiver as _receiver
from logitech_receiver.base import receivers
from logitech_receiver.base import receivers_and_devices
from logitech_receiver import base
from logitech_receiver import device
from logitech_receiver import receiver
from solaar import NAME
logger = logging.getLogger(__name__)
#
#
#
def _create_parser():
parser = _argparse.ArgumentParser(
prog=NAME.lower(), add_help=False, epilog=f"For details on individual actions, run `{NAME.lower()} <action> --help`."
parser = argparse.ArgumentParser(
prog=NAME.lower(),
add_help=False,
epilog=f"For details on individual actions, run `{NAME.lower()} <action> --help`.",
)
subparsers = parser.add_subparsers(title="actions", help="optional action to perform")
subparsers = parser.add_subparsers(title="actions", help="command-line action to perform")
sp = subparsers.add_parser("show", help="show information about devices")
sp.add_argument(
@ -59,7 +55,11 @@ def _create_parser():
)
sp.set_defaults(action="probe")
sp = subparsers.add_parser("profiles", help="read or write onboard profiles", epilog="Only works on active devices.")
sp = subparsers.add_parser(
"profiles",
help="read or write onboard 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, "
@ -108,37 +108,35 @@ print_help = _cli_parser.print_help
def _receivers(dev_path=None):
for dev_info in receivers():
for dev_info in base.receivers():
if dev_path is not None and dev_path != dev_info.path:
continue
try:
r = _receiver.ReceiverFactory.create_receiver(dev_info)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("[%s] => %s", dev_info.path, r)
r = receiver.create_receiver(base, dev_info)
logger.debug("[%s] => %s", dev_info.path, r)
if r:
yield r
except Exception as e:
logger.exception("opening " + str(dev_info))
_sys.exit(f"{NAME.lower()}: error: {str(e)}")
sys.exit(f"{NAME.lower()}: error: {str(e)}")
def _receivers_and_devices(dev_path=None):
for dev_info in receivers_and_devices():
for dev_info in base.receivers_and_devices():
if dev_path is not None and dev_path != dev_info.path:
continue
try:
if dev_info.isDevice:
d = _device.DeviceFactory.create_device(dev_info)
d = device.create_device(base, dev_info)
else:
d = _receiver.ReceiverFactory.create_receiver(dev_info)
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:
logger.exception("opening " + str(dev_info))
_sys.exit(f"{NAME.lower()}: error: {str(e)}")
sys.exit(f"{NAME.lower()}: error: {str(e)}")
def _find_receiver(receivers, name):
@ -190,9 +188,6 @@ def _find_device(receivers, name):
break
# raise Exception("no device found matching '%s'" % name)
def run(cli_args=None, hidraw_path=None):
if cli_args:
action = cli_args[0]
@ -202,9 +197,9 @@ def run(cli_args=None, hidraw_path=None):
# Python 3 has an undocumented 'feature' that breaks parsing empty args
# http://bugs.python.org/issue16308
if "cmd" not in args:
_cli_parser.print_usage(_sys.stderr)
_sys.stderr.write(f"{NAME.lower()}: error: too few arguments\n")
_sys.exit(2)
_cli_parser.print_usage(sys.stderr)
sys.stderr.write(f"{NAME.lower()}: error: too few arguments\n")
sys.exit(2)
action = args.action
assert action in actions
@ -215,12 +210,12 @@ def run(cli_args=None, hidraw_path=None):
c = list(_receivers(hidraw_path))
if not c:
raise Exception(
'No supported device found. Use "lsusb" and "bluetoothctl devices Connected" to list connected devices.'
'No supported device found. Use "lsusb" and "bluetoothctl devices Connected" to list connected devices.'
)
m = import_module("." + action, package=__name__)
m.run(c, args, _find_receiver, _find_device)
except AssertionError:
tb_last = extract_tb(_sys.exc_info()[2])[-1]
_sys.exit(f"{NAME.lower()}: assertion failed: {tb_last[0]} line {int(tb_last[1])}")
tb_last = extract_tb(sys.exc_info()[2])[-1]
sys.exit(f"{NAME.lower()}: assertion failed: {tb_last[0]} line {int(tb_last[1])}")
except Exception:
_sys.exit(f"{NAME.lower()}: error: {format_exc()}")
sys.exit(f"{NAME.lower()}: error: {format_exc()}")

View File

@ -14,13 +14,16 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import yaml as _yaml
import yaml
from logitech_receiver import settings as _settings
from logitech_receiver import settings_templates as _settings_templates
from logitech_receiver.common import NamedInts as _NamedInts
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 as _configuration
from solaar import configuration
APP_ID = "io.github.pwr_solaar.solaar"
def _print_setting(s, verbose=True):
@ -28,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),
@ -51,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)")
@ -62,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)")
@ -92,7 +95,7 @@ def select_choice(value, choices, setting, key):
break
if val is not None:
value = val
elif ivalue is not None and ivalue >= 1 and ivalue <= len(choices):
elif ivalue is not None and 1 <= ivalue <= len(choices):
value = choices[ivalue - 1]
elif lvalue in ("higher", "lower"):
old_value = setting.read() if key is None else setting.read_key(key)
@ -134,13 +137,13 @@ def select_range(value, setting):
value = int(value)
except ValueError as exc:
raise Exception(f"{setting.name}: can't interpret '{value}' as integer") from exc
min, max = setting.range
if value < min or value > max:
minimum, maximum = setting.range
if value < minimum or value > maximum:
raise Exception(f"{setting.name}: value '{value}' out of bounds")
return value
def run(receivers, args, find_receiver, find_device):
def run(receivers, args, _find_receiver, find_device):
assert receivers
assert args.device
@ -158,8 +161,7 @@ def run(receivers, args, find_receiver, find_device):
if not args.setting: # print all settings, so first set them all up
if not dev.settings:
raise Exception(f"no settings for {dev.name}")
_configuration.attach_to(dev)
# _settings.apply_all_settings(dev)
configuration.attach_to(dev)
print(dev.name, f"({dev.codename}) [{dev.wpid}:{dev.serial}]")
for s in dev.settings:
print("")
@ -167,7 +169,7 @@ def run(receivers, args, find_receiver, find_device):
return
setting_name = args.setting.lower()
setting = _settings_templates.check_feature_setting(dev, setting_name)
setting = settings_templates.check_feature_setting(dev, setting_name)
if not setting and dev.descriptor and dev.descriptor.settings:
for sclass in dev.descriptor.settings:
if sclass.register and sclass.name == setting_name:
@ -179,7 +181,6 @@ def run(receivers, args, find_receiver, find_device):
raise Exception(f"no setting '{args.setting}' for {dev.name}")
if args.value_key is None:
# setting.apply()
_print_setting(setting)
return
@ -192,7 +193,6 @@ def run(receivers, args, find_receiver, find_device):
from gi.repository import Gtk
if Gtk.init_check()[0]: # can Gtk be initialized?
APP_ID = "io.github.pwr_solaar.solaar"
application = Gtk.Application.new(APP_ID, Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
application.register()
remote = application.get_is_remote()
@ -210,35 +210,35 @@ 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))
application.run(yaml.safe_dump(argl))
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)
return None, None, None
key = args.value_key
ikey = to_int(key)
k = next((k for k in setting.choices.keys() if key == k), None)
@ -253,13 +253,13 @@ 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)
return None, None, None
key = args.value_key
all_keys = getattr(setting, "choices_universe", None)
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, _NamedInts) else to_int(key)
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, NamedInts) else to_int(key)
k = next((k for k in setting._labels if key == k), None)
if k is None and ikey is not None:
k = next((k for k in setting._labels if ikey == k), None)
@ -272,12 +272,12 @@ 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
all_keys = getattr(setting, "choices_universe", None)
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, _NamedInts) else to_int(key)
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, NamedInts) else to_int(key)
if args.extra2 is None or to_int(args.extra2) is None:
raise Exception(f"{setting.name}: setting needs an integer value, not {args.extra2}")
if not setting._value: # ensure that there are values to look through

View File

@ -14,15 +14,14 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from time import time as _timestamp
from time import time
from logitech_receiver import base as _base
from logitech_receiver import base
from logitech_receiver import hidpp10
from logitech_receiver import hidpp10_constants as _hidpp10_constants
from logitech_receiver import notifications as _notifications
from logitech_receiver import hidpp10_constants
from logitech_receiver import notifications
_hidpp10 = hidpp10.Hidpp10()
_R = _hidpp10_constants.REGISTERS
def run(receivers, args, find_receiver, _ignore):
@ -40,8 +39,8 @@ def run(receivers, args, find_receiver, _ignore):
# 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)
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]
@ -51,8 +50,8 @@ def run(receivers, args, find_receiver, _ignore):
nonlocal known_devices
assert n
if n.devnumber == 0xFF:
_notifications.process(receiver, n)
elif n.sub_id == 0x41 and len(n.data) == _base._SHORT_MESSAGE_SIZE - 4:
notifications.process(receiver, n)
elif n.sub_id == 0x41 and len(n.data) == base.SHORT_MESSAGE_SIZE - 4:
kd, known_devices = known_devices, None # only process one connection notification
if kd is not None:
if n.devnumber not in kd:
@ -67,13 +66,13 @@ def run(receivers, args, find_receiver, _ignore):
if receiver.receiver_kind == "bolt": # Bolt receivers require authentication to pair a device
receiver.discover(timeout=timeout)
print("Bolt Pairing: long-press the pairing key or button on your device (timing out in", timeout, "seconds).")
pairing_start = _timestamp()
pairing_start = time()
patience = 5 # the discovering notification may come slightly later, so be patient
while receiver.pairing.discovering or _timestamp() - pairing_start < patience:
while receiver.pairing.discovering or time() - pairing_start < patience:
if receiver.pairing.device_address and receiver.pairing.device_authentication and receiver.pairing.device_name:
break
n = _base.read(receiver.handle)
n = _base.make_notification(*n) if n else None
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
address = receiver.pairing.device_address
@ -84,15 +83,15 @@ def run(receivers, args, find_receiver, _ignore):
receiver.pair_device(
address=address,
authentication=authentication,
entropy=20 if kind == _hidpp10_constants.DEVICE_KIND.keyboard else 10,
entropy=20 if kind == hidpp10_constants.DEVICE_KIND.keyboard else 10,
)
pairing_start = _timestamp()
pairing_start = time()
patience = 5 # the discovering notification may come slightly later, so be patient
while receiver.pairing.lock_open or _timestamp() - pairing_start < patience:
while receiver.pairing.lock_open or time() - pairing_start < patience:
if receiver.pairing.device_passkey:
break
n = _base.read(receiver.handle)
n = _base.make_notification(*n) if n else None
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
if authentication & 0x01:
@ -103,24 +102,26 @@ def run(receivers, args, find_receiver, _ignore):
print(f"Bolt Pairing: press {passkey}")
print("and then press left and right buttons simultaneously")
while receiver.pairing.lock_open:
n = _base.read(receiver.handle)
n = _base.make_notification(*n) if n else None
n = base.read(receiver.handle)
n = base.make_notification(*n) if n else None
if n:
receiver.handle.notifications_hook(n)
else:
receiver.set_lock(False, timeout=timeout)
print("Pairing: turn your new device on (timing out in", timeout, "seconds).")
pairing_start = _timestamp()
print("Pairing: Turn your device on or press, hold, and release")
print("a channel button or the channel switch button.")
print("Timing out in", timeout, "seconds.")
pairing_start = time()
patience = 5 # the lock-open notification may come slightly later, wait for it a bit
while receiver.pairing.lock_open or _timestamp() - pairing_start < patience:
n = _base.read(receiver.handle)
while receiver.pairing.lock_open or time() - pairing_start < patience:
n = base.read(receiver.handle)
if n:
n = _base.make_notification(*n)
n = base.make_notification(*n)
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

@ -14,15 +14,14 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from logitech_receiver import base as _base
from logitech_receiver import hidpp10_constants as _hidpp10_constants
from logitech_receiver.common import strhex as _strhex
from logitech_receiver import base
from logitech_receiver.common import strhex
from logitech_receiver.hidpp10_constants import ErrorCode
from logitech_receiver.hidpp10_constants import Registers
from solaar.cli.show import _print_device
from solaar.cli.show import _print_receiver
_R = _hidpp10_constants.REGISTERS
def run(receivers, args, find_receiver, _ignore):
assert receivers
@ -45,37 +44,42 @@ def run(receivers, args, find_receiver, _ignore):
print("")
print(" Register Dump")
rgst = receiver.read_register(_R.notifications)
print(" Notifications %#04x: %s" % (_R.notifications % 0x100, "0x" + _strhex(rgst) if rgst else "None"))
rgst = receiver.read_register(_R.receiver_connection)
print(" Connection State %#04x: %s" % (_R.receiver_connection % 0x100, "0x" + _strhex(rgst) if rgst else "None"))
rgst = receiver.read_register(_R.devices_activity)
print(" Device Activity %#04x: %s" % (_R.devices_activity % 0x100, "0x" + _strhex(rgst) if rgst else "None"))
rgst = receiver.read_register(Registers.NOTIFICATIONS)
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, f"0x{strhex(rgst)}" if rgst else "None")
)
rgst = receiver.read_register(Registers.DEVICES_ACTIVITY)
print(
" 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(_R.receiver_info, sub_reg)
rgst = receiver.read_register(Registers.RECEIVER_INFO, sub_reg)
print(
" Pairing Register %#04x %#04x: %s"
% (_R.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(_R.receiver_info, sub_reg + device)
rgst = receiver.read_register(Registers.RECEIVER_INFO, sub_reg + device)
print(
" Pairing Register %#04x %#04x: %s"
% (_R.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(_R.receiver_info, 0x40 + device)
rgst = receiver.read_register(Registers.RECEIVER_INFO, 0x40 + device)
print(
" Pairing Name %#04x %#02x: %s"
% (_R.receiver_info % 0x100, 0x40 + device, rgst[2 : 2 + ord(rgst[1:2])] if rgst else "None")
% (Registers.RECEIVER_INFO % 0x100, 0x40 + device, rgst[2 : 2 + ord(rgst[1:2])] if rgst else "None")
)
for part in range(1, 4):
rgst = receiver.read_register(_R.receiver_info, 0x60 + device, part)
rgst = receiver.read_register(Registers.RECEIVER_INFO, 0x60 + device, part)
print(
" Pairing Name %#04x %#02x %#02x: %2d %s"
% (
_R.receiver_info % 0x100,
Registers.RECEIVER_INFO % 0x100,
0x60 + device,
part,
ord(rgst[2:3]) if rgst else 0,
@ -83,39 +87,26 @@ def run(receivers, args, find_receiver, _ignore):
)
)
for sub_reg in range(0, 5):
rgst = receiver.read_register(_R.firmware, sub_reg)
rgst = receiver.read_register(Registers.FIRMWARE, sub_reg)
print(
" Firmware %#04x %#04x: %s"
% (_R.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("")
for reg in range(0, 0xFF):
last = None
for sub in range(0, 0xFF):
rgst = _base.request(receiver.handle, 0xFF, 0x8100 | reg, sub, return_error=True)
if isinstance(rgst, int) and rgst == _hidpp10_constants.ERROR.invalid_address:
break
elif isinstance(rgst, int) and rgst == _hidpp10_constants.ERROR.invalid_value:
continue
else:
if not isinstance(last, bytes) or not isinstance(rgst, bytes) or last != rgst:
print(
" Register Short %#04x %#04x: %s"
% (reg, sub, "0x" + _strhex(rgst) if isinstance(rgst, bytes) else str(rgst))
)
last = rgst
last = None
for sub in range(0, 0xFF):
rgst = _base.request(receiver.handle, 0xFF, 0x8100 | (0x200 + reg), sub, return_error=True)
if isinstance(rgst, int) and rgst == _hidpp10_constants.ERROR.invalid_address:
break
elif isinstance(rgst, int) and rgst == _hidpp10_constants.ERROR.invalid_value:
continue
else:
if not isinstance(last, bytes) or not isinstance(rgst, bytes) or last != rgst:
print(
" Register Long %#04x %#04x: %s"
% (reg, sub, "0x" + _strhex(rgst) if isinstance(rgst, bytes) else str(rgst))
)
last = rgst
for offset, reg_type in [(0x00, "Short"), (0x200, "Long")]:
last = None
for sub in range(0, 0xFF):
rgst = base.request(receiver.handle, 0xFF, 0x8100 | (offset + reg), sub, return_error=True)
if isinstance(rgst, int) and rgst == ErrorCode.INVALID_ADDRESS:
break
elif isinstance(rgst, int) and rgst == ErrorCode.INVALID_VALUE:
continue
else:
if not isinstance(last, bytes) or not isinstance(rgst, bytes) or last != rgst:
print(
" Register %s %#04x %#04x: %s"
% (reg_type, reg, sub, "0x" + strhex(rgst) if isinstance(rgst, bytes) else str(rgst))
)
last = rgst

View File

@ -14,12 +14,12 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import traceback as _traceback
import traceback
import yaml as _yaml
import yaml
from logitech_receiver.hidpp20 import OnboardProfiles as _OnboardProfiles
from logitech_receiver.hidpp20 import OnboardProfilesVersion as _OnboardProfilesVersion
from logitech_receiver.hidpp20 import OnboardProfiles
from logitech_receiver.hidpp20 import OnboardProfilesVersion
def run(receivers, args, find_receiver, find_device):
@ -42,15 +42,15 @@ def run(receivers, args, find_receiver, find_device):
print(f"Device {dev.name} is either offline or has no onboard profiles")
elif not profiles_file:
print(f"#Dumping profiles from {dev.name}")
print(_yaml.dump(dev.profiles))
print(yaml.dump(dev.profiles))
else:
try:
with open(profiles_file, "r") as f:
print(f"Reading profiles from {profiles_file}")
profiles = _yaml.safe_load(f)
if not isinstance(profiles, _OnboardProfiles):
profiles = yaml.safe_load(f)
if not isinstance(profiles, OnboardProfiles):
print("Profiles file does not contain current onboard profiles")
elif getattr(profiles, "version", None) != _OnboardProfilesVersion:
elif getattr(profiles, "version", None) != OnboardProfilesVersion:
version = getattr(profiles, "version", None)
print(f"Missing or incorrect profile version {version} in loaded profile")
elif getattr(profiles, "name", None) != dev.name:
@ -62,4 +62,4 @@ def run(receivers, args, find_receiver, find_device):
print(f"Wrote {written} sectors to {dev.name}")
except Exception as exc:
print("Profiles not written:", exc)
print(_traceback.format_exc())
print(traceback.format_exc())

View File

@ -14,15 +14,18 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
from logitech_receiver import common
from logitech_receiver import exceptions
from logitech_receiver import hidpp10
from logitech_receiver import hidpp10_constants as _hidpp10_constants
from logitech_receiver import hidpp10_constants
from logitech_receiver import hidpp20
from logitech_receiver import hidpp20_constants as _hidpp20_constants
from logitech_receiver import receiver as _receiver
from logitech_receiver import settings_templates as _settings_templates
from logitech_receiver.common import NamedInt as _NamedInt
from logitech_receiver.common import strhex as _strhex
from logitech_receiver import hidpp20_constants
from logitech_receiver import receiver
from logitech_receiver import settings_templates
from logitech_receiver.common import LOGITECH_VENDOR_ID
from logitech_receiver.common import NamedInt
from logitech_receiver.common import strhex
from logitech_receiver.hidpp20_constants import SupportedFeature
from solaar import NAME
from solaar import __version__
@ -36,9 +39,9 @@ def _print_receiver(receiver):
print(receiver.name)
print(" Device path :", receiver.path)
print(f" USB id : 046d:{receiver.product_id}")
print(f" USB id : {LOGITECH_VENDOR_ID:04x}:{receiver.product_id}")
print(" Serial :", receiver.serial)
pending = _hidpp10.get_configuration_pending_flags(receiver)
pending = hidpp10.get_configuration_pending_flags(receiver)
if pending:
print(f" C Pending : {pending:02x}")
if receiver.firmware:
@ -52,12 +55,12 @@ 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)
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X})")
else:
print(" Notifications: (none)")
activity = receiver.read_register(_hidpp10_constants.REGISTERS.devices_activity)
activity = receiver.read_register(hidpp10_constants.Registers.DEVICES_ACTIVITY)
if activity:
activity = [(d, ord(activity[d - 1 : d])) for d in range(1, receiver.max_devices)]
activity_text = ", ".join(f"{int(d)}={int(a)}" for d, a in activity if a > 0)
@ -67,7 +70,7 @@ def _print_receiver(receiver):
def _battery_text(level) -> str:
if level is None:
return "N/A"
elif isinstance(level, _NamedInt):
elif isinstance(level, NamedInt):
return str(level)
else:
return f"{int(level)}%"
@ -79,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.")
@ -103,7 +106,7 @@ def _print_device(dev, num=None):
if dev.wpid:
print(f" WPID : {dev.wpid}")
if dev.product_id:
print(f" USB id : 046d:{dev.product_id}")
print(f" USB id : {LOGITECH_VENDOR_ID:04x}:{dev.product_id}")
print(" Codename :", dev.codename)
print(" Kind :", dev.kind)
if dev.protocol:
@ -128,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)")
@ -143,15 +146,20 @@ def _print_device(dev, num=None):
if dev.online and dev.features:
print(f" Supports {len(dev.features)} HID++ 2.0 features:")
dev_settings = []
_settings_templates.check_feature_settings(dev, dev_settings)
settings_templates.check_feature_settings(dev, dev_settings)
for feature, index in dev.features.enumerate():
flags = dev.request(0x0000, feature.bytes(2))
if isinstance(feature, str):
feature_bytes = bytes.fromhex(feature[-4:])
else:
feature_bytes = feature.to_bytes(2, byteorder="little")
feature_int = int.from_bytes(feature_bytes, byteorder="little")
flags = dev.request(0x0000, feature_bytes)
flags = 0 if flags is None else ord(flags[1:2])
flags = _hidpp20_constants.FEATURE_FLAG.flag_names(flags)
version = dev.features.get_feature_version(int(feature))
flags = common.flag_names(hidpp20_constants.FeatureFlag, flags)
version = dev.features.get_feature_version(feature_int)
version = version if version else 0
print(" %2d: %-22s {%04X} V%s %s " % (index, feature, feature, version, ", ".join(flags)))
if feature == _hidpp20_constants.FEATURE.HIRES_WHEEL:
print(" %2d: %-22s {%04X} V%s %s " % (index, feature, feature_int, version, ", ".join(flags)))
if feature == SupportedFeature.HIRES_WHEEL:
wheel = _hidpp20.get_hires_wheel(dev)
if wheel:
multi, has_invert, has_switch, inv, res, target, ratchet = wheel
@ -168,7 +176,7 @@ def _print_device(dev, num=None):
print(" HID++ notification")
else:
print(" HID notification")
elif feature == _hidpp20_constants.FEATURE.MOUSE_POINTER:
elif feature == SupportedFeature.MOUSE_POINTER:
mouse_pointer = _hidpp20.get_mouse_pointer_info(dev)
if mouse_pointer:
print(f" DPI: {mouse_pointer['dpi']}")
@ -181,13 +189,13 @@ def _print_device(dev, num=None):
print(" Provide vertical tuning, trackball")
else:
print(" No vertical tuning, standard mice")
elif feature == _hidpp20_constants.FEATURE.VERTICAL_SCROLLING:
elif feature == SupportedFeature.VERTICAL_SCROLLING:
vertical_scrolling_info = _hidpp20.get_vertical_scrolling_info(dev)
if vertical_scrolling_info:
print(f" Roller type: {vertical_scrolling_info['roller']}")
print(f" Ratchet per turn: {vertical_scrolling_info['ratchet']}")
print(f" Scroll lines: {vertical_scrolling_info['lines']}")
elif feature == _hidpp20_constants.FEATURE.HI_RES_SCROLLING:
elif feature == SupportedFeature.HI_RES_SCROLLING:
scrolling_mode, scrolling_resolution = _hidpp20.get_hi_res_scrolling_info(dev)
if scrolling_mode:
print(" Hi-res scrolling enabled")
@ -195,49 +203,46 @@ def _print_device(dev, num=None):
print(" Hi-res scrolling disabled")
if scrolling_resolution:
print(f" Hi-res scrolling multiplier: {scrolling_resolution}")
elif feature == _hidpp20_constants.FEATURE.POINTER_SPEED:
elif feature == SupportedFeature.POINTER_SPEED:
pointer_speed = _hidpp20.get_pointer_speed_info(dev)
if pointer_speed:
print(f" Pointer Speed: {pointer_speed}")
elif feature == _hidpp20_constants.FEATURE.LOWRES_WHEEL:
elif feature == SupportedFeature.LOWRES_WHEEL:
wheel_status = _hidpp20.get_lowres_wheel_status(dev)
if wheel_status:
print(f" Wheel Reports: {wheel_status}")
elif feature == _hidpp20_constants.FEATURE.NEW_FN_INVERSION:
elif feature == SupportedFeature.NEW_FN_INVERSION:
inversion = _hidpp20.get_new_fn_inversion(dev)
if inversion:
inverted, default_inverted = inversion
print(" Fn-swap:", "enabled" if inverted else "disabled")
print(" Fn-swap default:", "enabled" if default_inverted else "disabled")
elif feature == _hidpp20_constants.FEATURE.HOSTS_INFO:
elif feature == SupportedFeature.HOSTS_INFO:
host_names = _hidpp20.get_host_names(dev)
for host, (paired, name) in host_names.items():
print(f" Host {host} ({'paired' if paired else 'unpaired'}): {name}")
elif feature == _hidpp20_constants.FEATURE.DEVICE_NAME:
elif feature == SupportedFeature.DEVICE_NAME:
print(f" Name: {_hidpp20.get_name(dev)}")
print(f" Kind: {_hidpp20.get_kind(dev)}")
elif feature == _hidpp20_constants.FEATURE.DEVICE_FRIENDLY_NAME:
elif feature == SupportedFeature.DEVICE_FRIENDLY_NAME:
print(f" Friendly Name: {_hidpp20.get_friendly_name(dev)}")
elif feature == _hidpp20_constants.FEATURE.DEVICE_FW_VERSION:
elif feature == SupportedFeature.DEVICE_FW_VERSION:
for fw in _hidpp20.get_firmware(dev):
extras = _strhex(fw.extras) if fw.extras else ""
extras = strhex(fw.extras) if fw.extras else ""
print(f" Firmware: {fw.kind} {fw.name} {fw.version} {extras}")
ids = _hidpp20.get_ids(dev)
if ids:
unitId, modelId, tid_map = ids
print(f" Unit ID: {unitId} Model ID: {modelId} Transport IDs: {tid_map}")
elif (
feature == _hidpp20_constants.FEATURE.REPORT_RATE
or feature == _hidpp20_constants.FEATURE.EXTENDED_ADJUSTABLE_REPORT_RATE
):
elif feature == SupportedFeature.REPORT_RATE or feature == SupportedFeature.EXTENDED_ADJUSTABLE_REPORT_RATE:
print(f" Report Rate: {_hidpp20.get_polling_rate(dev)}")
elif feature == _hidpp20_constants.FEATURE.CONFIG_CHANGE:
response = dev.feature_request(_hidpp20_constants.FEATURE.CONFIG_CHANGE, 0x00)
elif feature == SupportedFeature.CONFIG_CHANGE:
response = dev.feature_request(SupportedFeature.CONFIG_CHANGE, 0x00)
print(f" Configuration: {response.hex()}")
elif feature == _hidpp20_constants.FEATURE.REMAINING_PAIRING:
elif feature == SupportedFeature.REMAINING_PAIRING:
print(f" Remaining Pairings: {int(_hidpp20.get_remaining_pairing(dev))}")
elif feature == _hidpp20_constants.FEATURE.ONBOARD_PROFILES:
if _hidpp20.get_onboard_mode(dev) == _hidpp20_constants.ONBOARD_MODES.MODE_HOST:
elif feature == SupportedFeature.ONBOARD_PROFILES:
if _hidpp20.get_onboard_mode(dev) == hidpp20_constants.OnboardMode.MODE_HOST:
mode = "Host"
else:
mode = "On-Board"
@ -266,14 +271,17 @@ def _print_device(dev, num=None):
print(f" Has {len(dev.keys)} reprogrammable keys:")
for k in dev.keys:
# TODO: add here additional variants for other REPROG_CONTROLS
if dev.keys.keyversion == _hidpp20_constants.FEATURE.REPROG_CONTROLS_V2:
if dev.keys.keyversion == SupportedFeature.REPROG_CONTROLS_V2:
print(" %2d: %-26s => %-27s %s" % (k.index, k.key, k.default_task, ", ".join(k.flags)))
if dev.keys.keyversion == _hidpp20_constants.FEATURE.REPROG_CONTROLS_V4:
if dev.keys.keyversion == SupportedFeature.REPROG_CONTROLS_V4:
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:
@ -311,7 +319,7 @@ def run(devices, args, find_receiver, find_device):
if device_name == "all":
for d in devices:
if isinstance(d, _receiver.Receiver):
if isinstance(d, receiver.Receiver):
_print_receiver(d)
count = d.count()
if count:

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

@ -15,22 +15,22 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import json as _json
import json
import logging
import os as _os
import os
import threading
import yaml as _yaml
import yaml
from logitech_receiver.common import NamedInt as _NamedInt
from logitech_receiver.common import NamedInt
from solaar import __version__
logger = logging.getLogger(__name__)
_XDG_CONFIG_HOME = _os.environ.get("XDG_CONFIG_HOME") or _os.path.expanduser(_os.path.join("~", ".config"))
_yaml_file_path = _os.path.join(_XDG_CONFIG_HOME, "solaar", "config.yaml")
_json_file_path = _os.path.join(_XDG_CONFIG_HOME, "solaar", "config.json")
_XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser(os.path.join("~", ".config"))
_yaml_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "config.yaml")
_json_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "config.json")
_KEY_VERSION = "_version"
_KEY_NAME = "_NAME"
@ -45,25 +45,24 @@ _config = []
def _load():
loaded_config = []
if _os.path.isfile(_yaml_file_path):
if os.path.isfile(_yaml_file_path):
path = _yaml_file_path
try:
with open(_yaml_file_path) as config_file:
loaded_config = _yaml.safe_load(config_file)
loaded_config = yaml.safe_load(config_file)
except Exception as e:
logger.error("failed to load from %s: %s", _yaml_file_path, e)
elif _os.path.isfile(_json_file_path):
elif os.path.isfile(_json_file_path):
path = _json_file_path
try:
with open(_json_file_path) as config_file:
loaded_config = _json.load(config_file)
loaded_config = json.load(config_file)
except Exception as e:
logger.error("failed to load from %s: %s", _json_file_path, e)
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)
@ -129,10 +127,10 @@ def save(defer=False):
global save_timer
if not _config:
return
dirname = _os.path.dirname(_yaml_file_path)
if not _os.path.isdir(dirname):
dirname = os.path.dirname(_yaml_file_path)
if not os.path.isdir(dirname):
try:
_os.makedirs(dirname)
os.makedirs(dirname)
except Exception:
logger.error("failed to create %s", dirname)
return
@ -153,9 +151,8 @@ def do_save():
save_timer = None
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)
yaml.dump(_config, config_file, default_flow_style=None, width=150)
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)
@ -216,14 +213,14 @@ def device_representer(dumper, data):
return dumper.represent_mapping("tag:yaml.org,2002:map", data)
_yaml.add_representer(_DeviceEntry, device_representer)
yaml.add_representer(_DeviceEntry, device_representer)
def named_int_representer(dumper, data):
return dumper.represent_scalar("tag:yaml.org,2002:int", str(int(data)))
_yaml.add_representer(_NamedInt, named_int_representer)
yaml.add_representer(NamedInt, named_int_representer)
# A device can be identified by a combination of WPID and serial number (for receiver-connected devices)
@ -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

@ -14,9 +14,12 @@
## 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
import logging
from typing import Callable
logger = logging.getLogger(__name__)
try:
@ -31,7 +34,7 @@ try:
except Exception:
# Either the dbus library is not available or the system dbus is not running
logger.warning("failed to set up dbus")
pass
bus = None
_suspend_callback = None
@ -49,16 +52,23 @@ _LOGIND_PATH = "/org/freedesktop/login1"
_LOGIND_INTERFACE = "org.freedesktop.login1.Manager"
def watch_suspend_resume(on_resume_callback=None, on_suspend_callback=None):
def watch_suspend_resume(
on_resume_callback: Callable[[], None] | None = None,
on_suspend_callback: Callable[[], None] | None = None,
):
"""Register callback for suspend/resume events.
They are called only if the system DBus is running, and the Login daemon is available."""
global _resume_callback, _suspend_callback
_suspend_callback = on_suspend_callback
_resume_callback = on_resume_callback
if on_resume_callback is not None or on_suspend_callback is not None:
bus.add_signal_receiver(_suspend_or_resume, "PrepareForSleep", dbus_interface=_LOGIND_INTERFACE, path=_LOGIND_PATH)
if logger.isEnabledFor(logging.INFO):
logger.info("connected to system dbus, watching for suspend/resume events")
if bus is not None and on_resume_callback is not None or on_suspend_callback is not None:
bus.add_signal_receiver(
_suspend_or_resume,
"PrepareForSleep",
dbus_interface=_LOGIND_INTERFACE,
path=_LOGIND_PATH,
)
logger.info("connected to system dbus, watching for suspend/resume events")
_BLUETOOTH_PATH_PREFIX = "/org/bluez/hci0/dev_"
@ -71,7 +81,7 @@ def watch_bluez_connect(serial, callback=None):
if _bluetooth_callbacks.get(serial):
_bluetooth_callbacks.get(serial).remove()
path = _BLUETOOTH_PATH_PREFIX + serial.replace(":", "_").upper()
if callback is not None:
if bus is not None and callback is not None:
_bluetooth_callbacks[serial] = bus.add_signal_receiver(
callback, "PropertiesChanged", path=path, dbus_interface=_BLUETOOTH_INTERFACE
)

View File

@ -19,6 +19,7 @@
import argparse
import faulthandler
import importlib
import locale
import logging
import os.path
import platform
@ -28,23 +29,18 @@ import tempfile
from traceback import format_exc
import solaar.cli as _cli
import solaar.configuration as _configuration
import solaar.dbus as _dbus
import solaar.i18n as _i18n
import solaar.listener as _listener
import solaar.ui as _ui
import solaar.ui.common as _common
from solaar import NAME
from solaar import __version__
from solaar import cli
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__)
#
#
#
def _require(module, os_package, gi=None, gi_package=None, gi_version=None):
try:
@ -56,10 +52,11 @@ def _require(module, os_package, gi=None, gi_package=None, gi_version=None):
battery_icons_style = "regular"
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"
)
@ -78,9 +75,16 @@ def _parse_arguments():
metavar="PATH",
help="unifying receiver to use; the first detected receiver if unspecified. Example: /dev/hidraw2",
)
arg_parser.add_argument("--restart-on-wake-up", action="store_true", help="restart Solaar on sleep wake-up (experimental)")
arg_parser.add_argument(
"-w", "--window", choices=("show", "hide", "only"), help="start with window showing / hidden / only (no tray icon)"
"--restart-on-wake-up",
action="store_true",
help="restart Solaar on sleep wake-up (experimental)",
)
arg_parser.add_argument(
"-w",
"--window",
choices=("show", "hide", "only"),
help="start with window showing / hidden / only (no tray icon)",
)
arg_parser.add_argument(
"-b",
@ -90,13 +94,22 @@ def _parse_arguments():
)
arg_parser.add_argument("--tray-icon-size", type=int, help="explicit size for tray icons")
arg_parser.add_argument("-V", "--version", action="version", version="%(prog)s " + __version__)
arg_parser.add_argument("--help-actions", action="store_true", help="print help for the optional actions")
arg_parser.add_argument("action", nargs=argparse.REMAINDER, choices=_cli.actions, help="optional actions to perform")
arg_parser.add_argument("--help-actions", action="store_true", help="describe the command-line actions")
arg_parser.add_argument(
"action",
nargs=argparse.REMAINDER,
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:
_cli.print_help()
cli.print_help()
return
if args.window is None:
@ -121,8 +134,8 @@ def _parse_arguments():
logging.getLogger("").addHandler(stream_handler)
if not args.action:
if logger.isEnabledFor(logging.INFO):
logger.info("version %s, language %s (%s)", __version__, _i18n.language, _i18n.encoding)
language, encoding = locale.getlocale()
logger.info("version %s, language %s (%s)", __version__, language, encoding)
return args
@ -146,10 +159,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")
@ -161,7 +179,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)
@ -169,17 +188,17 @@ def main():
logger.warning("Solaar udev file not found in expected location")
logger.warning("See https://pwr-solaar.github.io/Solaar/installation for more information")
try:
_listener.setup_scanner(_ui.status_changed, _ui.setting_changed, _common.error_dialog)
listener.setup_scanner(ui.status_changed, ui.setting_changed, ui.common.error_dialog)
if args.restart_on_wake_up:
_dbus.watch_suspend_resume(_listener.start_all, _listener.stop_all)
dbus.watch_suspend_resume(listener.start_all, listener.stop_all)
else:
_dbus.watch_suspend_resume(lambda: _listener.ping_all(True))
dbus.watch_suspend_resume(lambda: listener.ping_all(True))
_configuration.defer_saves = True # allow configuration saves to be deferred
configuration.defer_saves = True # allow configuration saves to be deferred
# main UI event loop
_ui.run_loop(_listener.start_all, _listener.stop_all, args.window != "only", args.window != "hide")
ui.run_loop(listener.start_all, listener.stop_all, args.window != "only", args.window != "hide")
except Exception:
sys.exit(f"{NAME.lower()}: error: {format_exc()}")

View File

@ -14,43 +14,60 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import gettext as _gettext
import gettext
import locale
import os.path as _path
import sys as _sys
import logging
import os
import sys
from glob import glob as _glob
from glob import glob
from solaar import NAME as _NAME
from solaar import NAME
#
#
#
_LOCALE_DOMAIN = NAME.lower()
logger = logging.getLogger(__name__)
def _find_locale_path(lc_domain):
prefix_share = _path.normpath(_path.join(_path.realpath(_sys.path[0]), ".."))
src_share = _path.normpath(_path.join(_path.realpath(_sys.path[0]), "..", "share"))
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(_path.join(location, "locale", "*", "LC_MESSAGES", lc_domain + ".mo"))
mo_files = glob(os.path.join(location, "locale", "*", "LC_MESSAGES", f"{locale_domain}.mo"))
if mo_files:
return _path.join(location, "locale")
return os.path.join(location, "locale")
raise FileNotFoundError(f"Could not find locale path for {locale_domain}")
try:
locale.setlocale(locale.LC_ALL, "")
except Exception:
pass
def set_locale_to_system_default() -> None:
"""Sets locale for translations to the system default.
language, encoding = locale.getlocale()
If locale is unsupported, fallback to standard English without
translation 'C'.
_LOCALE_DOMAIN = _NAME.lower()
path = _find_locale_path(_LOCALE_DOMAIN)
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, "") # 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
_gettext.bindtextdomain(_LOCALE_DOMAIN, path)
_gettext.textdomain(_LOCALE_DOMAIN)
_gettext.install(_LOCALE_DOMAIN)
try:
path = _find_locale_path(_LOCALE_DOMAIN)
except FileNotFoundError:
path = None
gettext.bindtextdomain(_LOCALE_DOMAIN, path)
gettext.textdomain(_LOCALE_DOMAIN)
gettext.install(_LOCALE_DOMAIN)
_ = _gettext.gettext
ngettext = _gettext.ngettext
set_locale_to_system_default()
_ = gettext.gettext
ngettext = gettext.ngettext

View File

@ -15,47 +15,57 @@
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
import errno as _errno
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.device as _device
import logitech_receiver.receiver as _receiver
import logitech_receiver
from logitech_receiver import base as _base
from logitech_receiver import base
from logitech_receiver import exceptions
from logitech_receiver import hidpp10_constants as _hidpp10_constants
from logitech_receiver import listener as _listener
from logitech_receiver import notifications as _notifications
from logitech_receiver import hidpp10_constants
from logitech_receiver import listener
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__)
_R = _hidpp10_constants.REGISTERS
_IR = _hidpp10_constants.INFO_SUBREGISTERS
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)
return _GHOST_DEVICE(
receiver=device.receiver, number=device.number, name=device.name, kind=device.kind, online=False, path=None
)
class SolaarListener(_listener.EventsListener):
class SolaarListener(listener.EventsListener):
"""Keeps the status of a Receiver or Device (member name is receiver but it can also be a device)."""
def __init__(self, receiver, status_changed_callback):
@ -65,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 if nfs else 0) & hidpp10_constants.NotificationFlag.WIRELESS.value):
logger.warning(
"Receiver on %s might not support connection notifications, GUI might not show its devices",
self.receiver.path,
)
self.receiver.notification_flags = nfs
self.receiver.notify_devices()
self._status_changed(self.receiver)
@ -81,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)
@ -130,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)
@ -142,35 +148,30 @@ class SolaarListener(_listener.EventsListener):
def _notifications_handler(self, n):
assert self.receiver
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug("%s: handling %s", self.receiver, n)
if n.devnumber == 0xFF:
# a receiver notification
_notifications.process(self.receiver, n)
notifications.process(self.receiver, n)
return
# 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)
if n.sub_id == 0x41 and n.report_id == base.DJ_MESSAGE_ID:
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
# FIXME: hacky fix for kernel/hardware race condition
# If the device was just turned on or woken up from sleep, it may not be ready to receive commands.
# The "payload" bit of the wireless tatus notification seems to tell us this. If this is the case, we
# The "payload" bit of the wireless status notification seems to tell us this. If this is the case, we
# must wait a short amount of time to avoid causing a broken pipe error.
device_ready = not bool(ord(n.data[0:1]) & 0x80) or n.sub_id != 0x41
if not device_ready:
@ -183,7 +184,13 @@ class SolaarListener(_listener.EventsListener):
if not already_known:
if n.address == 0x0A and not self.receiver.receiver_kind == "bolt":
# some Nanos send a notification even if no new pairing - check that there really is a device there
if self.receiver.read_register(_R.receiver_info, _IR.pairing_information + n.devnumber - 1) is None:
if (
self.receiver.read_register(
hidpp10_constants.Registers.RECEIVER_INFO,
hidpp10_constants.InfoSubRegisters.PAIRING_INFORMATION + n.devnumber - 1,
)
is None
):
return
dev = self.receiver.register_new_device(n.devnumber, n)
elif self.receiver.pairing.lock_open and self.receiver.re_pairs and not ord(n.data[0:1]) & 0x40:
@ -203,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)
@ -212,14 +218,12 @@ class SolaarListener(_listener.EventsListener):
# the receiver changed status as well
self._status_changed(self.receiver)
_notifications.process(dev, n)
notifications.process(dev, n)
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()
@ -228,45 +232,44 @@ class SolaarListener(_listener.EventsListener):
return f"<SolaarListener({self.receiver.path},{self.receiver.handle})>"
def _process_bluez_dbus(device, path, dictionary, signature):
"""Process bluez dbus property changed signals for device status changes to discover disconnections and connections"""
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:
receiver = _receiver.ReceiverFactory.create_receiver(device_info, _setting_callback)
else:
receiver = _device.DeviceFactory.create_device(device_info, _setting_callback)
if receiver:
configuration.attach_to(receiver)
if receiver.bluetooth and receiver.hid_serial:
dbus.watch_bluez_connect(receiver.hid_serial, partial(_process_bluez_dbus, receiver))
receiver.cleanups.append(_cleanup_bluez_dbus)
if receiver:
rl = SolaarListener(receiver, _status_callback)
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)
if receiver_:
configuration.attach_to(receiver_)
if receiver_.bluetooth and receiver_.hid_serial:
dbus.watch_bluez_connect(receiver_.hid_serial, partial(_process_bluez_dbus, receiver_))
receiver_.cleanups.append(_cleanup_bluez_dbus)
if receiver_:
rl = SolaarListener(receiver_, _status_callback)
rl.start()
_all_listeners[device_info.path] = rl
return rl
@ -276,18 +279,16 @@ 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")
for device_info in _base.receivers_and_devices():
_process_receiver_event("add", device_info)
logger.info("starting receiver listening threads")
for device_info in base.receivers_and_devices():
_process_receiver_event(ACTION_ADD, device_info)
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()
@ -299,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:
@ -327,34 +327,33 @@ _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
_setting_callback = setting_changed_callback
_error_callback = error_callback
_base.notify_on_receivers_glib(_process_receiver_event)
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:
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)
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
@ -362,13 +361,12 @@ 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:
assert isinstance(listener_thread, SolaarListener)
listener_thread.stop()
if action == "add":
if action == ACTION_ADD:
_process_add(device_info, 3)
return False

View File

@ -18,25 +18,21 @@
import logging
from threading import Thread as _Thread
from threading import Thread
logger = logging.getLogger(__name__)
try:
from Queue import Queue as _Queue
from Queue import Queue
except ImportError:
from queue import Queue as _Queue
#
#
#
from queue import Queue
class TaskRunner(_Thread):
class TaskRunner(Thread):
def __init__(self, name):
super().__init__(name=name)
self.daemon = True
self.queue = _Queue(16)
self.queue = Queue(16)
self.alive = False
def __call__(self, function, *args, **kwargs):
@ -50,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()
@ -63,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,10 +17,13 @@
import logging
import gi
import yaml as _yaml
from enum import Enum
from typing import Callable
from logitech_receiver.common import ALERT
import gi
import yaml
from logitech_receiver.common import Alert
from solaar.i18n import _
from solaar.ui.config_panel import change_setting
@ -28,8 +31,8 @@ from solaar.ui.config_panel import record_setting
from solaar.ui.window import find_device
from . import common
from . import desktop_notifications
from . import diversion_rules
from . import notify
from . import tray
from . import window
@ -43,11 +46,19 @@ logger = logging.getLogger(__name__)
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()
notify.init()
desktop_notifications.init()
if use_tray:
tray.init(lambda _ignore: window.destroy())
window.init(show_window, use_tray)
@ -55,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:
@ -65,12 +75,11 @@ def _activate(app):
def _command_line(app, command_line):
args = command_line.get_arguments()
args = _yaml.safe_load("".join(args)) if args else args
args = yaml.safe_load("".join(args)) if args else args
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)
@ -79,24 +88,32 @@ def _command_line(app, command_line):
return 0
def _shutdown(app, shutdown_hook):
if logger.isEnabledFor(logging.DEBUG):
logger.debug("shutdown")
def _shutdown(_app, shutdown_hook):
logger.debug("shutdown")
shutdown_hook()
common.stop_async()
tray.destroy()
notify.uninit()
desktop_notifications.uninit()
def run_loop(startup_hook, shutdown_hook, use_tray, show_window):
def run_loop(
startup_hook: Callable[[], None],
shutdown_hook: Callable[[], None],
use_tray: bool,
show_window: bool,
):
assert use_tray or show_window, "need either tray or visible window"
APP_ID = "io.github.pwr_solaar.solaar"
application = Gtk.Application.new(APP_ID, Gio.ApplicationFlags.HANDLES_COMMAND_LINE)
application.connect("startup", 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(
"startup",
lambda app, startup_hook: _startup(app, startup_hook, use_tray, show_window),
startup_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():
@ -106,24 +123,23 @@ def run_loop(startup_hook, shutdown_hook, use_tray, show_window):
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
alert = Alert.NONE
tray.update(device)
if alert & ALERT.ATTENTION:
if alert & Alert.ATTENTION:
tray.attention(reason)
need_popup = alert & ALERT.SHOW_WINDOW
need_popup = alert & Alert.SHOW_WINDOW
window.update(device, need_popup, refresh)
diversion_rules.update_devices()
if alert & (ALERT.NOTIFICATION | ALERT.ATTENTION):
notify.show(device, reason)
if alert & (Alert.NOTIFICATION | Alert.ATTENTION):
desktop_notifications.show(device, reason)
def status_changed(device, alert=ALERT.NONE, reason=None, refresh=False):
def status_changed(device, alert=Alert.NONE, reason=None, refresh=False):
GLib.idle_add(_status_changed, device, alert, reason, refresh)

View File

@ -1,106 +0,0 @@
## Copyright (C) 2012-2013 Daniel Pavel
## Revisions Copyright (C) Contributors to the Solaar project.
##
## 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.
import logging
from gi.repository import Gtk
from solaar import NAME
from solaar import __version__
from solaar.i18n import _
#
#
#
_dialog = None
def _create():
about = Gtk.AboutDialog()
about.set_program_name(NAME)
about.set_version(__version__)
about.set_comments(_("Manages Logitech receivers,\nkeyboards, mice, and tablets."))
about.set_icon_name(NAME.lower())
about.set_logo_icon_name(NAME.lower())
about.set_copyright("© 2012-2023 Daniel Pavel and contributors to the Solaar project")
about.set_license_type(Gtk.License.GPL_2_0)
about.set_authors(("Daniel Pavel http://github.com/pwr",))
try:
about.add_credit_section(_("Additional Programming"), ("Filipe Laíns", "Peter F. Patel-Schneider"))
about.add_credit_section(_("GUI design"), ("Julien Gascard", "Daniel Pavel"))
about.add_credit_section(
_("Testing"),
(
"Douglas Wagner",
"Julien Gascard",
"Peter Wu http://www.lekensteyn.nl/logitech-unifying.html",
),
)
about.add_credit_section(
_("Logitech documentation"),
(
"Julien Danjou http://julien.danjou.info/blog/2012/logitech-unifying-upower",
"Nestor Lopez Casado http://drive.google.com/folderview?id=0BxbRzx7vEV7eWmgwazJ3NUFfQ28",
),
)
except TypeError:
# gtk3 < ~3.6.4 has incorrect gi bindings
logging.exception("failed to fully create the about dialog")
except Exception:
# the Gtk3 version may be too old, and the function does not exist
logging.exception("failed to fully create the about dialog")
about.set_translator_credits(
"\n".join(
(
"gogo (croatian)",
"Papoteur, David Geiger, Damien Lallement (français)",
"Michele Olivo (italiano)",
"Adrian Piotrowicz (polski)",
"Drovetto, JrBenito (Portuguese-BR)",
"Daniel Pavel (română)",
"Daniel Zippert, Emelie Snecker (svensk)",
"Dimitriy Ryazantcev (Russian)",
"El Jinete Sin Cabeza (Español)",
"Ferdina Kusumah (Indonesia)",
)
)
)
about.set_website("https://pwr-solaar.github.io/Solaar")
about.set_website_label(NAME)
about.connect("response", lambda x, y: x.hide())
def _hide(dialog, event):
dialog.hide()
return True
about.connect("delete-event", _hide)
return about
def show_window(trigger=None):
global _dialog
if _dialog is None:
_dialog = _create()
_dialog.present()

View File

View File

@ -0,0 +1,36 @@
## Copyright (C) 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.
from solaar.ui.about.model import AboutModel
from solaar.ui.about.presenter import Presenter
from solaar.ui.about.view import AboutView
def show(_=None, model=None, view=None):
"""Opens the About dialog."""
if model is None:
model = AboutModel()
if view is None:
view = AboutView()
presenter = Presenter(model, view)
presenter.run()
if __name__ == "__main__":
from gi.repository import Gtk
show(None)
Gtk.main()

View File

@ -0,0 +1,83 @@
## Copyright (C) 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.
from __future__ import annotations
from datetime import datetime
from typing import List
from typing import Tuple
from solaar import __version__
from solaar.i18n import _
def _get_current_year() -> int:
return datetime.now().year
class AboutModel:
def get_version(self) -> str:
return __version__
def get_description(self) -> str:
return _("Manages Logitech receivers,\nkeyboards, mice, and tablets.")
def get_copyright(self) -> str:
return f"© 2012-{_get_current_year()} Daniel Pavel and contributors to the Solaar project"
def get_authors(self) -> List[str]:
return [
"Daniel Pavel http://github.com/pwr",
]
def get_translators(self) -> List[str]:
return [
"gogo (croatian)",
"Papoteur, David Geiger, Damien Lallement (français)",
"Michele Olivo (italiano)",
"Adrian Piotrowicz (polski)",
"Drovetto, JrBenito (Portuguese-BR)",
"Daniel Pavel (română)",
"Daniel Zippert, Emelie Snecker (svensk)",
"Dimitriy Ryazantcev (Russian)",
"El Jinete Sin Cabeza (Español)",
"Ferdina Kusumah (Indonesia)",
"John Erling Blad (Norwegian Bokmål, Norwegian Nynorsk)",
]
def get_credit_sections(self) -> List[Tuple[str, List[str]]]:
return [
(_("Additional Programming"), ["Filipe Laíns", "Peter F. Patel-Schneider"]),
(_("GUI design"), ["Julien Gascard", "Daniel Pavel"]),
(
_("Testing"),
[
"Douglas Wagner",
"Julien Gascard",
"Peter Wu http://www.lekensteyn.nl/logitech-unifying.html",
],
),
(
_("Logitech documentation"),
[
"Julien Danjou http://julien.danjou.info/blog/2012/logitech-unifying-upower",
"Nestor Lopez Casado http://drive.google.com/folderview?id=0BxbRzx7vEV7eWmgwazJ3NUFfQ28",
],
),
]
def get_website(self):
return "https://pwr-solaar.github.io/Solaar"

View File

@ -0,0 +1,95 @@
## Copyright (C) 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.
from __future__ import annotations
from typing_extensions import Protocol
from solaar.ui.about.model import AboutModel
class AboutViewProtocol(Protocol):
def init_ui(self) -> None:
...
def update_version_info(self, version: str) -> None:
...
def update_description(self, comments: str) -> None:
...
def update_copyright(self, copyright_text: str) -> None:
...
def update_authors(self, authors: list[str]) -> None:
...
def update_translators(self, translators: list[str]) -> None:
...
def update_website(self, website):
...
def update_credits(self, credit_sections: list[tuple[str, list[str]]]) -> None:
...
def show(self) -> None:
...
class Presenter:
def __init__(self, model: AboutModel, view: AboutViewProtocol) -> None:
self.model = model
self.view = view
def update_version_info(self) -> None:
version = self.model.get_version()
self.view.update_version_info(version)
def update_credits(self) -> None:
credit_sections = self.model.get_credit_sections()
self.view.update_credits(credit_sections)
def update_description(self) -> None:
comments = self.model.get_description()
self.view.update_description(comments)
def update_copyright(self) -> None:
copyright_text = self.model.get_copyright()
self.view.update_copyright(copyright_text)
def update_authors(self) -> None:
authors = self.model.get_authors()
self.view.update_authors(authors)
def update_translators(self) -> None:
translators = self.model.get_translators()
self.view.update_translators(translators)
def update_website(self) -> None:
website = self.model.get_website()
self.view.update_website(website)
def run(self) -> None:
self.view.init_ui()
self.update_version_info()
self.update_description()
self.update_website()
self.update_copyright()
self.update_authors()
self.update_credits()
self.update_translators()
self.view.show()

View File

@ -0,0 +1,70 @@
## Copyright (C) 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.
from enum import Enum
from typing import List
from typing import Tuple
from typing import Union
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
def init_ui(self) -> None:
self.view = Gtk.AboutDialog()
self.view.set_program_name(NAME)
self.view.set_logo_icon_name(NAME.lower())
self.view.set_license_type(Gtk.License.GPL_2_0)
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)
def update_description(self, comments: str) -> None:
self.view.set_comments(comments)
def update_copyright(self, copyright_text: str):
self.view.set_copyright(copyright_text)
def update_authors(self, authors: List[str]) -> None:
self.view.set_authors(authors)
def update_credits(self, credit_sections: List[Tuple[str, List[str]]]) -> None:
for section_name, people in credit_sections:
self.view.add_credit_section(section_name, people)
def update_translators(self, translators: List[str]) -> None:
translator_credits = "\n".join(translators)
self.view.set_translator_credits(translator_credits)
def update_website(self, website):
self.view.set_website_label(NAME)
self.view.set_website(website)
def show(self) -> None:
self.view.present()
def handle_close(self, event) -> None:
event.hide()

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,10 +16,13 @@
import logging
from enum import Enum
from typing import Tuple
import gi
from solaar.i18n import _
from solaar.tasks import TaskRunner as _TaskRunner
from solaar.tasks import TaskRunner
gi.require_version("Gtk", "3.0")
from gi.repository import GLib # NOQA: E402
@ -28,35 +31,45 @@ from gi.repository import Gtk # NOQA: E402
logger = logging.getLogger(__name__)
def _error_dialog(reason, object):
logger.error("error: %s %s", reason, object)
class ErrorReason(Enum):
PERMISSIONS = "Permissions"
NO_DEVICE = "No device"
UNPAIR = "Unpair"
if reason == "permissions":
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
_("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
_("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(device=object.name, receiver=object.receiver.name)
_("Failed to unpair %{device} from %{receiver}.").format(
device=object_.name,
receiver=object_.receiver.name,
)
+ "\n\n"
+ _("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
assert title
assert text
def _error_dialog(reason: ErrorReason, object_):
logger.error("error: %s %s", reason, object_)
title, text = _create_error_text(reason, object_)
m = Gtk.MessageDialog(None, Gtk.DialogFlags.MODAL, Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE, text)
m.set_title(title)
@ -64,21 +77,16 @@ def _error_dialog(reason, object):
m.destroy()
def error_dialog(reason, object):
assert reason is not None
GLib.idle_add(_error_dialog, reason, object)
def error_dialog(reason: ErrorReason, object_):
GLib.idle_add(_error_dialog, reason, object_)
#
#
#
_task_runner = None
def start_async():
global _task_runner
_task_runner = _TaskRunner("AsyncUI")
_task_runner = TaskRunner("AsyncUI")
_task_runner.start()
@ -89,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

@ -18,18 +18,18 @@
import logging
import traceback
from threading import Timer as _Timer
from enum import Enum
from threading import Timer
import gi
from logitech_receiver.hidpp20 import LEDEffectSetting as _LEDEffectSetting
from logitech_receiver.settings import KIND as _SETTING_KIND
from logitech_receiver.settings import SENSITIVITY_IGNORE as _SENSITIVITY_IGNORE
from logitech_receiver import hidpp20
from logitech_receiver import settings
from solaar.i18n import _
from solaar.i18n import ngettext
from .common import ui_async as _ui_async
from .common import ui_async
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk # NOQA: E402
@ -39,21 +39,30 @@ 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"
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)
ui_async(_do_read, setting, force_read, sbox, device_is_online, sensitive)
def _write_async(setting, value, sbox, sensitive=True, key=None):
def _do_write(s, v, sb, key):
def _do_write(_s, v, sb, key):
try:
if key is None:
v = setting.write(v)
@ -71,7 +80,7 @@ def _write_async(setting, value, sbox, sensitive=True, key=None):
sbox._failed.set_visible(False)
sbox._spinner.set_visible(True)
sbox._spinner.start()
_ui_async(_do_write, setting, value, sbox, key)
ui_async(_do_write, setting, value, sbox, key)
class ComboBoxText(Gtk.ComboBoxText):
@ -88,8 +97,9 @@ class Scale(Gtk.Scale):
class Control:
def __init__(**kwargs):
pass
def __init__(self, **kwargs):
self.sbox = None
self.delegate = None
def init(self, sbox, delegate):
self.sbox = sbox
@ -105,7 +115,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 == _SETTING_KIND.range or sbox.setting.kind == _SETTING_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 +126,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 +145,7 @@ 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 get_value(self):
return int(super().get_value())
@ -144,7 +154,7 @@ class SliderControl(Gtk.Scale, Control):
if self.get_sensitive():
if self.timer:
self.timer.cancel()
self.timer = _Timer(0.5, lambda: GLib.idle_add(self.do_change))
self.timer = Timer(0.5, lambda: GLib.idle_add(self.do_change))
self.timer.start()
def do_change(self):
@ -167,7 +177,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 +215,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 +231,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 ""
@ -228,12 +241,12 @@ class ChoiceControlBig(Gtk.Entry, Control):
tooltip = _("Incomplete") if self.value is None else _("Complete - ENTER to change")
self.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, tooltip)
def activate(self, *args):
def activate(self, *_args):
if self.value is not None and self.get_sensitive():
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "")
self.delegate.update()
def select(self, completion, model, iter):
def select(self, _completion, model, iter):
self.set_value(model.get(iter, 0)[0])
if self.value and self.get_sensitive():
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "")
@ -253,7 +266,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,13 +286,12 @@ 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:
self.valueBox.set_value(current)
def map_value_notify_key(self, *args):
def map_value_notify_key(self, *_args):
key_choice = int(self.keyBox.get_active_id())
if self.keyBox.get_sensitive():
self.map_populate_value_box(key_choice)
@ -301,7 +313,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)
@ -322,7 +334,7 @@ class MultipleControl(Gtk.ListBox, Control):
sbox._button = self._button
return True
def toggle_display(self, *args):
def toggle_display(self, *_args):
self._showing = not self._showing
if not self._showing:
for c in self.get_children():
@ -349,14 +361,14 @@ 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)
self.add(h)
self._label_control_pairs.append((lbl, control))
def toggle_notify(self, switch, active):
def toggle_notify(self, switch, _active):
if switch.get_sensitive():
key = switch._setting_key
new_state = switch.get_state()
@ -376,7 +388,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)
@ -411,7 +423,12 @@ class MultipleRangeControl(MultipleControl):
h.pack_start(sub_item_lbl, False, False, 0)
sub_item_lbl.set_margin_start(30)
if sub_item.widget == "Scale":
control = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, sub_item.minimum, sub_item.maximum, 1)
control = Gtk.Scale.new_with_range(
Gtk.Orientation.HORIZONTAL,
sub_item.minimum,
sub_item.maximum,
1,
)
control.set_round_digits(0)
control.set_digits(0)
h.pack_end(control, True, True, 0)
@ -421,7 +438,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
@ -435,7 +452,7 @@ class MultipleRangeControl(MultipleControl):
if control.get_sensitive():
if hasattr(control, "_timer"):
control._timer.cancel()
control._timer = _Timer(0.5, lambda: GLib.idle_add(self._write, control, item, sub_item))
control._timer = Timer(0.5, lambda: GLib.idle_add(self._write, control, item, sub_item))
control._timer.start()
def _write(self, control, item, sub_item):
@ -455,7 +472,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
@ -465,7 +482,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)
@ -474,15 +491,15 @@ class MultipleRangeControl(MultipleControl):
class PackedRangeControl(MultipleRangeControl):
def setup(self, setting):
validator = setting._validator
self._items = []
validator = setting._validator
for item in range(validator.count):
h = Gtk.HBox(homogeneous=False, spacing=0)
lbl = Gtk.Label(label=str(validator.keys[item]))
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]
@ -495,7 +512,7 @@ class PackedRangeControl(MultipleRangeControl):
if control.get_sensitive():
if hasattr(control, "_timer"):
control._timer.cancel()
control._timer = _Timer(0.5, lambda: GLib.idle_add(self._write, control, item))
control._timer = Timer(0.5, lambda: GLib.idle_add(self._write, control, item))
control._timer.start()
def _write(self, control, item):
@ -518,7 +535,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)
@ -537,20 +554,21 @@ class HeteroKeyControl(Gtk.HBox, Control):
item_lblbox.set_visible(False)
else:
item_lblbox = None
if item["kind"] == _SETTING_KIND.choice:
item_box = ComboBoxText()
item_box = ComboBoxText()
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"] == _SETTING_KIND.range:
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)
@ -559,7 +577,7 @@ class HeteroKeyControl(Gtk.HBox, Control):
result = {}
for k, (_lblbox, box) in self._items.items():
result[str(k)] = box.get_value()
result = _LEDEffectSetting(**result)
result = hidpp20.LEDEffectSetting(**result)
return result
def set_value(self, value):
@ -573,8 +591,8 @@ class HeteroKeyControl(Gtk.HBox, Control):
self.sbox._failed.set_visible(True)
self.setup_visibles(value.ID if value is not None else 0)
def setup_visibles(self, ID):
fields = self.sbox.setting.fields_map[ID][1] if ID in self.sbox.setting.fields_map else {}
def setup_visibles(self, id_):
fields = self.sbox.setting.fields_map[id_][1] if id_ in self.sbox.setting.fields_map else {}
for name, (lblbox, box) in self._items.items():
visible = name in fields or name == "ID"
if lblbox:
@ -587,7 +605,7 @@ class HeteroKeyControl(Gtk.HBox, Control):
self.setup_visibles(int(self._items["ID"][1].get_value()))
if hasattr(control, "_timer"):
control._timer.cancel()
control._timer = _Timer(0.3, lambda: GLib.idle_add(self._write, control))
control._timer = Timer(0.3, lambda: GLib.idle_add(self._write, control))
control._timer.start()
def _write(self, control):
@ -598,17 +616,13 @@ class HeteroKeyControl(Gtk.HBox, Control):
_write_async(self.sbox.setting, new_state, self.sbox)
#
#
#
_allowables_icons = {True: "changes-allow", False: "changes-prevent", _SENSITIVITY_IGNORE: "dialog-error"}
_allowables_icons = {True: "changes-allow", False: "changes-prevent", settings.SENSITIVITY_IGNORE: "dialog-error"}
_allowables_tooltips = {
True: _("Changes allowed"),
False: _("No changes allowed"),
_SENSITIVITY_IGNORE: _("Ignore this setting"),
settings.SENSITIVITY_IGNORE: _("Ignore this setting"),
}
_next_allowable = {True: False, False: _SENSITIVITY_IGNORE, _SENSITIVITY_IGNORE: True}
_next_allowable = {True: False, False: settings.SENSITIVITY_IGNORE, settings.SENSITIVITY_IGNORE: True}
_icons_allowables = {v: k for k, v in _allowables_icons.items()}
@ -622,7 +636,7 @@ def _change_click(button, sbox):
_change_icon(new_allowed, icon)
if sbox.setting._device.persister: # remember the new setting sensitivity
sbox.setting._device.persister.set_sensitivity(sbox.setting.name, new_allowed)
if allowed == _SENSITIVITY_IGNORE: # update setting if it was being ignored
if allowed == settings.SENSITIVITY_IGNORE: # update setting if it was being ignored
setting = next((s for s in sbox.setting._device.settings if s.name == sbox.setting.name), None)
if setting:
persisted = sbox.setting._device.persister.get(setting.name) if sbox.setting._device.persister else None
@ -640,7 +654,7 @@ def _change_icon(allowed, icon):
icon.set_tooltip_text(_allowables_tooltips[allowed])
def _create_sbox(s, device):
def _create_sbox(s, _device):
sbox = Gtk.HBox(homogeneous=False, spacing=6)
sbox.setting = s
sbox.kind = s.kind
@ -662,27 +676,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 == _SETTING_KIND.toggle:
if s.kind == settings.Kind.TOGGLE:
control = ToggleControl(sbox)
elif s.kind == _SETTING_KIND.range:
elif s.kind == settings.Kind.RANGE:
control = SliderControl(sbox)
elif s.kind == _SETTING_KIND.choice:
elif s.kind == settings.Kind.CHOICE:
control = _create_choice_control(sbox)
elif s.kind == _SETTING_KIND.map_choice:
elif s.kind == settings.Kind.MAP_CHOICE:
control = MapChoiceControl(sbox)
elif s.kind == _SETTING_KIND.multiple_toggle:
elif s.kind == settings.Kind.MULTIPLE_TOGGLE:
control = MultipleToggleControl(sbox, change)
elif s.kind == _SETTING_KIND.multiple_range:
elif s.kind == settings.Kind.MULTIPLE_RANGE:
control = MultipleRangeControl(sbox, change)
elif s.kind == _SETTING_KIND.packed_range:
elif s.kind == settings.Kind.PACKED_RANGE:
control = PackedRangeControl(sbox, change)
elif s.kind == _SETTING_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
@ -694,18 +707,20 @@ def _create_sbox(s, device):
return sbox
def _update_setting_item(sbox, value, is_online=True, sensitive=True, nullOK=False):
# sbox._spinner.set_visible(False) # don't repack item box
def _update_setting_item(sbox, value, is_online=True, sensitive=True, null_okay=False):
sbox._spinner.stop()
sensitive = sbox._change_icon._allowed if sensitive is None else sensitive
if value is None and not nullOK:
if value is None and not null_okay:
sbox._control.set_sensitive(sensitive is True)
_change_icon(sensitive, sbox._change_icon)
sbox._failed.set_visible(is_online)
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)
@ -809,11 +824,14 @@ 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):
logger.debug("No setting for %s found on %s when trying to record a change made elsewhere", setting_class.name, device)
if setting is None:
logger.debug(
"No setting for %s found on %s when trying to record a change made elsewhere",
setting_class.name,
device,
)
if setting:
assert device == setting._device
if len(values) > 1:

View File

@ -13,48 +13,52 @@
## 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 importlib
# Optional desktop notifications.
import logging
from solaar import NAME
from solaar.i18n import _
from . import icons as _icons
from . import icons
logger = logging.getLogger(__name__)
#
#
#
try:
import gi
def notifications_available():
"""Checks if notification service is available."""
notifications_supported = False
try:
import gi
gi.require_version("Notify", "0.7")
# this import is allowed to fail, in which case the entire feature is unavailable
gi.require_version("Notify", "0.7")
importlib.util.find_spec("gi.repository.GLib")
importlib.util.find_spec("gi.repository.Notify")
notifications_supported = True
except ValueError as e:
logger.warning(f"Notification service is not available: {e}")
return notifications_supported
available = notifications_available()
if available:
from gi.repository import GLib
from gi.repository import Notify
# assumed to be working since the import succeeded
available = True
except (ValueError, ImportError):
available = False
if available:
# cache references to shown notifications here, so if another status comes
# while its notification is still visible we don't create another one
_notifications = {}
def init():
"""Init the notifications system."""
"""Initialize desktop notifications."""
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:
@ -63,20 +67,12 @@ if available:
return available and Notify.is_initted()
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()
# def toggle(action):
# if action.get_active():
# init()
# else:
# uninit()
# action.set_sensitive(available)
# return action.get_active()
def alert(reason, icon=None):
assert reason
@ -87,15 +83,13 @@ if available:
# we need to use the filename here because the notifications daemon
# is an external application that does not know about our icon sets
icon_file = _icons.icon_file(NAME.lower()) if icon is None else _icons.icon_file(icon)
icon_file = icons.icon_file(NAME.lower()) if icon is None else icons.icon_file(icon)
n.update(NAME.lower(), reason, icon_file)
n.set_urgency(Notify.Urgency.NORMAL)
n.set_hint("desktop-entry", GLib.Variant("s", NAME.lower()))
try:
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug("showing %s", n)
n.show()
except Exception:
logger.exception("showing %s", n)
@ -116,16 +110,10 @@ if available:
message = reason
else:
message = _("unspecified reason")
# elif dev.status is None:
# message = _("unpaired")
# elif bool(dev.status):
# message = dev.status_string() or _("connected")
# else:
# message = _("offline")
# we need to use the filename here because the notifications daemon
# is an external application that does not know about our icon sets
icon_file = _icons.device_icon_file(dev.name, dev.kind) if icon is None else _icons.icon_file(icon)
icon_file = icons.device_icon_file(dev.name, dev.kind) if icon is None else icons.icon_file(icon)
n.update(summary, message, icon_file)
n.set_urgency(Notify.Urgency.NORMAL)
@ -134,11 +122,9 @@ if available:
n.set_hint("value", GLib.Variant("i", progress))
try:
# if logger.isEnabledFor(logging.DEBUG):
# logger.debug("showing %s", n)
n.show()
return n.show()
except Exception:
logger.exception("showing %s", n)
logger.exception(f"showing {n}")
else:

File diff suppressed because it is too large Load Diff

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,14 +17,16 @@
import logging
from enum import Enum
from gi.repository import GLib
from gi.repository import Gtk
from logitech_receiver import hidpp10_constants as _hidpp10_constants
from logitech_receiver import hidpp10_constants
from solaar.i18n import _
from solaar.i18n import ngettext
from . import icons as _icons
from . import icons
logger = logging.getLogger(__name__)
@ -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}
@ -45,10 +52,20 @@ def create(receiver):
else:
text = _("Other receivers are only compatible with a few devices.")
text += "\n\n"
text += _("Turn on the device you want to pair.")
text += _("For most devices, turn on the device you want to pair.")
text += _("If the device is already turned on, turn it off and on again.")
text += "\n"
text += _("The device must not be paired with a nearby powered-on receiver.")
text += "\n"
text += _("If the device is already turned on, turn it off and on again.")
text += _(
"For devices with multiple channels, "
"press, hold, and release the button for the channel you wish to pair"
"\n"
"or use the channel switch button to select a channel "
"and then press, hold, and release the channel switch button."
)
text += "\n"
text += _("The channel indicator light should be blinking rapidly.")
if receiver.remaining_pairings() and receiver.remaining_pairings() >= 0:
text += (
ngettext(
@ -82,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)
@ -108,7 +124,7 @@ def _check_lock_state(assistant, receiver, count):
return True
elif receiver.pairing.discovering and receiver.pairing.device_address and receiver.pairing.device_name:
add = receiver.pairing.device_address
ent = 20 if receiver.pairing.device_kind == _hidpp10_constants.DEVICE_KIND.keyboard else 10
ent = 20 if receiver.pairing.device_kind == hidpp10_constants.DEVICE_KIND.keyboard else 10
if receiver.pair_device(address=add, authentication=receiver.pairing.device_authentication, entropy=ent):
return True
else:
@ -119,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:
@ -148,21 +161,28 @@ 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}
page_text = _("Enter passcode on %(name)s.") % {"name": name}
page_text += "\n"
if authentication & 0x01:
page_text += _("Type %(passcode)s and then press the enter key.") % {"passcode": receiver.pairing.device_passkey}
page_text += _("Type %(passcode)s and then press the enter key.") % {
"passcode": receiver.pairing.device_passkey,
}
else:
passcode = ", ".join(
[_("right") if bit == "1" else _("left") for bit in f"{int(receiver.pairing.device_passkey):010b}"]
)
page_text += _("Press %(code)s\nand then press left and right buttons simultaneously.") % {"code": passcode}
page = _create_page(assistant, Gtk.AssistantPageType.PROGRESS, intro_text, "preferences-desktop-peripherals", page_text)
page = _create_page(
assistant,
Gtk.AssistantPageType.PROGRESS,
intro_text,
"preferences-desktop-peripherals",
page_text,
)
assistant.set_page_complete(page, True)
assistant.next_page()
@ -175,7 +195,13 @@ def _create_assistant(receiver, ok, finish, title, text):
assistant.set_resizable(False)
assistant.set_role("pair-device")
if ok:
page_intro = _create_page(assistant, Gtk.AssistantPageType.PROGRESS, title, "preferences-desktop-peripherals", text)
page_intro = _create_page(
assistant,
Gtk.AssistantPageType.PROGRESS,
title,
"preferences-desktop-peripherals",
text,
)
spinner = Gtk.Spinner()
spinner.set_visible(True)
spinner.start()
@ -183,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
@ -200,8 +226,8 @@ def _create_success_page(assistant, device):
header = Gtk.Label(label=_("Found a new device:"))
page.pack_start(header, False, False, 0)
device_icon = Gtk.Image()
icon_name = _icons.device_icon_name(device.name, device.kind)
device_icon.set_from_icon_name(icon_name, _icons.LARGE_SIZE)
icon_name = icons.device_icon_name(device.name, device.kind)
device_icon.set_from_icon_name(icon_name, icons.LARGE_SIZE)
page.pack_start(device_icon, True, True, 0)
device_label = Gtk.Label()
device_label.set_markup(f"<b>{device.name}</b>")
@ -217,7 +243,7 @@ def _create_success_page(assistant, device):
assistant.commit()
def _create_failure_page(assistant, error):
def _create_failure_page(assistant, error) -> None:
header = _("Pairing failed") + ": " + _(str(error)) + "."
if "timeout" in str(error):
text = _("Make sure your device is within range, and has a decent battery charge.")
@ -232,7 +258,7 @@ def _create_failure_page(assistant, error):
assistant.commit()
def _create_page(assistant, kind, header=None, icon_name=None, text=None):
def _create_page(assistant, kind, header=None, icon_name=None, text=None) -> Gtk.VBox:
p = Gtk.VBox(homogeneous=False, spacing=8)
assistant.append_page(p)
assistant.set_page_type(p, kind)

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